diff --git a/.eslintignore b/.eslintignore
index e019f3ca..e9b4f077 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -1,3 +1,4 @@
node_modules/
main.js
+archive/
diff --git a/.eslintrc b/.eslintrc
index f9d396c2..c64703dd 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -43,5 +43,14 @@
]
}
]
- }
+ },
+ "overrides": [
+ {
+ "files": ["__tests__/**/*.ts", "unit/**/*.ts"],
+ "rules": {
+ "no-restricted-imports": "off",
+ "@typescript-eslint/no-var-requires": "off"
+ }
+ }
+ ]
}
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..5573a5dd
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+__tests__/** filter=git-crypt diff=git-crypt
diff --git a/.github/workflows/e2e-smoke.yml b/.github/workflows/e2e-smoke.yml
deleted file mode 100644
index 8d10f839..00000000
--- a/.github/workflows/e2e-smoke.yml
+++ /dev/null
@@ -1,320 +0,0 @@
-name: E2E Smoke Tests
-
-on:
- push:
- branches: [merge-hsm]
- workflow_dispatch:
- inputs:
- branch:
- description: 'Branch to test (e.g. origin/main, origin/feature-x)'
- required: true
- default: 'origin/main'
-
-env:
- VM_NAME: ${{ secrets.GCP_VM_NAME }}
- VM_ZONE: ${{ secrets.GCP_VM_ZONE }}
- GCP_PROJECT: ${{ secrets.GCP_PROJECT }}
- GCP_USE_IAP: "true"
-
-jobs:
- smoke-test:
- runs-on: ubuntu-latest
- timeout-minutes: 30
-
- steps:
- - name: Auth to GCP
- uses: google-github-actions/auth@v2
- with:
- credentials_json: ${{ secrets.GCP_SA_KEY }}
-
- - name: Setup gcloud
- uses: google-github-actions/setup-gcloud@v2
-
- - name: Clone relay-harness
- run: |
- mkdir -p ~/.ssh
- echo '${{ secrets.HARNESS_DEPLOY_KEY }}' > ~/.ssh/harness-key
- chmod 600 ~/.ssh/harness-key
- cat >> ~/.ssh/config << 'EOF'
- Host github.com
- IdentityFile ~/.ssh/harness-key
- StrictHostKeyChecking accept-new
- EOF
-
- git clone git@github.com:No-Instructions/relay-harness.git ~/relay-harness
-
- - name: Ensure VM is running
- run: |
- STATUS=$(gcloud compute instances describe "$VM_NAME" \
- --zone="$VM_ZONE" --project="$GCP_PROJECT" \
- --format='get(status)' 2>/dev/null || echo "NOT_FOUND")
-
- if [ "$STATUS" = "NOT_FOUND" ]; then
- echo "VM does not exist. Creating via harness infra scripts..."
- cd ~/relay-harness
- GCP_PROJECT="$GCP_PROJECT" GCP_ZONE="$VM_ZONE" ./infra/gcp-linux-vm.sh create
- elif [ "$STATUS" = "TERMINATED" ] || [ "$STATUS" = "STOPPED" ]; then
- echo "Starting stopped VM..."
- gcloud compute instances start "$VM_NAME" \
- --zone="$VM_ZONE" --project="$GCP_PROJECT"
- elif [ "$STATUS" = "RUNNING" ]; then
- echo "VM already running"
- else
- echo "Unexpected VM status: $STATUS"
- exit 1
- fi
-
- # Pre-generate SSH key quietly (gcloud's ssh-keygen leaks to stdout)
- ssh-keygen -t rsa -b 3072 -f ~/.ssh/google_compute_engine -N "" -q 2>/dev/null || true
- # Set connect timeout so SSH attempts fail fast instead of hanging
- echo -e "\nHost *\n ConnectTimeout 10" >> ~/.ssh/config
-
- echo "Waiting for SSH readiness..."
- for i in $(seq 1 30); do
- if gcloud compute ssh "$VM_NAME" --quiet \
- --zone="$VM_ZONE" --project="$GCP_PROJECT" \
- --tunnel-through-iap \
- --command="echo ready" 2>/dev/null; then
- echo "VM is ready"
- break
- fi
- if [ "$i" -eq 30 ]; then
- echo "Timed out waiting for VM SSH"
- exit 1
- fi
- sleep 5
- done
-
- - name: Provision VM credentials
- run: |
- echo '${{ secrets.GCP_SA_KEY }}' > /tmp/sa-key.json
- gcloud compute scp --quiet /tmp/sa-key.json "$VM_NAME":~/ci-sa-key.json \
- --zone="$VM_ZONE" --project="$GCP_PROJECT" \
- --tunnel-through-iap 2>/dev/null
- rm -f /tmp/sa-key.json
-
- echo '${{ secrets.HARNESS_DEPLOY_KEY }}' > /tmp/deploy-key
- chmod 600 /tmp/deploy-key
- gcloud compute scp --quiet /tmp/deploy-key "$VM_NAME":~/ci-deploy-key \
- --zone="$VM_ZONE" --project="$GCP_PROJECT" \
- --tunnel-through-iap 2>/dev/null
- rm -f /tmp/deploy-key
-
- # GitHub App private key for posting Check Runs
- echo '${{ secrets.E2E_APP_KEY }}' > /tmp/github-app-key.pem
- chmod 600 /tmp/github-app-key.pem
- gcloud compute ssh "$VM_NAME" --quiet \
- --zone="$VM_ZONE" --project="$GCP_PROJECT" \
- --tunnel-through-iap \
- --command="mkdir -p ~/.config/relay-e2e" 2>/dev/null
- gcloud compute scp --quiet /tmp/github-app-key.pem "$VM_NAME":~/.config/relay-e2e/github-app-key.pem \
- --zone="$VM_ZONE" --project="$GCP_PROJECT" \
- --tunnel-through-iap 2>/dev/null
- rm -f /tmp/github-app-key.pem
-
- # R2 credentials for ci.system3.dev uploads
- gcloud compute ssh "$VM_NAME" --quiet \
- --zone="$VM_ZONE" --project="$GCP_PROJECT" \
- --tunnel-through-iap \
- --command="cat > ~/.config/relay-e2e/r2-env.sh << 'CREDS'
- export CI_R2_ENDPOINT='${{ secrets.CI_R2_ENDPOINT }}'
- export CI_R2_ACCESS_KEY_ID='${{ secrets.CI_R2_ACCESS_KEY_ID }}'
- export CI_R2_SECRET_ACCESS_KEY='${{ secrets.CI_R2_SECRET_ACCESS_KEY }}'
- CREDS
- chmod 600 ~/.config/relay-e2e/r2-env.sh" 2>/dev/null
-
- - name: Run smoke tests on VM
- run: |
- BRANCH="${{ github.event.inputs.branch || format('origin/{0}', github.ref_name) }}"
-
- gcloud compute ssh "$VM_NAME" --quiet \
- --zone="$VM_ZONE" --project="$GCP_PROJECT" \
- --tunnel-through-iap \
- --command="bash -s -- '$BRANCH'" << 'REMOTE_SCRIPT'
- set -eo pipefail
- BRANCH="$1"
-
- # Load R2 credentials
- [ -f ~/.config/relay-e2e/r2-env.sh ] && source ~/.config/relay-e2e/r2-env.sh
-
- # Ensure Obsidian AppImage is installed
- export OBSIDIAN_PATH="$HOME/obsidian/Obsidian.AppImage"
- if [ ! -f "$OBSIDIAN_PATH" ]; then
- echo "Installing Obsidian AppImage..."
- mkdir -p ~/obsidian
- OBSIDIAN_VERSION="1.8.9"
- wget -q -O "$OBSIDIAN_PATH" \
- "https://github.com/obsidianmd/obsidian-releases/releases/download/v${OBSIDIAN_VERSION}/Obsidian-${OBSIDIAN_VERSION}.AppImage"
- chmod +x "$OBSIDIAN_PATH"
- echo "Installed Obsidian $OBSIDIAN_VERSION"
- fi
-
- # Start virtual display for headless Obsidian
- export DISPLAY=:99
- Xvfb :99 -screen 0 1920x1080x24 >/dev/null 2>&1 &
- XVFB_PID=$!
- sleep 1
-
- # Clean stale test reports from previous runs
- rm -rf /tmp/test-reports
-
- # Activate GCP service account for gcloud/GCS uploads
- gcloud auth activate-service-account --key-file="$HOME/ci-sa-key.json" 2>/dev/null
-
- # Set up SSH for private repo access
- mkdir -p ~/.ssh
- cp ~/ci-deploy-key ~/.ssh/ci-deploy-key
- chmod 600 ~/.ssh/ci-deploy-key
- if ! grep -q 'ci-deploy-key' ~/.ssh/config 2>/dev/null; then
- cat >> ~/.ssh/config << 'SSHCFG'
- Host github.com
- IdentityFile ~/.ssh/ci-deploy-key
- StrictHostKeyChecking accept-new
- SSHCFG
- fi
-
- # Clone or update relay-plugin (public)
- if [ ! -d ~/relay-plugin ]; then
- git clone https://github.com/No-Instructions/Relay.git ~/relay-plugin
- fi
- cd ~/relay-plugin
- git fetch --all --prune
- git checkout -- .
- git clean -fd
- # Detached HEAD checkout — works for origin/branch, tags, and SHAs
- git checkout --detach "$BRANCH"
-
- # Clone or update relay-harness (private)
- if [ ! -d ~/relay-harness ]; then
- git clone git@github.com:No-Instructions/relay-harness.git ~/relay-harness
- fi
- cd ~/relay-harness && git checkout main && git pull
-
- # Install playwright test dependencies
- cd ~/relay-harness/playwright && npm install --ignore-scripts 2>/dev/null
-
- # Run tests using the harness test-branch.sh orchestrator
- export RELAY_PLUGIN_DIR=~/relay-plugin
- cd ~/relay-harness
-
- TEST_EXIT=0
- ./scripts/test-branch.sh "$BRANCH" --upload --checks || TEST_EXIT=$?
-
- # Extract results for the GitHub Actions runner
- LATEST=$(ls -td /tmp/test-reports/runs/*/* 2>/dev/null | head -1)
- if [ -n "$LATEST" ] && [ -f "$LATEST/summary.json" ]; then
- cp "$LATEST/summary.json" ~/test-summary.json
-
- COMMIT=$(basename "$(dirname "$LATEST")")
- EXEC_ID=$(basename "$LATEST")
- REPORT_URL="https://ci.system3.dev/runs/${COMMIT}/${EXEC_ID}/index.html"
- echo "{\"reportUrl\": \"$REPORT_URL\", \"commit\": \"$COMMIT\", \"execId\": \"$EXEC_ID\"}" > ~/test-metadata.json
- else
- echo '{"summary":{"total":0,"pass":0,"fail":0},"tests":[],"state":"error"}' > ~/test-summary.json
- echo '{"reportUrl":"","commit":"","execId":""}' > ~/test-metadata.json
- fi
-
- # Clean up virtual display
- kill $XVFB_PID 2>/dev/null || true
-
- exit $TEST_EXIT
- REMOTE_SCRIPT
-
- - name: Clean up VM credentials
- if: always()
- run: |
- gcloud compute ssh "$VM_NAME" --quiet \
- --zone="$VM_ZONE" --project="$GCP_PROJECT" \
- --tunnel-through-iap \
- --command="rm -f ~/ci-sa-key.json ~/ci-deploy-key ~/.ssh/ci-deploy-key ~/.config/relay-e2e/github-app-key.pem ~/.config/relay-e2e/r2-env.sh" 2>/dev/null || true
-
- - name: Fetch results
- if: always()
- run: |
- gcloud compute scp --quiet "$VM_NAME":~/test-summary.json ./summary.json \
- --zone="$VM_ZONE" --project="$GCP_PROJECT" \
- --tunnel-through-iap 2>/dev/null || true
- gcloud compute scp --quiet "$VM_NAME":~/test-metadata.json ./metadata.json \
- --zone="$VM_ZONE" --project="$GCP_PROJECT" \
- --tunnel-through-iap 2>/dev/null || true
-
- - name: Generate job summary
- if: always()
- uses: actions/github-script@v7
- with:
- script: |
- const fs = require('fs');
-
- let summary, metadata;
- try {
- summary = JSON.parse(fs.readFileSync('summary.json', 'utf-8'));
- } catch {
- core.summary.addRaw('## E2E Smoke Test Results\n\n:x: **Failed to retrieve test results from VM**\n');
- await core.summary.write();
- core.setFailed('No test results available');
- return;
- }
- try {
- metadata = JSON.parse(fs.readFileSync('metadata.json', 'utf-8'));
- } catch {
- metadata = { reportUrl: '', commit: '', execId: '' };
- }
-
- const s = summary.summary || {};
- const tests = summary.tests || [];
- const reportBase = (metadata.reportUrl || '').replace(/\/index\.html$/, '');
-
- // Status emoji map
- const statusEmoji = {
- pass: ':white_check_mark:',
- fail: ':x:',
- expected_fail: ':warning:',
- unexpected_pass: ':sparkles:',
- };
-
- // Verdict
- const totalDur = Math.round((s.duration || 0) / 1000);
- const verdictEmoji = (s.fail || 0) > 0 ? ':x:' : ':white_check_mark:';
- const verdictText = (s.fail || 0) > 0 ? 'FAIL' : 'PASS';
- const parts = [];
- if (s.pass) parts.push(`${s.pass} pass`);
- if (s.fail) parts.push(`${s.fail} fail`);
- if (s.expectedFail) parts.push(`${s.expectedFail} expected-fail`);
- if (s.unexpectedPass) parts.push(`${s.unexpectedPass} unexpected-pass`);
-
- let md = `## E2E Smoke Test Results\n\n`;
- md += `${verdictEmoji} **${verdictText}** | ${parts.join(', ')} | ${totalDur}s`;
- if (metadata.reportUrl) {
- md += ` | [Full Report](${metadata.reportUrl})`;
- }
- md += `\n\n`;
-
- // Test table
- md += `| Test | Status | Assertions | Duration |\n`;
- md += `|------|--------|------------|----------|\n`;
- for (const t of tests) {
- const emoji = statusEmoji[t.status] || ':grey_question:';
- const dur = `${(t.duration / 1000).toFixed(1)}s`;
- const assertions = `${t.passedAssertions ?? '?'}/${t.assertions ?? '?'}`;
- const testUrl = reportBase ? `${reportBase}/${t.dir}/index.html` : '';
- const name = testUrl ? `[${t.name}](${testUrl})` : t.name;
-
- let row = `| ${name} | ${emoji} ${t.status} | ${assertions} | ${dur} |`;
- md += row + '\n';
-
- // Add error detail for failures
- if (t.status === 'fail' && t.firstError) {
- const errText = t.firstError.split('\n')[0].substring(0, 120);
- md += `| | | \`${errText}\` | |\n`;
- }
- }
-
- core.summary.addRaw(md);
- await core.summary.write();
-
- // Set job status
- if ((s.total || 0) === 0 || summary.state === 'error') {
- core.setFailed('No tests were executed');
- } else if ((s.fail || 0) > 0) {
- core.setFailed(`${s.fail} test(s) failed`);
- }
diff --git a/.github/workflows/e2e-standard.yml b/.github/workflows/e2e-standard.yml
new file mode 100644
index 00000000..f93a21f1
--- /dev/null
+++ b/.github/workflows/e2e-standard.yml
@@ -0,0 +1,672 @@
+name: E2E standard suite
+
+on:
+ push:
+ branches: [merge-hsm]
+ tags:
+ - '0.8.0-rc*'
+ workflow_dispatch:
+ inputs:
+ ref:
+ description: 'Ref to test (e.g. origin/main, origin/feature-x, a tag, or a SHA)'
+ required: true
+ default: 'origin/main'
+ tests:
+ description: 'Optional comma-separated Level 2 strands to run (e.g. test-basic-open-close,test-share-folder)'
+ required: false
+ default: ''
+
+concurrency:
+ group: e2e-standard-linux
+ cancel-in-progress: false
+
+env:
+ VM_ZONE: ${{ secrets.GCP_VM_ZONE }}
+ GCP_PROJECT: ${{ secrets.GCP_PROJECT }}
+ GCP_USE_IAP: "true"
+
+jobs:
+ standard-test:
+ name: ${{ format('standard-test ({0})', matrix.shard.name) }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ strategy:
+ fail-fast: false
+ max-parallel: 7
+ matrix:
+ shard:
+ - name: foundation-core
+ slug: foundation-core
+ tests: test-basic-open-close,test-folder-lifecycle,test-view-reuse-file-switching
+ - name: foundation-share
+ slug: foundation-share
+ tests: test-share-folder,test-in-note-status
+ - name: sync-state
+ slug: sync-state
+ tests: test-idle-registration,test-active-editor-sync,test-offline-banner,test-binary-free-user
+ - name: conflicts-differ
+ slug: conflicts-differ
+ tests: test-differ-resolve,test-trigger-differ,test-differ-dismissal
+ - name: conflicts-resolution
+ slug: conflicts-resolution
+ tests: test-conflict-dismiss,test-conflict-cancel,test-upgrade-no-lca-conflict
+ - name: bulk-upload
+ slug: bulk
+ tests: test-bulk-upload
+ - name: restart-heavy
+ slug: restart
+ tests: test-bases-checkbox-sync,test-obsidian-restart,test-cold-reopen-external-edit
+ env:
+ VM_NAME: ${{ format('{0}-{1}', secrets.GCP_VM_NAME, matrix.shard.slug) }}
+ SHARD_NAME: ${{ matrix.shard.name }}
+ SHARD_SLUG: ${{ matrix.shard.slug }}
+ SHARD_TESTS: ${{ matrix.shard.tests }}
+ GCP_VM_DISK_SIZE: 20GB
+ GCP_VM_DISK_TYPE: pd-standard
+
+ steps:
+ - name: Auth to GCP
+ uses: google-github-actions/auth@v2
+ with:
+ credentials_json: ${{ secrets.GCP_SA_KEY }}
+
+ - name: Setup gcloud
+ uses: google-github-actions/setup-gcloud@v2
+
+ - name: Select tests for shard
+ id: select-tests
+ run: |
+ TARGETED_TESTS="${{ github.event.inputs.tests || '' }}"
+ SHARD_TESTS="${{ matrix.shard.tests }}"
+
+ select_tests() {
+ local available="$1"
+ local wanted="$2"
+ local filtered=""
+ local test match
+
+ IFS=',' read -r -a available_tests <<< "$available"
+ IFS=',' read -r -a wanted_tests <<< "$wanted"
+
+ for test in "${available_tests[@]}"; do
+ test="${test//[[:space:]]/}"
+ [ -n "$test" ] || continue
+ for match in "${wanted_tests[@]}"; do
+ match="${match//[[:space:]]/}"
+ [ -n "$match" ] || continue
+ if [ "$test" = "$match" ]; then
+ filtered="${filtered:+$filtered,}$test"
+ break
+ fi
+ done
+ done
+
+ printf '%s' "$filtered"
+ }
+
+ SELECTED_TESTS="$SHARD_TESTS"
+ if [ -n "$TARGETED_TESTS" ]; then
+ SELECTED_TESTS="$(select_tests "$SHARD_TESTS" "$TARGETED_TESTS")"
+ fi
+
+ if [ -n "$SELECTED_TESTS" ]; then
+ echo "should_run=true" >> "$GITHUB_OUTPUT"
+ echo "selected_tests=$SELECTED_TESTS" >> "$GITHUB_OUTPUT"
+ else
+ echo "should_run=false" >> "$GITHUB_OUTPUT"
+ echo "selected_tests=" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Clone relay-harness
+ if: steps.select-tests.outputs.should_run == 'true'
+ run: |
+ mkdir -p ~/.ssh
+ echo '${{ secrets.HARNESS_DEPLOY_KEY }}' > ~/.ssh/harness-key
+ chmod 600 ~/.ssh/harness-key
+ cat > ~/.ssh/known_hosts << 'EOF'
+ github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl
+ github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=
+ github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=
+ EOF
+ chmod 600 ~/.ssh/known_hosts
+ cat > ~/.ssh/config << 'EOF'
+ Host github.com
+ IdentityFile ~/.ssh/harness-key
+ IdentitiesOnly yes
+ StrictHostKeyChecking yes
+ UserKnownHostsFile ~/.ssh/known_hosts
+ EOF
+
+ git clone git@github.com:No-Instructions/relay-harness.git ~/relay-harness
+
+ - name: Ensure VM is running
+ if: steps.select-tests.outputs.should_run == 'true'
+ run: |
+ STATUS=""
+ for i in $(seq 1 60); do
+ STATUS=$(gcloud compute instances describe "$VM_NAME" \
+ --zone="$VM_ZONE" --project="$GCP_PROJECT" \
+ --format='get(status)' 2>/dev/null || echo "NOT_FOUND")
+
+ case "$STATUS" in
+ PROVISIONING|STAGING|STOPPING|SUSPENDING|REPAIRING)
+ echo "VM is $STATUS; waiting for a stable state ($i/60) ..."
+ sleep 10
+ ;;
+ *)
+ break
+ ;;
+ esac
+ done
+
+ if [ "$STATUS" = "NOT_FOUND" ]; then
+ echo "VM does not exist. Creating via harness infra scripts..."
+ cd ~/relay-harness
+ GCP_PROJECT="$GCP_PROJECT" GCP_ZONE="$VM_ZONE" GCP_VM_NAME="$VM_NAME" \
+ GCP_VM_DISK_SIZE="$GCP_VM_DISK_SIZE" GCP_VM_DISK_TYPE="$GCP_VM_DISK_TYPE" \
+ ./infra/gcp-linux-vm.sh create
+ elif [ "$STATUS" = "TERMINATED" ] || [ "$STATUS" = "STOPPED" ]; then
+ echo "Starting stopped VM..."
+ gcloud compute instances start "$VM_NAME" \
+ --zone="$VM_ZONE" --project="$GCP_PROJECT"
+ elif [ "$STATUS" = "RUNNING" ]; then
+ echo "VM already running"
+ else
+ echo "Unexpected VM status: $STATUS"
+ exit 1
+ fi
+
+ # Pre-generate SSH key quietly (gcloud's ssh-keygen leaks to stdout)
+ ssh-keygen -t rsa -b 3072 -f ~/.ssh/google_compute_engine -N "" -q 2>/dev/null || true
+ # Set connect timeout so SSH attempts fail fast instead of hanging
+ echo -e "\nHost *\n ConnectTimeout 10" >> ~/.ssh/config
+
+ echo "Waiting for SSH readiness..."
+ for i in $(seq 1 30); do
+ if gcloud compute ssh "$VM_NAME" --quiet \
+ --zone="$VM_ZONE" --project="$GCP_PROJECT" \
+ --tunnel-through-iap \
+ --command="echo ready" 2>/dev/null; then
+ echo "VM is ready"
+ break
+ fi
+ if [ "$i" -eq 30 ]; then
+ echo "Timed out waiting for VM SSH"
+ exit 1
+ fi
+ sleep 5
+ done
+
+ - name: Provision VM credentials
+ if: steps.select-tests.outputs.should_run == 'true'
+ run: |
+ scp_retry() {
+ local src="$1"
+ local dest="$2"
+ local label="$3"
+ local attempt
+
+ for attempt in $(seq 1 5); do
+ if gcloud compute scp --quiet "$src" "$VM_NAME":"$dest" \
+ --zone="$VM_ZONE" --project="$GCP_PROJECT" \
+ --tunnel-through-iap; then
+ return 0
+ fi
+ echo "Retrying $label scp ($attempt/5) ..."
+ sleep 5
+ done
+
+ echo "Failed to copy $label to VM after 5 attempts"
+ return 1
+ }
+
+ ssh_retry() {
+ local label="$1"
+ local command="$2"
+ local attempt
+
+ for attempt in $(seq 1 5); do
+ if gcloud compute ssh "$VM_NAME" --quiet \
+ --zone="$VM_ZONE" --project="$GCP_PROJECT" \
+ --tunnel-through-iap \
+ --command="$command"; then
+ return 0
+ fi
+ echo "Retrying $label ssh command ($attempt/5) ..."
+ sleep 5
+ done
+
+ echo "Failed $label ssh command after 5 attempts"
+ return 1
+ }
+
+ echo '${{ secrets.GCP_SA_KEY }}' > /tmp/sa-key.json
+ scp_retry /tmp/sa-key.json ~/ci-sa-key.json "service-account key"
+ rm -f /tmp/sa-key.json
+
+ echo '${{ secrets.HARNESS_DEPLOY_KEY }}' > /tmp/deploy-key
+ chmod 600 /tmp/deploy-key
+ scp_retry /tmp/deploy-key ~/ci-deploy-key "deploy key"
+ rm -f /tmp/deploy-key
+
+ # GitHub App private key for posting Check Runs
+ echo '${{ secrets.E2E_APP_KEY }}' > /tmp/github-app-key.pem
+ chmod 600 /tmp/github-app-key.pem
+ ssh_retry "relay-e2e config dir creation" "mkdir -p ~/.config/relay-e2e"
+ scp_retry /tmp/github-app-key.pem ~/.config/relay-e2e/github-app-key.pem "GitHub App key"
+ rm -f /tmp/github-app-key.pem
+
+ # R2 credentials for ci.system3.dev uploads
+ ssh_retry "R2 env write" "cat > ~/.config/relay-e2e/r2-env.sh << 'CREDS'
+ export CI_R2_ENDPOINT='${{ secrets.CI_R2_ENDPOINT }}'
+ export CI_R2_ACCESS_KEY_ID='${{ secrets.CI_R2_ACCESS_KEY_ID }}'
+ export CI_R2_SECRET_ACCESS_KEY='${{ secrets.CI_R2_SECRET_ACCESS_KEY }}'
+ CREDS
+ chmod 600 ~/.config/relay-e2e/r2-env.sh"
+
+ - name: Run standard suite on VM
+ if: steps.select-tests.outputs.should_run == 'true'
+ run: |
+ INPUT_REF="${{ github.event.inputs.ref || '' }}"
+ REF_TYPE="${{ github.ref_type }}"
+ REF_NAME="${{ github.ref_name }}"
+
+ if [ -n "$INPUT_REF" ]; then
+ REF="$INPUT_REF"
+ elif [ "$REF_TYPE" = "tag" ]; then
+ REF="refs/tags/$REF_NAME"
+ else
+ REF="origin/$REF_NAME"
+ fi
+
+ SELECTED_TESTS="${{ steps.select-tests.outputs.selected_tests }}"
+ SHARD_SLUG_ARG="${{ matrix.shard.slug }}"
+
+ gcloud compute ssh "$VM_NAME" --quiet \
+ --zone="$VM_ZONE" --project="$GCP_PROJECT" \
+ --tunnel-through-iap \
+ --command="bash -s -- '$REF' '$SELECTED_TESTS' '$SHARD_SLUG_ARG'" 2>/dev/null << 'REMOTE_SCRIPT'
+ set -eo pipefail
+ REF="$1"
+ SELECTED_TESTS="$2"
+ SHARD_SLUG="$3"
+
+ # Load R2 credentials
+ [ -f ~/.config/relay-e2e/r2-env.sh ] && source ~/.config/relay-e2e/r2-env.sh
+
+ # Ensure Obsidian AppImage is installed
+ export OBSIDIAN_PATH="$HOME/obsidian/Obsidian.AppImage"
+ if [ ! -f "$OBSIDIAN_PATH" ]; then
+ echo "Installing Obsidian AppImage..."
+ mkdir -p ~/obsidian
+ OBSIDIAN_VERSION="1.8.9"
+ wget -q -O "$OBSIDIAN_PATH" \
+ "https://github.com/obsidianmd/obsidian-releases/releases/download/v${OBSIDIAN_VERSION}/Obsidian-${OBSIDIAN_VERSION}.AppImage"
+ chmod +x "$OBSIDIAN_PATH"
+ echo "Installed Obsidian $OBSIDIAN_VERSION"
+ fi
+
+ # Start virtual display for headless Obsidian
+ export DISPLAY=:99
+ Xvfb :99 -screen 0 1920x1080x24 >/dev/null 2>&1 &
+ XVFB_PID=$!
+ sleep 1
+
+ # Clean stale test reports from previous runs
+ rm -rf /tmp/test-reports
+ rm -f ~/test-summary.json ~/test-metadata.json
+
+ # Activate GCP service account for gcloud/GCS uploads
+ gcloud auth activate-service-account --key-file="$HOME/ci-sa-key.json" 2>/dev/null
+
+ # Set up SSH for private repo access
+ mkdir -p ~/.ssh
+ cp ~/ci-deploy-key ~/.ssh/ci-deploy-key
+ chmod 600 ~/.ssh/ci-deploy-key
+ cat > ~/.ssh/known_hosts << 'EOF'
+ github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl
+ github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=
+ github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=
+ EOF
+ chmod 600 ~/.ssh/known_hosts
+ export GIT_SSH_COMMAND="ssh -i $HOME/.ssh/ci-deploy-key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
+
+ # Clone or update relay-plugin (public)
+ if [ ! -d ~/relay-plugin ]; then
+ git clone https://github.com/No-Instructions/Relay.git ~/relay-plugin
+ fi
+ cd ~/relay-plugin
+ git fetch origin --force --prune \
+ '+refs/heads/*:refs/remotes/origin/*' \
+ '+refs/tags/*:refs/tags/*'
+ git checkout -- .
+ git clean -fd
+ # Detached HEAD checkout — works for origin/branch, tags, and SHAs
+ git checkout --detach "$REF"
+ echo "Relay product checkout:"
+ git show -s --format=' commit: %H%n subject: %s'
+ echo " describe: $(git describe --tags --always --long 2>/dev/null || git rev-parse --short HEAD)"
+
+ # Clone or update relay-harness (private)
+ if [ ! -d ~/relay-harness ]; then
+ git clone git@github.com:No-Instructions/relay-harness.git ~/relay-harness
+ fi
+ cd ~/relay-harness
+ git fetch origin --prune
+ git checkout -- .
+ git clean -fd
+ git checkout --detach origin/main
+
+ # Clear stale repo-local test instances left by canceled or crashed runs
+ echo "Clearing stale repo-local slots..."
+ ./scripts/stop-obsidian.sh >/dev/null 2>&1 || true
+
+ # Install playwright test dependencies
+ cd ~/relay-harness/playwright && npm install --ignore-scripts 2>/dev/null
+
+ # Run tests using the harness standard-suite orchestrator
+ export RELAY_PLUGIN_DIR=~/relay-plugin
+ cd ~/relay-harness
+
+ TEST_EXIT=0
+ TEST_CMD=(./scripts/run-e2e-suite.sh "$REF" --upload)
+ TEST_CMD+=("--tests=$SELECTED_TESTS")
+ RELAY_RUN_EXEC_SUFFIX="$SHARD_SLUG" "${TEST_CMD[@]}" || TEST_EXIT=$?
+
+ # Extract results for the GitHub Actions runner
+ LATEST_SUMMARY=$(find /tmp/test-reports/runs -mindepth 3 -maxdepth 3 -type f -name summary.json -print0 2>/dev/null \
+ | xargs -0 ls -t 2>/dev/null \
+ | head -1)
+ if [ -n "$LATEST_SUMMARY" ]; then
+ LATEST="$(dirname "$LATEST_SUMMARY")"
+ cp "$LATEST_SUMMARY" ~/test-summary.json
+
+ COMMIT=$(basename "$(dirname "$LATEST")")
+ EXEC_ID=$(basename "$LATEST")
+ REPORT_URL="https://ci.system3.dev/runs/${COMMIT}/${EXEC_ID}/index.html"
+ echo "{\"reportUrl\": \"$REPORT_URL\", \"commit\": \"$COMMIT\", \"execId\": \"$EXEC_ID\"}" > ~/test-metadata.json
+ else
+ echo '{"summary":{"total":0,"pass":0,"fail":0},"tests":[],"state":"error"}' > ~/test-summary.json
+ echo '{"reportUrl":"","commit":"","execId":""}' > ~/test-metadata.json
+ fi
+
+ # Clean up virtual display
+ kill $XVFB_PID 2>/dev/null || true
+
+ exit $TEST_EXIT
+ REMOTE_SCRIPT
+
+ - name: Clean up VM credentials
+ if: always() && steps.select-tests.outputs.should_run == 'true'
+ run: |
+ gcloud compute ssh "$VM_NAME" --quiet \
+ --zone="$VM_ZONE" --project="$GCP_PROJECT" \
+ --tunnel-through-iap \
+ --command="rm -f ~/ci-sa-key.json ~/ci-deploy-key ~/.ssh/ci-deploy-key ~/.config/relay-e2e/github-app-key.pem ~/.config/relay-e2e/r2-env.sh" 2>/dev/null || true
+
+ - name: Fetch results
+ if: always() && steps.select-tests.outputs.should_run == 'true'
+ run: |
+ rm -f summary.json metadata.json
+ gcloud compute scp --quiet "$VM_NAME":~/test-summary.json ./summary.json \
+ --zone="$VM_ZONE" --project="$GCP_PROJECT" \
+ --tunnel-through-iap 2>/dev/null || true
+ gcloud compute scp --quiet "$VM_NAME":~/test-metadata.json ./metadata.json \
+ --zone="$VM_ZONE" --project="$GCP_PROJECT" \
+ --tunnel-through-iap 2>/dev/null || true
+
+ - name: Upload shard metadata
+ if: always() && steps.select-tests.outputs.should_run == 'true'
+ uses: actions/upload-artifact@v4
+ with:
+ name: standard-${{ matrix.shard.slug }}-metadata
+ path: |
+ summary.json
+ metadata.json
+ if-no-files-found: ignore
+
+ - name: Request shard VM stop
+ if: always() && steps.select-tests.outputs.should_run == 'true'
+ run: |
+ STATUS=$(gcloud compute instances describe "$VM_NAME" \
+ --zone="$VM_ZONE" --project="$GCP_PROJECT" \
+ --format='get(status)' 2>/dev/null || echo "NOT_FOUND")
+
+ if [ "$STATUS" = "RUNNING" ]; then
+ echo "Requesting asynchronous stop for shard VM..."
+ gcloud compute instances stop "$VM_NAME" \
+ --zone="$VM_ZONE" --project="$GCP_PROJECT" \
+ --quiet --async 2>/dev/null || true
+ else
+ echo "Skipping stop request (status: $STATUS)"
+ fi
+
+ - name: Generate job summary
+ if: always()
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const fs = require('fs');
+ const shardName = process.env.SHARD_NAME || 'shard';
+ const shouldRun = process.env.RUN_SHARD === 'true';
+
+ if (!shouldRun) {
+ core.summary.addRaw(`## E2E standard suite results (${shardName})\n\n:warning: **SKIPPED** | no matching tests for this shard\n`);
+ await core.summary.write();
+ return;
+ }
+
+ let summary, metadata;
+ try {
+ summary = JSON.parse(fs.readFileSync('summary.json', 'utf-8'));
+ } catch {
+ core.summary.addRaw(`## E2E standard suite results (${shardName})\n\n:x: **Failed to retrieve test results from VM**\n`);
+ await core.summary.write();
+ core.setFailed('No test results available');
+ return;
+ }
+ try {
+ metadata = JSON.parse(fs.readFileSync('metadata.json', 'utf-8'));
+ } catch {
+ metadata = { reportUrl: '', commit: '', execId: '' };
+ }
+
+ const s = summary.summary || {};
+ const tests = summary.tests || [];
+ const reportBase = (metadata.reportUrl || '').replace(/\/index\.html$/, '');
+
+ // Status emoji map
+ const statusEmoji = {
+ pass: ':white_check_mark:',
+ fail: ':x:',
+ expected_fail: ':warning:',
+ unexpected_pass: ':sparkles:',
+ };
+
+ // Verdict
+ const totalDur = Math.round((s.duration || 0) / 1000);
+ const verdictEmoji = (s.fail || 0) > 0 ? ':x:' : ':white_check_mark:';
+ const verdictText = (s.fail || 0) > 0 ? 'FAIL' : 'PASS';
+ const parts = [];
+ if (s.pass) parts.push(`${s.pass} pass`);
+ if (s.fail) parts.push(`${s.fail} fail`);
+ if (s.expectedFail) parts.push(`${s.expectedFail} expected-fail`);
+ if (s.unexpectedPass) parts.push(`${s.unexpectedPass} unexpected-pass`);
+
+ let md = `## E2E standard suite results (${shardName})\n\n`;
+ md += `${verdictEmoji} **${verdictText}** | ${parts.join(', ')} | ${totalDur}s`;
+ if (metadata.reportUrl) {
+ md += ` | [Full Report](${metadata.reportUrl})`;
+ }
+ md += `\n\n`;
+
+ // Test table
+ md += `| Test | Status | Assertions | Duration |\n`;
+ md += `|------|--------|------------|----------|\n`;
+ for (const t of tests) {
+ const emoji = statusEmoji[t.status] || ':grey_question:';
+ const dur = `${(t.duration / 1000).toFixed(1)}s`;
+ const assertions = `${t.passedAssertions ?? '?'}/${t.assertions ?? '?'}`;
+ const testUrl = reportBase ? `${reportBase}/${t.dir}/index.html` : '';
+ const name = testUrl ? `[${t.name}](${testUrl})` : t.name;
+
+ let row = `| ${name} | ${emoji} ${t.status} | ${assertions} | ${dur} |`;
+ md += row + '\n';
+
+ // Add error detail for failures
+ if (t.status === 'fail' && t.firstError) {
+ const errText = t.firstError.split('\n')[0].substring(0, 120);
+ md += `| | | \`${errText}\` | |\n`;
+ }
+ }
+
+ core.summary.addRaw(md);
+ await core.summary.write();
+
+ // Set job status
+ if ((s.total || 0) === 0 || summary.state === 'error') {
+ core.setFailed('No tests were executed');
+ } else if ((s.fail || 0) > 0) {
+ core.setFailed(`${s.fail} test(s) failed`);
+ }
+ env:
+ RUN_SHARD: ${{ steps.select-tests.outputs.should_run }}
+
+ publish-report-site:
+ name: publish report site
+ runs-on: ubuntu-latest
+ needs: standard-test
+ if: always()
+ steps:
+ - name: Clone relay-harness
+ run: |
+ mkdir -p ~/.ssh
+ echo '${{ secrets.HARNESS_DEPLOY_KEY }}' > ~/.ssh/harness-key
+ chmod 600 ~/.ssh/harness-key
+ cat > ~/.ssh/known_hosts << 'EOF'
+ github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl
+ github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=
+ github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=
+ EOF
+ chmod 600 ~/.ssh/known_hosts
+ cat > ~/.ssh/config << 'EOF'
+ Host github.com
+ IdentityFile ~/.ssh/harness-key
+ IdentitiesOnly yes
+ StrictHostKeyChecking yes
+ UserKnownHostsFile ~/.ssh/known_hosts
+ EOF
+
+ git clone git@github.com:No-Instructions/relay-harness.git ~/relay-harness
+
+ - name: Download shard metadata
+ uses: actions/download-artifact@v4
+ with:
+ pattern: standard-*-metadata
+ path: shard-metadata
+
+ - name: Resolve tested commit
+ id: resolve-commit
+ run: |
+ set -euo pipefail
+
+ COMMIT="$(node <<'NODE'
+ const fs = require('fs');
+ const path = require('path');
+
+ function walk(dir) {
+ if (!fs.existsSync(dir)) return [];
+ const out = [];
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) out.push(...walk(full));
+ else if (entry.name === 'metadata.json') out.push(full);
+ }
+ return out;
+ }
+
+ for (const file of walk('shard-metadata')) {
+ try {
+ const metadata = JSON.parse(fs.readFileSync(file, 'utf8'));
+ if (metadata.commit) {
+ console.log(metadata.commit);
+ process.exit(0);
+ }
+ } catch {
+ // Try the next shard metadata file.
+ }
+ }
+ NODE
+ )"
+
+ if [ -z "$COMMIT" ]; then
+ INPUT_REF="${{ github.event.inputs.ref || '' }}"
+ REF_TYPE="${{ github.ref_type }}"
+ REF_NAME="${{ github.ref_name }}"
+
+ if [ -n "$INPUT_REF" ]; then
+ REF="$INPUT_REF"
+ elif [ "$REF_TYPE" = "tag" ]; then
+ REF="refs/tags/$REF_NAME"
+ else
+ REF="origin/$REF_NAME"
+ fi
+
+ git init /tmp/relay-ref >/dev/null
+ git -C /tmp/relay-ref remote add origin https://github.com/No-Instructions/Relay.git
+ git -C /tmp/relay-ref fetch origin --force --prune \
+ '+refs/heads/*:refs/remotes/origin/*' \
+ '+refs/tags/*:refs/tags/*'
+ COMMIT="$(git -C /tmp/relay-ref rev-parse "$REF^{commit}")"
+ fi
+
+ COMMIT_SHORT="$(printf '%s' "$COMMIT" | cut -c1-7)"
+ echo "commit=$COMMIT_SHORT" >> "$GITHUB_OUTPUT"
+ echo "Resolved report-site commit: $COMMIT_SHORT"
+
+ - name: Install report-site dependencies
+ run: npm ci --prefix ~/relay-harness/report-site
+
+ - name: Publish report site
+ env:
+ CI_R2_ENDPOINT: ${{ secrets.CI_R2_ENDPOINT }}
+ CI_R2_ACCESS_KEY_ID: ${{ secrets.CI_R2_ACCESS_KEY_ID }}
+ CI_R2_SECRET_ACCESS_KEY: ${{ secrets.CI_R2_SECRET_ACCESS_KEY }}
+ CI_SYSTEM3_DEV_CF_ACCESS_CLIENT_ID: ${{ secrets.CI_SYSTEM3_DEV_CF_ACCESS_CLIENT_ID }}
+ CI_SYSTEM3_DEV_CF_ACCESS_CLIENT_SECRET: ${{ secrets.CI_SYSTEM3_DEV_CF_ACCESS_CLIENT_SECRET }}
+ TARGET_COMMIT: ${{ steps.resolve-commit.outputs.commit }}
+ run: |
+ set -euo pipefail
+ cd ~/relay-harness
+
+ COMMIT_SET="$(node --input-type=module <<'NODE'
+ import { readJson } from './scripts/lib/ci-artifact-store.mjs';
+
+ const target = process.env.TARGET_COMMIT || '';
+ const manifest = await readJson('runs/manifest.json');
+ const existing = (manifest?.commitGroups || manifest?.runs || [])
+ .map(entry => entry.sha || entry.id || entry.runId)
+ .filter(Boolean);
+ console.log([...new Set([target, ...existing].filter(Boolean))].join(','));
+ NODE
+ )"
+
+ if [ -z "$COMMIT_SET" ]; then
+ echo "No commit set resolved for report-site publish"
+ exit 1
+ fi
+
+ READBACK_ARGS=()
+ if [ -n "${CI_SYSTEM3_DEV_CF_ACCESS_CLIENT_ID:-}" ] && [ -n "${CI_SYSTEM3_DEV_CF_ACCESS_CLIENT_SECRET:-}" ]; then
+ READBACK_ARGS=(--live-readback --live-readback-base-url=https://ci.system3.dev)
+ else
+ echo "Cloudflare Access readback credentials not configured; skipping live readback"
+ fi
+
+ node scripts/publish-report-site-static.mjs \
+ --commit="$COMMIT_SET" \
+ --refresh-commit="$TARGET_COMMIT" \
+ --materialize-missing-commits \
+ --rebuild-manifest-from-commits \
+ --upload \
+ "${READBACK_ARGS[@]}"
diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml
index 076ea3ba..637e936f 100644
--- a/.github/workflows/unit-tests.yaml
+++ b/.github/workflows/unit-tests.yaml
@@ -1,15 +1,549 @@
name: Unit Tests
+
on:
push:
+ branches: [main, merge-hsm]
+ tags:
+ - '0.8.0-rc*'
+ workflow_dispatch:
+ inputs:
+ ref:
+ description: 'Ref to test (e.g. origin/main, origin/feature-x, a tag, or a SHA)'
+ required: true
+ default: 'origin/main'
+
+concurrency:
+ group: unit-tests-private
+ cancel-in-progress: false
+
+env:
+ VM_ZONE: ${{ secrets.GCP_VM_ZONE }}
+ GCP_PROJECT: ${{ secrets.GCP_PROJECT }}
+ GCP_USE_IAP: "true"
jobs:
- test:
+ unit-test:
+ name: private unit tests
runs-on: ubuntu-latest
+ timeout-minutes: 45
+ env:
+ VM_NAME: ${{ format('{0}-unit', secrets.GCP_VM_NAME) }}
+ GCP_VM_DISK_SIZE: 20GB
+ GCP_VM_DISK_TYPE: pd-standard
+
steps:
- - uses: actions/checkout@v4
- - name: Use Node.js
- uses: actions/setup-node@v4
- with:
- node-version: '20.x'
- - run: npm ci
- - run: npm test
+ - name: Auth to GCP
+ uses: google-github-actions/auth@v2
+ with:
+ credentials_json: ${{ secrets.GCP_SA_KEY }}
+
+ - name: Setup gcloud
+ uses: google-github-actions/setup-gcloud@v2
+
+ - name: Clone relay-harness
+ run: |
+ mkdir -p ~/.ssh
+ echo '${{ secrets.HARNESS_DEPLOY_KEY }}' > ~/.ssh/harness-key
+ chmod 600 ~/.ssh/harness-key
+ cat > ~/.ssh/known_hosts << 'EOF'
+ github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl
+ github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=
+ github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=
+ EOF
+ chmod 600 ~/.ssh/known_hosts
+ cat > ~/.ssh/config << 'EOF'
+ Host github.com
+ IdentityFile ~/.ssh/harness-key
+ IdentitiesOnly yes
+ StrictHostKeyChecking yes
+ UserKnownHostsFile ~/.ssh/known_hosts
+ EOF
+
+ git clone git@github.com:No-Instructions/relay-harness.git ~/relay-harness
+
+ - name: Ensure VM is running
+ run: |
+ STATUS=""
+ for i in $(seq 1 60); do
+ STATUS=$(gcloud compute instances describe "$VM_NAME" \
+ --zone="$VM_ZONE" --project="$GCP_PROJECT" \
+ --format='get(status)' 2>/dev/null || echo "NOT_FOUND")
+
+ case "$STATUS" in
+ PROVISIONING|STAGING|STOPPING|SUSPENDING|REPAIRING)
+ echo "VM is $STATUS; waiting for a stable state ($i/60) ..."
+ sleep 10
+ ;;
+ *)
+ break
+ ;;
+ esac
+ done
+
+ if [ "$STATUS" = "NOT_FOUND" ]; then
+ echo "VM does not exist. Creating via harness infra scripts..."
+ cd ~/relay-harness
+ GCP_PROJECT="$GCP_PROJECT" GCP_ZONE="$VM_ZONE" GCP_VM_NAME="$VM_NAME" \
+ GCP_VM_DISK_SIZE="$GCP_VM_DISK_SIZE" GCP_VM_DISK_TYPE="$GCP_VM_DISK_TYPE" \
+ ./infra/gcp-linux-vm.sh create
+ elif [ "$STATUS" = "TERMINATED" ] || [ "$STATUS" = "STOPPED" ]; then
+ echo "Starting stopped VM..."
+ gcloud compute instances start "$VM_NAME" \
+ --zone="$VM_ZONE" --project="$GCP_PROJECT"
+ elif [ "$STATUS" = "RUNNING" ]; then
+ echo "VM already running"
+ else
+ echo "Unexpected VM status: $STATUS"
+ exit 1
+ fi
+
+ ssh-keygen -t rsa -b 3072 -f ~/.ssh/google_compute_engine -N "" -q 2>/dev/null || true
+ echo -e "\nHost *\n ConnectTimeout 10" >> ~/.ssh/config
+
+ echo "Waiting for SSH readiness..."
+ for i in $(seq 1 30); do
+ if gcloud compute ssh "$VM_NAME" --quiet \
+ --zone="$VM_ZONE" --project="$GCP_PROJECT" \
+ --tunnel-through-iap \
+ --command="echo ready" 2>/dev/null; then
+ echo "VM is ready"
+ break
+ fi
+ if [ "$i" -eq 30 ]; then
+ echo "Timed out waiting for VM SSH"
+ exit 1
+ fi
+ sleep 5
+ done
+
+ - name: Provision VM credentials
+ run: |
+ scp_retry() {
+ local src="$1"
+ local dest="$2"
+ local label="$3"
+ local attempt
+
+ for attempt in $(seq 1 5); do
+ if gcloud compute scp --quiet "$src" "$VM_NAME":"$dest" \
+ --zone="$VM_ZONE" --project="$GCP_PROJECT" \
+ --tunnel-through-iap; then
+ return 0
+ fi
+ echo "Retrying $label scp ($attempt/5) ..."
+ sleep 5
+ done
+
+ echo "Failed to copy $label to VM after 5 attempts"
+ return 1
+ }
+
+ ssh_retry() {
+ local label="$1"
+ local command="$2"
+ local attempt
+
+ for attempt in $(seq 1 5); do
+ if gcloud compute ssh "$VM_NAME" --quiet \
+ --zone="$VM_ZONE" --project="$GCP_PROJECT" \
+ --tunnel-through-iap \
+ --command="$command"; then
+ return 0
+ fi
+ echo "Retrying $label ssh command ($attempt/5) ..."
+ sleep 5
+ done
+
+ echo "Failed $label ssh command after 5 attempts"
+ return 1
+ }
+
+ ssh_retry "relay-e2e config dir creation" "mkdir -p ~/.config/relay-e2e"
+
+ echo '${{ secrets.GCP_SA_KEY }}' > /tmp/sa-key.json
+ scp_retry /tmp/sa-key.json ~/ci-sa-key.json "service-account key"
+ rm -f /tmp/sa-key.json
+
+ echo '${{ secrets.HARNESS_DEPLOY_KEY }}' > /tmp/deploy-key
+ chmod 600 /tmp/deploy-key
+ scp_retry /tmp/deploy-key ~/ci-deploy-key "deploy key"
+ rm -f /tmp/deploy-key
+
+ cat > /tmp/relay-git-crypt-key.b64 << 'KEY'
+ ${{ secrets.RELAY_GIT_CRYPT_KEY_B64 }}
+ KEY
+ if [ ! -s /tmp/relay-git-crypt-key.b64 ]; then
+ echo "Missing RELAY_GIT_CRYPT_KEY_B64 secret"
+ exit 1
+ fi
+ base64 -d /tmp/relay-git-crypt-key.b64 > /tmp/relay-git-crypt-key
+ chmod 600 /tmp/relay-git-crypt-key
+ scp_retry /tmp/relay-git-crypt-key ~/.config/relay-e2e/relay-git-crypt-key "git-crypt key"
+ rm -f /tmp/relay-git-crypt-key /tmp/relay-git-crypt-key.b64
+
+ ssh_retry "R2 env write" "cat > ~/.config/relay-e2e/r2-env.sh << 'CREDS'
+ export CI_R2_ENDPOINT='${{ secrets.CI_R2_ENDPOINT }}'
+ export CI_R2_ACCESS_KEY_ID='${{ secrets.CI_R2_ACCESS_KEY_ID }}'
+ export CI_R2_SECRET_ACCESS_KEY='${{ secrets.CI_R2_SECRET_ACCESS_KEY }}'
+ CREDS
+ chmod 600 ~/.config/relay-e2e/r2-env.sh"
+
+ - name: Run unit tests on VM
+ run: |
+ INPUT_REF="${{ github.event.inputs.ref || '' }}"
+ REF_TYPE="${{ github.ref_type }}"
+ REF_NAME="${{ github.ref_name }}"
+
+ if [ -n "$INPUT_REF" ]; then
+ REF="$INPUT_REF"
+ elif [ "$REF_TYPE" = "tag" ]; then
+ REF="refs/tags/$REF_NAME"
+ else
+ REF="origin/$REF_NAME"
+ fi
+
+ gcloud compute ssh "$VM_NAME" --quiet \
+ --zone="$VM_ZONE" --project="$GCP_PROJECT" \
+ --tunnel-through-iap \
+ --command="bash -s -- '$REF'" 2>/dev/null << 'REMOTE_SCRIPT'
+ set -eo pipefail
+ REF="$1"
+
+ [ -f ~/.config/relay-e2e/r2-env.sh ] && source ~/.config/relay-e2e/r2-env.sh
+ rm -rf /tmp/test-reports
+ rm -f ~/test-summary.json ~/test-metadata.json
+
+ if ! command -v git-crypt >/dev/null 2>&1; then
+ sudo apt-get update -qq
+ sudo apt-get install -y -qq git-crypt
+ fi
+
+ gcloud auth activate-service-account --key-file="$HOME/ci-sa-key.json" 2>/dev/null
+
+ mkdir -p ~/.ssh
+ cp ~/ci-deploy-key ~/.ssh/ci-deploy-key
+ chmod 600 ~/.ssh/ci-deploy-key
+ cat > ~/.ssh/known_hosts << 'EOF'
+ github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl
+ github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=
+ github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=
+ EOF
+ chmod 600 ~/.ssh/known_hosts
+ export GIT_SSH_COMMAND="ssh -i $HOME/.ssh/ci-deploy-key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
+
+ if [ ! -d ~/relay-plugin ]; then
+ git clone https://github.com/No-Instructions/Relay.git ~/relay-plugin
+ fi
+ cd ~/relay-plugin
+ git fetch origin --force --prune \
+ '+refs/heads/*:refs/remotes/origin/*' \
+ '+refs/tags/0.8.0*:refs/tags/0.8.0*'
+ git checkout -- .
+ git clean -fd
+ git checkout --detach "$REF"
+ git crypt unlock ~/.config/relay-e2e/relay-git-crypt-key
+ if ! grep -q "describe\\|test\\|it(" __tests__/TestMinimark.ts; then
+ echo "git-crypt unlock did not expose the private unit tests"
+ exit 1
+ fi
+
+ COMMIT=$(git rev-parse --short HEAD)
+ EXEC_ID="unit-$(date -u +%Y%m%dT%H%M%SZ)"
+ REPORT_DIR="/tmp/test-reports/runs/$COMMIT/$EXEC_ID"
+ mkdir -p "$REPORT_DIR"
+ JEST_JSON="$REPORT_DIR/jest-results.json"
+ JEST_LOG="$REPORT_DIR/jest-output.txt"
+
+ echo "Relay product checkout:"
+ git show -s --format=' commit: %H%n subject: %s'
+ echo " describe: $(git describe --tags --always --long 2>/dev/null || git rev-parse --short HEAD)"
+
+ if [ ! -d ~/relay-harness ]; then
+ git clone git@github.com:No-Instructions/relay-harness.git ~/relay-harness
+ fi
+ cd ~/relay-harness
+ git fetch origin --prune
+ git checkout -- .
+ git clean -fd
+ git checkout --detach origin/main
+
+ cd ~/relay-plugin
+ npm ci
+
+ TEST_EXIT=0
+ npm test -- --silent --json --outputFile="$JEST_JSON" >"$JEST_LOG" 2>&1 || TEST_EXIT=$?
+
+ node - "$JEST_JSON" "$JEST_LOG" "$REPORT_DIR" "$COMMIT" "$EXEC_ID" <<'NODE'
+ const fs = require('fs');
+ const path = require('path');
+
+ const [jestJsonPath, jestLogPath, reportDir, commit, execId] = process.argv.slice(2);
+ const raw = JSON.parse(fs.readFileSync(jestJsonPath, 'utf8'));
+
+ const escapeHtml = value => String(value ?? '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ const safeSegment = value => String(value ?? '')
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 40);
+ const statusFor = status => {
+ if (status === 'passed') return 'pass';
+ if (status === 'pending' || status === 'todo' || status === 'skipped') return 'expected_fail';
+ return 'fail';
+ };
+
+ const started = Number(raw.startTime || Date.now());
+ const ended = Number(raw.endTime || Date.now());
+ const tests = [];
+ let index = 0;
+
+ for (const suite of raw.testResults || []) {
+ const suiteName = path.relative(process.cwd(), suite.name || '');
+ for (const assertion of suite.assertionResults || []) {
+ index += 1;
+ const name = [...(assertion.ancestorTitles || []), assertion.title]
+ .filter(Boolean)
+ .join(' > ');
+ const status = statusFor(assertion.status);
+ const dir = `unit-${String(index).padStart(4, '0')}`;
+ const failureMessages = assertion.failureMessages || [];
+ const duration = Number(assertion.duration || 0);
+ const test = {
+ name: name || suiteName || `unit ${index}`,
+ status,
+ assertions: 1,
+ passedAssertions: status === 'pass' ? 1 : 0,
+ duration,
+ dir,
+ suite: suiteName,
+ firstError: failureMessages[0] || '',
+ };
+ tests.push(test);
+
+ fs.mkdirSync(path.join(reportDir, dir), { recursive: true });
+ const passed = status === 'pass';
+ fs.writeFileSync(path.join(reportDir, dir, 'results.json'), JSON.stringify({
+ testName: test.name,
+ passed,
+ status,
+ startTime: started,
+ endTime: started + duration,
+ duration,
+ steps: [
+ {
+ name: 'Jest assertion',
+ passed,
+ error: failureMessages.join('\n\n'),
+ assertions: [
+ {
+ description: test.name,
+ expected: 'pass',
+ actual: status,
+ passed,
+ },
+ ],
+ actions: [],
+ trace: [],
+ },
+ ],
+ summary: {
+ totalSteps: 1,
+ passedSteps: passed ? 1 : 0,
+ totalAssertions: 1,
+ passedAssertions: passed ? 1 : 0,
+ totalActions: 0,
+ },
+ }, null, 2));
+ fs.writeFileSync(path.join(reportDir, dir, 'index.html'), `
+
+
+
+ ${escapeHtml(test.name)}
+
+
+
+ Back to unit report
+ ${escapeHtml(test.name)}
+ ${escapeHtml(status)}
+ Suite: ${escapeHtml(suiteName)}
+ Duration: ${escapeHtml(duration)}ms
+ ${failureMessages.length ? `Failure
${escapeHtml(failureMessages.join('\n\n'))}` : ''}
+
+
+ `);
+ }
+ }
+
+ const counts = tests.reduce((acc, test) => {
+ acc[test.status] = (acc[test.status] || 0) + 1;
+ return acc;
+ }, {});
+ const summary = {
+ summary: {
+ total: tests.length,
+ pass: counts.pass || 0,
+ fail: counts.fail || 0,
+ expectedFail: counts.expected_fail || 0,
+ unexpectedPass: 0,
+ duration: Math.max(0, ended - started),
+ },
+ tests,
+ state: (counts.fail || 0) > 0 ? 'failure' : 'success',
+ };
+
+ fs.writeFileSync(path.join(reportDir, 'summary.json'), JSON.stringify(summary, null, 2));
+
+ const rows = tests.map(test => `
+
+ | ${escapeHtml(test.name)} |
+ ${escapeHtml(test.status)} |
+ ${escapeHtml(test.suite)} |
+ ${escapeHtml(test.duration)}ms |
+
+ `).join('');
+ fs.writeFileSync(path.join(reportDir, 'index.html'), `
+
+
+
+ Relay Unit Tests ${escapeHtml(commit)}
+
+
+
+ Relay Unit Tests
+ Commit ${escapeHtml(commit)} / ${escapeHtml(execId)}
+
+ ${summary.summary.pass} pass
+ ${summary.summary.fail} fail
+ ${summary.summary.expectedFail} expected fail
+ ${summary.summary.total} total
+
+ Jest JSON | Jest output | Summary JSON
+
+ | Test | Status | Suite | Duration |
+ ${rows}
+
+
+
+ `);
+
+ if (!fs.existsSync(jestLogPath)) {
+ fs.writeFileSync(jestLogPath, '');
+ }
+ NODE
+
+ if ! node ~/relay-harness/scripts/upload-report.mjs "$REPORT_DIR" "$COMMIT" "$EXEC_ID" >"$REPORT_DIR/upload-output.txt" 2>&1; then
+ echo "Report upload failed"
+ exit 1
+ fi
+
+ cp "$REPORT_DIR/summary.json" ~/test-summary.json
+ REPORT_URL="https://ci.system3.dev/runs/${COMMIT}/${EXEC_ID}/index.html"
+ echo "{\"reportUrl\":\"$REPORT_URL\",\"commit\":\"$COMMIT\",\"execId\":\"$EXEC_ID\"}" > ~/test-metadata.json
+
+ exit $TEST_EXIT
+ REMOTE_SCRIPT
+
+ - name: Clean up VM credentials
+ if: always()
+ run: |
+ gcloud compute ssh "$VM_NAME" --quiet \
+ --zone="$VM_ZONE" --project="$GCP_PROJECT" \
+ --tunnel-through-iap \
+ --command="rm -f ~/ci-sa-key.json ~/ci-deploy-key ~/.ssh/ci-deploy-key ~/.config/relay-e2e/relay-git-crypt-key ~/.config/relay-e2e/r2-env.sh" 2>/dev/null || true
+
+ - name: Fetch results
+ if: always()
+ run: |
+ rm -f summary.json metadata.json
+ gcloud compute scp --quiet "$VM_NAME":~/test-summary.json ./summary.json \
+ --zone="$VM_ZONE" --project="$GCP_PROJECT" \
+ --tunnel-through-iap 2>/dev/null || true
+ gcloud compute scp --quiet "$VM_NAME":~/test-metadata.json ./metadata.json \
+ --zone="$VM_ZONE" --project="$GCP_PROJECT" \
+ --tunnel-through-iap 2>/dev/null || true
+
+ - name: Request VM stop
+ if: always()
+ run: |
+ STATUS=$(gcloud compute instances describe "$VM_NAME" \
+ --zone="$VM_ZONE" --project="$GCP_PROJECT" \
+ --format='get(status)' 2>/dev/null || echo "NOT_FOUND")
+
+ if [ "$STATUS" = "RUNNING" ]; then
+ echo "Requesting asynchronous stop for unit-test VM..."
+ gcloud compute instances stop "$VM_NAME" \
+ --zone="$VM_ZONE" --project="$GCP_PROJECT" \
+ --quiet --async 2>/dev/null || true
+ else
+ echo "Skipping stop request (status: $STATUS)"
+ fi
+
+ - name: Generate job summary
+ if: always()
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const fs = require('fs');
+
+ let summary, metadata;
+ try {
+ summary = JSON.parse(fs.readFileSync('summary.json', 'utf-8'));
+ } catch {
+ core.summary.addRaw('## Unit test results\n\n:x: **Failed to retrieve unit test results from VM**\n');
+ await core.summary.write();
+ core.setFailed('No unit test results available');
+ return;
+ }
+ try {
+ metadata = JSON.parse(fs.readFileSync('metadata.json', 'utf-8'));
+ } catch {
+ metadata = { reportUrl: '', commit: '', execId: '' };
+ }
+
+ const s = summary.summary || {};
+ const totalDur = Math.round((s.duration || 0) / 1000);
+ const fail = s.fail || 0;
+ const verdictEmoji = fail > 0 ? ':x:' : ':white_check_mark:';
+ const verdictText = fail > 0 ? 'FAIL' : 'PASS';
+ const parts = [];
+ parts.push(`${s.pass || 0} pass`);
+ if (fail) parts.push(`${fail} fail`);
+ if (s.expectedFail) parts.push(`${s.expectedFail} expected-fail`);
+
+ let md = '## Unit test results\n\n';
+ md += `${verdictEmoji} **${verdictText}** | ${parts.join(', ')} | ${s.total || 0} total | ${totalDur}s`;
+ if (metadata.reportUrl) {
+ md += ` | [Private Report](${metadata.reportUrl})`;
+ }
+ md += '\n';
+
+ core.summary.addRaw(md);
+ await core.summary.write();
+
+ if ((s.total || 0) === 0 || summary.state === 'error') {
+ core.setFailed('No unit tests were executed');
+ } else if (fail > 0) {
+ core.setFailed(`${fail} unit test(s) failed`);
+ }
diff --git a/.gitignore b/.gitignore
index 5ae8f945..2263fb1d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,11 +19,16 @@ data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
+# local archive
+archive/
+/bug-reports/
+
# igit
.igit
.igitignore
# developer harness
+.mcp.json
AGENTS.md
CLAUDE.md
AGENTS.md
@@ -37,10 +42,12 @@ infra/
scripts/
tickets/
specs/
+arch/
unit/
templates/
test-plans/
test-results/
+merge-queue/
prompts/
vision/
diff --git a/__tests__/Document.test.ts b/__tests__/Document.test.ts
new file mode 100644
index 00000000..c0bd6693
Binary files /dev/null and b/__tests__/Document.test.ts differ
diff --git a/__tests__/FileLogDetails.test.ts b/__tests__/FileLogDetails.test.ts
new file mode 100644
index 00000000..514f7541
Binary files /dev/null and b/__tests__/FileLogDetails.test.ts differ
diff --git a/__tests__/HasProvider.test.ts b/__tests__/HasProvider.test.ts
new file mode 100644
index 00000000..5e55770d
Binary files /dev/null and b/__tests__/HasProvider.test.ts differ
diff --git a/__tests__/MetadataRenderer.test.ts b/__tests__/MetadataRenderer.test.ts
new file mode 100644
index 00000000..a5f845fb
Binary files /dev/null and b/__tests__/MetadataRenderer.test.ts differ
diff --git a/__tests__/Observable.test.ts b/__tests__/Observable.test.ts
new file mode 100644
index 00000000..a809198f
Binary files /dev/null and b/__tests__/Observable.test.ts differ
diff --git a/__tests__/ObservableMap.test.ts b/__tests__/ObservableMap.test.ts
index 8174debf..4740b6cd 100644
Binary files a/__tests__/ObservableMap.test.ts and b/__tests__/ObservableMap.test.ts differ
diff --git a/__tests__/RelayDebugAPI-conflicts.test.ts b/__tests__/RelayDebugAPI-conflicts.test.ts
new file mode 100644
index 00000000..af82d96e
Binary files /dev/null and b/__tests__/RelayDebugAPI-conflicts.test.ts differ
diff --git a/__tests__/RemoteActivityIndex.test.ts b/__tests__/RemoteActivityIndex.test.ts
new file mode 100644
index 00000000..0e67ac8a
Binary files /dev/null and b/__tests__/RemoteActivityIndex.test.ts differ
diff --git a/__tests__/SharedFolder.test.ts b/__tests__/SharedFolder.test.ts
index 6d71ba62..0735d759 100644
Binary files a/__tests__/SharedFolder.test.ts and b/__tests__/SharedFolder.test.ts differ
diff --git a/__tests__/SyncFile.test.ts b/__tests__/SyncFile.test.ts
new file mode 100644
index 00000000..29780d36
Binary files /dev/null and b/__tests__/SyncFile.test.ts differ
diff --git a/__tests__/SyncSettings.test.ts b/__tests__/SyncSettings.test.ts
new file mode 100644
index 00000000..c6dd87a3
Binary files /dev/null and b/__tests__/SyncSettings.test.ts differ
diff --git a/__tests__/TestMinimark.ts b/__tests__/TestMinimark.ts
index a09b48f5..7520fd1c 100644
Binary files a/__tests__/TestMinimark.ts and b/__tests__/TestMinimark.ts differ
diff --git a/__tests__/TestSettingsStorage.ts b/__tests__/TestSettingsStorage.ts
index 0aa47651..2c1e71ee 100644
Binary files a/__tests__/TestSettingsStorage.ts and b/__tests__/TestSettingsStorage.ts differ
diff --git a/__tests__/TestSyncStore.ts b/__tests__/TestSyncStore.ts
index 9ff4e93e..6e752759 100644
Binary files a/__tests__/TestSyncStore.ts and b/__tests__/TestSyncStore.ts differ
diff --git a/__tests__/TestTimeProvider.ts b/__tests__/TestTimeProvider.ts
index f506e280..40973faf 100644
Binary files a/__tests__/TestTimeProvider.ts and b/__tests__/TestTimeProvider.ts differ
diff --git a/__tests__/TestTokenStore.ts b/__tests__/TestTokenStore.ts
index d9dc4553..12e05155 100644
Binary files a/__tests__/TestTokenStore.ts and b/__tests__/TestTokenStore.ts differ
diff --git a/__tests__/TextViewPlugin.test.ts b/__tests__/TextViewPlugin.test.ts
new file mode 100644
index 00000000..82db3ba3
Binary files /dev/null and b/__tests__/TextViewPlugin.test.ts differ
diff --git a/__tests__/ViewHookPlugin.test.ts b/__tests__/ViewHookPlugin.test.ts
new file mode 100644
index 00000000..32d4bf19
Binary files /dev/null and b/__tests__/ViewHookPlugin.test.ts differ
diff --git a/__tests__/client/provider.test.ts b/__tests__/client/provider.test.ts
new file mode 100644
index 00000000..4684721d
Binary files /dev/null and b/__tests__/client/provider.test.ts differ
diff --git a/__tests__/merge-hsm/E2ERecorded.test.ts b/__tests__/merge-hsm/E2ERecorded.test.ts
new file mode 100644
index 00000000..07e66354
Binary files /dev/null and b/__tests__/merge-hsm/E2ERecorded.test.ts differ
diff --git a/__tests__/merge-hsm/MergeHSM.test.ts b/__tests__/merge-hsm/MergeHSM.test.ts
new file mode 100644
index 00000000..d68dd680
Binary files /dev/null and b/__tests__/merge-hsm/MergeHSM.test.ts differ
diff --git a/__tests__/merge-hsm/MergeManager.test.ts b/__tests__/merge-hsm/MergeManager.test.ts
new file mode 100644
index 00000000..e31bbd4b
Binary files /dev/null and b/__tests__/merge-hsm/MergeManager.test.ts differ
diff --git a/__tests__/merge-hsm/cancel-persistence.test.ts b/__tests__/merge-hsm/cancel-persistence.test.ts
new file mode 100644
index 00000000..f21a71f6
Binary files /dev/null and b/__tests__/merge-hsm/cancel-persistence.test.ts differ
diff --git a/__tests__/merge-hsm/crdt-integrity.test.ts b/__tests__/merge-hsm/crdt-integrity.test.ts
new file mode 100644
index 00000000..33b3ca46
Binary files /dev/null and b/__tests__/merge-hsm/crdt-integrity.test.ts differ
diff --git a/__tests__/merge-hsm/cross-vault.test.ts b/__tests__/merge-hsm/cross-vault.test.ts
new file mode 100644
index 00000000..27356e6c
Binary files /dev/null and b/__tests__/merge-hsm/cross-vault.test.ts differ
diff --git a/__tests__/merge-hsm/deltaToPositionedChanges.test.ts b/__tests__/merge-hsm/deltaToPositionedChanges.test.ts
new file mode 100644
index 00000000..d8e2b056
Binary files /dev/null and b/__tests__/merge-hsm/deltaToPositionedChanges.test.ts differ
diff --git a/__tests__/merge-hsm/fork-syncgate.test.ts b/__tests__/merge-hsm/fork-syncgate.test.ts
new file mode 100644
index 00000000..49b66a9d
Binary files /dev/null and b/__tests__/merge-hsm/fork-syncgate.test.ts differ
diff --git a/__tests__/merge-hsm/frontmatter-repair.test.ts b/__tests__/merge-hsm/frontmatter-repair.test.ts
new file mode 100644
index 00000000..0b826d41
Binary files /dev/null and b/__tests__/merge-hsm/frontmatter-repair.test.ts differ
diff --git a/__tests__/merge-hsm/guid-remap.test.ts b/__tests__/merge-hsm/guid-remap.test.ts
new file mode 100644
index 00000000..e06cd916
Binary files /dev/null and b/__tests__/merge-hsm/guid-remap.test.ts differ
diff --git a/__tests__/merge-hsm/headless-conflict-resolution.test.ts b/__tests__/merge-hsm/headless-conflict-resolution.test.ts
new file mode 100644
index 00000000..d9b8c976
Binary files /dev/null and b/__tests__/merge-hsm/headless-conflict-resolution.test.ts differ
diff --git a/__tests__/merge-hsm/hibernation.test.ts b/__tests__/merge-hsm/hibernation.test.ts
new file mode 100644
index 00000000..36d02b98
Binary files /dev/null and b/__tests__/merge-hsm/hibernation.test.ts differ
diff --git a/__tests__/merge-hsm/invariants.test.ts b/__tests__/merge-hsm/invariants.test.ts
new file mode 100644
index 00000000..2cde35b6
Binary files /dev/null and b/__tests__/merge-hsm/invariants.test.ts differ
diff --git a/__tests__/merge-hsm/machine-definition.test.ts b/__tests__/merge-hsm/machine-definition.test.ts
new file mode 100644
index 00000000..00f89bde
Binary files /dev/null and b/__tests__/merge-hsm/machine-definition.test.ts differ
diff --git a/__tests__/merge-hsm/machine-edit-rewind.test.ts b/__tests__/merge-hsm/machine-edit-rewind.test.ts
new file mode 100644
index 00000000..e886fbbf
Binary files /dev/null and b/__tests__/merge-hsm/machine-edit-rewind.test.ts differ
diff --git a/__tests__/merge-hsm/machine-visualization.test.ts b/__tests__/merge-hsm/machine-visualization.test.ts
new file mode 100644
index 00000000..76a2c1eb
Binary files /dev/null and b/__tests__/merge-hsm/machine-visualization.test.ts differ
diff --git a/__tests__/merge-hsm/merge-path-adversarial.test.ts b/__tests__/merge-hsm/merge-path-adversarial.test.ts
new file mode 100644
index 00000000..e2d6a5bf
Binary files /dev/null and b/__tests__/merge-hsm/merge-path-adversarial.test.ts differ
diff --git a/__tests__/merge-hsm/network-resilience.test.ts b/__tests__/merge-hsm/network-resilience.test.ts
new file mode 100644
index 00000000..e5649258
Binary files /dev/null and b/__tests__/merge-hsm/network-resilience.test.ts differ
diff --git a/__tests__/merge-hsm/persistence.test.ts b/__tests__/merge-hsm/persistence.test.ts
new file mode 100644
index 00000000..f92510aa
Binary files /dev/null and b/__tests__/merge-hsm/persistence.test.ts differ
diff --git a/__tests__/merge-hsm/provider-integration-lifecycle.test.ts b/__tests__/merge-hsm/provider-integration-lifecycle.test.ts
new file mode 100644
index 00000000..5a0f06d0
Binary files /dev/null and b/__tests__/merge-hsm/provider-integration-lifecycle.test.ts differ
diff --git a/__tests__/merge-hsm/provider-sync-guard.test.ts b/__tests__/merge-hsm/provider-sync-guard.test.ts
new file mode 100644
index 00000000..71bf427b
Binary files /dev/null and b/__tests__/merge-hsm/provider-sync-guard.test.ts differ
diff --git a/__tests__/merge-hsm/recalculate-conflict-positions.test.ts b/__tests__/merge-hsm/recalculate-conflict-positions.test.ts
new file mode 100644
index 00000000..a164adb7
Binary files /dev/null and b/__tests__/merge-hsm/recalculate-conflict-positions.test.ts differ
diff --git a/__tests__/merge-hsm/recording.test.ts b/__tests__/merge-hsm/recording.test.ts
new file mode 100644
index 00000000..5ec58f74
Binary files /dev/null and b/__tests__/merge-hsm/recording.test.ts differ
diff --git a/__tests__/merge-hsm/replayBufferedEdits.test.ts b/__tests__/merge-hsm/replayBufferedEdits.test.ts
new file mode 100644
index 00000000..a83627e4
Binary files /dev/null and b/__tests__/merge-hsm/replayBufferedEdits.test.ts differ
diff --git a/__tests__/merge-hsm/state-helpers.test.ts b/__tests__/merge-hsm/state-helpers.test.ts
new file mode 100644
index 00000000..67f5e4c1
Binary files /dev/null and b/__tests__/merge-hsm/state-helpers.test.ts differ
diff --git a/__tests__/merge-hsm/state-vectors.test.ts b/__tests__/merge-hsm/state-vectors.test.ts
new file mode 100644
index 00000000..670911e1
Binary files /dev/null and b/__tests__/merge-hsm/state-vectors.test.ts differ
diff --git a/__tests__/merge-hsm/syncbridge.test.ts b/__tests__/merge-hsm/syncbridge.test.ts
new file mode 100644
index 00000000..ffc36f0b
Binary files /dev/null and b/__tests__/merge-hsm/syncbridge.test.ts differ
diff --git a/__tests__/merge-hsm/testing/fixtures.ts b/__tests__/merge-hsm/testing/fixtures.ts
new file mode 100644
index 00000000..08ac62ba
Binary files /dev/null and b/__tests__/merge-hsm/testing/fixtures.ts differ
diff --git a/__tests__/merge-hsm/testing/integration.ts b/__tests__/merge-hsm/testing/integration.ts
new file mode 100644
index 00000000..fdf7fbd1
Binary files /dev/null and b/__tests__/merge-hsm/testing/integration.ts differ
diff --git a/__tests__/merge-hsm/testing/replay-fixtures.ts b/__tests__/merge-hsm/testing/replay-fixtures.ts
new file mode 100644
index 00000000..64bd96ab
Binary files /dev/null and b/__tests__/merge-hsm/testing/replay-fixtures.ts differ
diff --git a/__tests__/merge-hsm/update-queues.test.ts b/__tests__/merge-hsm/update-queues.test.ts
new file mode 100644
index 00000000..a564432a
Binary files /dev/null and b/__tests__/merge-hsm/update-queues.test.ts differ
diff --git a/__tests__/mocks/MockTimeProvider.ts b/__tests__/mocks/MockTimeProvider.ts
index 2cd2d6f4..1b5eb082 100644
Binary files a/__tests__/mocks/MockTimeProvider.ts and b/__tests__/mocks/MockTimeProvider.ts differ
diff --git a/__tests__/ui/CanvasData.test.ts b/__tests__/ui/CanvasData.test.ts
new file mode 100644
index 00000000..1453f3a2
Binary files /dev/null and b/__tests__/ui/CanvasData.test.ts differ
diff --git a/__tests__/ui/FolderPillProgress.test.ts b/__tests__/ui/FolderPillProgress.test.ts
new file mode 100644
index 00000000..db031d29
Binary files /dev/null and b/__tests__/ui/FolderPillProgress.test.ts differ
diff --git a/__tests__/ui/SyncStatusModel.test.ts b/__tests__/ui/SyncStatusModel.test.ts
new file mode 100644
index 00000000..3b74cf8f
Binary files /dev/null and b/__tests__/ui/SyncStatusModel.test.ts differ
diff --git a/__tests__/ui/UserFacingError.test.ts b/__tests__/ui/UserFacingError.test.ts
new file mode 100644
index 00000000..f137ca97
Binary files /dev/null and b/__tests__/ui/UserFacingError.test.ts differ
diff --git a/__tests__/ui/timeAgo.test.ts b/__tests__/ui/timeAgo.test.ts
new file mode 100644
index 00000000..4304fcf8
Binary files /dev/null and b/__tests__/ui/timeAgo.test.ts differ
diff --git a/esbuild.config.mjs b/esbuild.config.mjs
index 5fa392cc..3a80197f 100644
--- a/esbuild.config.mjs
+++ b/esbuild.config.mjs
@@ -7,6 +7,7 @@ import { execSync } from "child_process";
import chokidar from "chokidar";
import path from "path";
import fs, { mkdirSync } from "fs";
+import crypto from "crypto";
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
@@ -14,15 +15,26 @@ if you want to view the source, please visit the github repository of this plugi
*/
`;
-const gitTag = execSync("git describe --tags --always", {
+const runBuildMetadataCommand = (command) => execSync(command, {
encoding: "utf8",
-}).trim();
+ stdio: ["ignore", "pipe", "pipe"],
+});
+
+const getGitTag = () => {
+ try {
+ return runBuildMetadataCommand("git describe --tags --always").trim();
+ } catch (e) {
+ return "dev";
+ }
+};
+const gitTag = getGitTag();
const develop = process.argv[2] === "develop";
const staging = process.argv[2] === "staging";
-const watch = process.argv[2] === "watch" || staging;
+const watch = process.argv[2] === "watch" || process.argv.includes("--watch");
const debug = process.argv[2] === "debug" || watch || staging || develop;
-const out = process.argv[3] || ".";
+const positionalArgs = process.argv.slice(3).filter((a) => !a.startsWith("--"));
+const out = positionalArgs[0] || ".";
const tld = staging ? "dev" : "md";
const apiUrl = `https://api.system3.${tld}`;
@@ -31,11 +43,58 @@ const healthUrl = `${apiUrl}/health?version=${gitTag}`;
console.log("git tag:", gitTag);
console.log("health URL", healthUrl);
+// Fingerprint the working tree: HEAD commit + hash of uncommitted changes.
+// Recompute this to check if a build artifact is stale.
+const getSourceFingerprint = () => {
+ try {
+ const head = runBuildMetadataCommand("git rev-parse --short HEAD").trim();
+ const diff = runBuildMetadataCommand("git diff HEAD");
+ if (!diff) return head;
+ const diffHash = crypto.createHash("sha1").update(diff).digest("hex").slice(0, 8);
+ return `${head}+${diffHash}`;
+ } catch (e) {
+ return "unknown";
+ }
+};
+
+const writeBuildStatus = (build, result) => {
+ const outdir = build.initialOptions.outfile;
+ // Derive vault name from outdir (e.g. "vaults/live1/.obsidian/..." → "live1")
+ const match = outdir.match(/vaults\/([^/]+)\//);
+ const name = match ? match[1] : path.basename(path.dirname(outdir));
+ const statusDir = ".build-status";
+ fs.mkdirSync(statusDir, { recursive: true });
+ const timestamp = new Date().toLocaleTimeString("en-GB", { hour12: false });
+ const fingerprint = getSourceFingerprint();
+ if (result.errors.length > 0) {
+ const errors = result.errors.map(e => e.text).join("\n");
+ fs.writeFileSync(`${statusDir}/${name}.log`, `FAIL ${timestamp} src:${fingerprint}\n${errors}\n`);
+ } else {
+ const stat = fs.statSync(outdir);
+ const kb = Math.round(stat.size / 1024);
+ fs.writeFileSync(`${statusDir}/${name}.log`, `OK ${timestamp} main.js ${kb}kb src:${fingerprint} → ${outdir}\n`);
+ }
+};
+
const NotifyPlugin = {
name: "on-end",
setup(build) {
build.onEnd((result) => {
- if (result.errors.length > 0) execSync(`notify-send "Build Failed"`);
+ if (result.errors.length > 0) {
+ execSync(`notify-send "Build Failed"`);
+ if (staging) {
+ writeBuildStatus(build, result);
+ }
+ } else if (watch) {
+ const tag = getGitTag();
+ const outfile = build.initialOptions.outfile;
+ const content = fs.readFileSync(outfile, "utf8");
+ fs.writeFileSync(outfile, content.replace(/__GIT_TAG__/g, tag));
+ console.log(`GIT_TAG: ${tag}`);
+ if (staging) {
+ writeBuildStatus(build, result);
+ }
+ }
});
},
};
@@ -43,6 +102,13 @@ const NotifyPlugin = {
const YjsInternalsPlugin = {
name: "yjs-internals",
setup(build) {
+ // Keep public Yjs imports and internal helper imports on the same
+ // module graph so Item/GC instanceof checks agree in the bundle.
+ build.onResolve({ filter: /^yjs$/ }, args => {
+ return {
+ path: path.resolve("node_modules/yjs/src/index.js"),
+ };
+ });
build.onResolve({ filter: /^yjs\/dist\/src\/internals$/ }, args => {
return {
path: path.resolve("node_modules/yjs/src/internals.js"),
@@ -77,7 +143,7 @@ const context = await esbuild.context({
format: "cjs",
plugins: [
esbuildSvelte({
- compilerOptions: { css: true },
+ compilerOptions: { css: "injected" },
preprocess: sveltePreprocess(),
}),
YjsInternalsPlugin,
@@ -89,7 +155,7 @@ const context = await esbuild.context({
sourcemap: debug ? "inline" : false,
define: {
BUILD_TYPE: debug ? '"debug"' : '"prod"',
- GIT_TAG: `"${gitTag}"`,
+ GIT_TAG: watch ? '"__GIT_TAG__"' : `"${gitTag}"`,
HEALTH_URL: `"${healthUrl}"`,
API_URL: `"${apiUrl}"`,
AUTH_URL: `"${authUrl}"`,
@@ -110,7 +176,7 @@ const copyFile = (src, dest) => {
const watchAndMove = (fnames, mapping) => {
// only usable on top level directory
const watcher = chokidar.watch(fnames, {
- ignored: /(^|[\/\\])\../, // ignore dotfiles
+ ignored: /(^|[\/\\])\./, // ignore dotfiles
persistent: true,
});
@@ -123,7 +189,7 @@ const watchAndMove = (fnames, mapping) => {
const mapping = debug ? { "manifest-beta.json": "manifest.json" } : {};
const manifest = debug ? "manifest-beta.json" : "manifest.json";
-const files = ["styles.css", manifest];
+const files = debug && out === "." ? ["styles.css"] : ["styles.css", manifest];
const updateManifest = (manifest) => {
const manifestPath = path.join(out, path.basename("manifest.json"));
@@ -155,7 +221,7 @@ if (watch) {
} else {
await context.rebuild();
move(files, mapping);
- if (!develop) {
+ if (!develop && out !== ".") {
updateManifest();
}
process.exit(0);
diff --git a/jest.config.js b/jest.config.js
index 9dd81529..71a8d404 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,22 +1,27 @@
-//module.exports = {
-// preset: 'ts-jest',
-// testEnvironment: 'node',
-//};
-//
/** @type {import('ts-jest').JestConfigWithTsJest} */
+
+// Resolve yjs paths dynamically so tests work in git worktrees where
+// node_modules may live in a parent directory rather than .
+const path = require("path");
+const yjsDir = path.dirname(require.resolve("yjs/package.json"));
+const yjsIndex = path.join(yjsDir, "src/index.js");
+const yjsInternals = path.join(yjsDir, "src/internals.js");
+
module.exports = {
- // [...]
- preset: "ts-jest/presets/default-esm", // or other ESM presets
+ preset: "ts-jest/presets/default-esm",
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1",
"^src/(.*)$": "/src/$1",
+ "^yjs$": yjsIndex,
+ "^yjs/dist/src/internals$": yjsInternals,
},
- testPathIgnorePatterns: ["/__tests__/mocks/"],
+ testPathIgnorePatterns: ["/__tests__/mocks/", "/__tests__/merge-hsm/testing/", "archive/", ".claude"],
globals: {
"BUILD_TYPE": "production",
},
+ transformIgnorePatterns: ["[\\/]node_modules[\\/](?!(yjs|lib0)[\\/])"],
transform: {
- ".ts": [
+ "\\.ts$": [
"ts-jest",
{
// Note: We shouldn't need to include `isolatedModules` here because it's a deprecated config option in TS 5,
@@ -26,5 +31,19 @@ module.exports = {
useESM: true,
},
],
+ "src/.+\\.js$": [
+ "ts-jest",
+ {
+ isolatedModules: true,
+ useESM: true,
+ },
+ ],
+ "node_modules[\\/](yjs|lib0)[\\/].+\\.js$": [
+ "ts-jest",
+ {
+ isolatedModules: true,
+ useESM: true,
+ },
+ ],
},
};
diff --git a/package-lock.json b/package-lock.json
index a694c217..748658ed 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
+ "cbor-x": "^1.6.0",
"diff": "^5.2.0",
"diff-match-patch": "^1.0.5",
"eventsource": "^2.0.2",
@@ -16,13 +17,13 @@
"jose": "^5.3.0",
"lucide-svelte": "^0.377.0",
"monkey-around": "^3.0.0",
+ "node-diff3": "^3.2.0",
"obsidian-daily-notes-interface": "^0.9.4",
"path-browserify": "^1.0.1",
"pocketbase": "^0.20.3",
"svelte-step-wizard": "^0.0.2",
"tslib": "2.4.0",
"uuid": "^9.0.1",
- "y-indexeddb": "^9.0.9",
"y-leveldb": "^0.1.2",
"y-protocols": "^1.0.5",
"y-websocket": "^1.5.3"
@@ -33,6 +34,7 @@
"@types/diff-match-patch": "^1.0.36",
"@types/eventsource": "^1.1.15",
"@types/jest": "^29.5.12",
+ "@types/js-yaml": "^4.0.9",
"@types/node": "^16.11.6",
"@types/path-browserify": "^1.0.2",
"@types/uuid": "^9.0.8",
@@ -43,6 +45,7 @@
"esbuild": "^0.27.0",
"esbuild-svelte": "^0.8.0",
"jest": "^29.7.0",
+ "js-yaml": "^4.1.1",
"obsidian": "^1.7.2",
"svelte": "^4.2.19",
"svelte-preprocess": "^5.1.4",
@@ -565,6 +568,84 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true
},
+ "node_modules/@cbor-extract/cbor-extract-darwin-arm64": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.0.tgz",
+ "integrity": "sha512-P7swiOAdF7aSi0H+tHtHtr6zrpF3aAq/W9FXx5HektRvLTM2O89xCyXF3pk7pLc7QpaY7AoaE8UowVf9QBdh3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@cbor-extract/cbor-extract-darwin-x64": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.2.0.tgz",
+ "integrity": "sha512-1liF6fgowph0JxBbYnAS7ZlqNYLf000Qnj4KjqPNW4GViKrEql2MgZnAsExhY9LSy8dnvA4C0qHEBgPrll0z0w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@cbor-extract/cbor-extract-linux-arm": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.2.0.tgz",
+ "integrity": "sha512-QeBcBXk964zOytiedMPQNZr7sg0TNavZeuUCD6ON4vEOU/25+pLhNN6EDIKJ9VLTKaZ7K7EaAriyYQ1NQ05s/Q==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@cbor-extract/cbor-extract-linux-arm64": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.2.0.tgz",
+ "integrity": "sha512-rQvhNmDuhjTVXSPFLolmQ47/ydGOFXtbR7+wgkSY0bdOxCFept1hvg59uiLPT2fVDuJFuEy16EImo5tE2x3RsQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@cbor-extract/cbor-extract-linux-x64": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.2.0.tgz",
+ "integrity": "sha512-cWLAWtT3kNLHSvP4RKDzSTX9o0wvQEEAj4SKvhWuOVZxiDAeQazr9A+PSiRILK1VYMLeDml89ohxCnUNQNQNCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@cbor-extract/cbor-extract-win32-x64": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.0.tgz",
+ "integrity": "sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
"node_modules/@codemirror/state": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz",
@@ -1025,25 +1106,30 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
- "version": "4.4.0",
- "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
- "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "eslint-visitor-keys": "^3.3.0"
+ "eslint-visitor-keys": "^3.4.3"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
"peerDependencies": {
"eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
}
},
"node_modules/@eslint-community/regexpp": {
- "version": "4.10.0",
- "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz",
- "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==",
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
@@ -1053,6 +1139,7 @@
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
"integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
"dev": true,
+ "license": "MIT",
"peer": true,
"dependencies": {
"ajv": "^6.12.4",
@@ -1073,23 +1160,26 @@
}
},
"node_modules/@eslint/js": {
- "version": "8.57.0",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz",
- "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==",
+ "version": "8.57.1",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
+ "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
"dev": true,
+ "license": "MIT",
"peer": true,
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@humanwhocodes/config-array": {
- "version": "0.11.14",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
- "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==",
+ "version": "0.13.0",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
+ "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==",
+ "deprecated": "Use @eslint/config-array instead",
"dev": true,
+ "license": "Apache-2.0",
"peer": true,
"dependencies": {
- "@humanwhocodes/object-schema": "^2.0.2",
+ "@humanwhocodes/object-schema": "^2.0.3",
"debug": "^4.3.1",
"minimatch": "^3.0.5"
},
@@ -1102,6 +1192,7 @@
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
"dev": true,
+ "license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=12.22"
@@ -1115,7 +1206,9 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
+ "deprecated": "Use @eslint/object-schema instead",
"dev": true,
+ "license": "BSD-3-Clause",
"peer": true
},
"node_modules/@istanbuljs/load-nyc-config": {
@@ -1553,6 +1646,7 @@
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
@@ -1566,6 +1660,7 @@
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">= 8"
}
@@ -1575,6 +1670,7 @@
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
@@ -1728,11 +1824,19 @@
"pretty-format": "^29.0.0"
}
},
+ "node_modules/@types/js-yaml": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
+ "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@types/node": {
"version": "16.18.86",
@@ -1753,10 +1857,11 @@
"dev": true
},
"node_modules/@types/semver": {
- "version": "7.5.8",
- "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
- "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==",
- "dev": true
+ "version": "7.7.1",
+ "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
+ "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@types/stack-utils": {
"version": "2.0.3",
@@ -1798,6 +1903,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
"integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.5.1",
"@typescript-eslint/scope-manager": "6.21.0",
@@ -1833,6 +1939,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
+ "license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@@ -1861,6 +1968,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz",
"integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0"
@@ -1878,6 +1986,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz",
"integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@typescript-eslint/typescript-estree": "6.21.0",
"@typescript-eslint/utils": "6.21.0",
@@ -1905,6 +2014,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz",
"integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": "^16.0.0 || >=18.0.0"
},
@@ -1918,6 +2028,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz",
"integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==",
"dev": true,
+ "license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0",
@@ -1942,9 +2053,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
+ "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1956,6 +2067,7 @@
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
"integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
@@ -1971,6 +2083,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz",
"integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@types/json-schema": "^7.0.12",
@@ -1996,6 +2109,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz",
"integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@typescript-eslint/types": "6.21.0",
"eslint-visitor-keys": "^3.4.1"
@@ -2009,10 +2123,11 @@
}
},
"node_modules/@ungap/structured-clone": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
- "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz",
+ "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==",
"dev": true,
+ "license": "ISC",
"peer": true
},
"node_modules/abstract-leveldown": {
@@ -2046,16 +2161,18 @@
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
"dev": true,
+ "license": "MIT",
"peer": true,
"peerDependencies": {
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/ajv": {
- "version": "6.12.6",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
- "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true,
+ "license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
@@ -2136,8 +2253,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
- "dev": true,
- "peer": true
+ "dev": true
},
"node_modules/aria-query": {
"version": "5.3.0",
@@ -2152,6 +2268,7 @@
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
"integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=8"
}
@@ -2324,9 +2441,9 @@
}
},
"node_modules/brace-expansion": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
+ "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2487,6 +2604,37 @@
}
]
},
+ "node_modules/cbor-extract": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/cbor-extract/-/cbor-extract-2.2.0.tgz",
+ "integrity": "sha512-Ig1zM66BjLfTXpNgKpvBePq271BPOvu8MR0Jl080yG7Jsl+wAZunfrwiwA+9ruzm/WEdIV5QF/bjDZTqyAIVHA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "node-gyp-build-optional-packages": "5.1.1"
+ },
+ "bin": {
+ "download-cbor-prebuilds": "bin/download-prebuilds.js"
+ },
+ "optionalDependencies": {
+ "@cbor-extract/cbor-extract-darwin-arm64": "2.2.0",
+ "@cbor-extract/cbor-extract-darwin-x64": "2.2.0",
+ "@cbor-extract/cbor-extract-linux-arm": "2.2.0",
+ "@cbor-extract/cbor-extract-linux-arm64": "2.2.0",
+ "@cbor-extract/cbor-extract-linux-x64": "2.2.0",
+ "@cbor-extract/cbor-extract-win32-x64": "2.2.0"
+ }
+ },
+ "node_modules/cbor-x": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/cbor-x/-/cbor-x-1.6.0.tgz",
+ "integrity": "sha512-0kareyRwHSkL6ws5VXHEf8uY1liitysCVJjlmhaLG+IXLqhSaOO+t63coaso7yjwEzWZzLy8fJo06gZDVQM9Qg==",
+ "license": "MIT",
+ "optionalDependencies": {
+ "cbor-extract": "^2.2.0"
+ }
+ },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -2724,6 +2872,7 @@
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true,
+ "license": "MIT",
"peer": true
},
"node_modules/deepmerge": {
@@ -2764,6 +2913,16 @@
"node": ">=8"
}
},
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/detect-newline": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@@ -2774,9 +2933,10 @@
}
},
"node_modules/diff": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz",
- "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==",
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz",
+ "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==",
+ "license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
@@ -2800,6 +2960,7 @@
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
"integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"path-type": "^4.0.0"
},
@@ -2812,6 +2973,7 @@
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
"dev": true,
+ "license": "Apache-2.0",
"peer": true,
"dependencies": {
"esutils": "^2.0.2"
@@ -2927,10 +3089,11 @@
}
},
"node_modules/esbuild-svelte": {
- "version": "0.8.0",
- "resolved": "https://registry.npmjs.org/esbuild-svelte/-/esbuild-svelte-0.8.0.tgz",
- "integrity": "sha512-uKcPf1kl2UGMjrfHChv4dLxGAvCNhf9s72mHo19ZhKP+LrVOuQkOM/g8GE7MiGpoqjpk8UHqL08uLRbSKXhmhw==",
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/esbuild-svelte/-/esbuild-svelte-0.8.2.tgz",
+ "integrity": "sha512-tG97WrhH/OH8wFCmRBk6sRggMVMPNZDktGx1jJcvh9Obvjwm8M1UYoXmliuLL+4fs/wfyVVwM2fvyNYz2e6UPQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.19"
},
@@ -2939,7 +3102,7 @@
},
"peerDependencies": {
"esbuild": ">=0.9.6",
- "svelte": ">=3.43.0 <5"
+ "svelte": ">=3.43.0 <6 || ^5.0.0-next.0"
}
},
"node_modules/escalade": {
@@ -2956,6 +3119,7 @@
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
+ "license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
@@ -2965,17 +3129,19 @@
}
},
"node_modules/eslint": {
- "version": "8.57.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
- "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
+ "version": "8.57.1",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
+ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
+ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
+ "license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
"@eslint/eslintrc": "^2.1.4",
- "@eslint/js": "8.57.0",
- "@humanwhocodes/config-array": "^0.11.14",
+ "@eslint/js": "8.57.1",
+ "@humanwhocodes/config-array": "^0.13.0",
"@humanwhocodes/module-importer": "^1.0.1",
"@nodelib/fs.walk": "^1.2.8",
"@ungap/structured-clone": "^1.2.0",
@@ -3025,6 +3191,7 @@
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
"integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
"dev": true,
+ "license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"esrecurse": "^4.3.0",
@@ -3042,6 +3209,7 @@
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
"dev": true,
+ "license": "Apache-2.0",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
@@ -3054,6 +3222,7 @@
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
"integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
"dev": true,
+ "license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"acorn": "^8.9.0",
@@ -3080,10 +3249,11 @@
}
},
"node_modules/esquery": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz",
- "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==",
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
"dev": true,
+ "license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"estraverse": "^5.1.0"
@@ -3097,6 +3267,7 @@
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
"dev": true,
+ "license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"estraverse": "^5.2.0"
@@ -3110,6 +3281,7 @@
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
"dev": true,
+ "license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=4.0"
@@ -3128,6 +3300,7 @@
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"dev": true,
+ "license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=0.10.0"
@@ -3205,19 +3378,21 @@
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
+ "license": "MIT",
"peer": true
},
"node_modules/fast-glob": {
- "version": "3.3.2",
- "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
- "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
- "micromatch": "^4.0.4"
+ "micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
@@ -3228,6 +3403,7 @@
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
@@ -3246,13 +3422,15 @@
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true,
+ "license": "MIT",
"peer": true
},
"node_modules/fastq": {
- "version": "1.17.1",
- "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
- "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==",
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
@@ -3271,6 +3449,7 @@
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
"integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
"dev": true,
+ "license": "MIT",
"peer": true,
"dependencies": {
"flat-cache": "^3.0.4"
@@ -3296,6 +3475,7 @@
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dev": true,
+ "license": "MIT",
"peer": true,
"dependencies": {
"locate-path": "^6.0.0",
@@ -3313,6 +3493,7 @@
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
"integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
"dev": true,
+ "license": "MIT",
"peer": true,
"dependencies": {
"flatted": "^3.2.9",
@@ -3324,10 +3505,11 @@
}
},
"node_modules/flatted": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
- "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
+ "license": "ISC",
"peer": true
},
"node_modules/fs.realpath": {
@@ -3423,6 +3605,7 @@
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
+ "license": "ISC",
"peer": true,
"dependencies": {
"is-glob": "^4.0.3"
@@ -3436,6 +3619,7 @@
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
"integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
"dev": true,
+ "license": "MIT",
"peer": true,
"dependencies": {
"type-fest": "^0.20.2"
@@ -3452,6 +3636,7 @@
"resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
"integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"array-union": "^2.1.0",
"dir-glob": "^3.0.1",
@@ -3477,7 +3662,8 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/gray-matter": {
"version": "4.0.3",
@@ -3570,10 +3756,11 @@
]
},
"node_modules/ignore": {
- "version": "5.3.1",
- "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz",
- "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==",
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">= 4"
}
@@ -3584,10 +3771,11 @@
"integrity": "sha512-HR7EVodfFUdQCTIeySw+WDRFJlPcLOJbXfwwZ7Oom6tjsvZ3bOkCDJHehQC3nxJrv7+f9XecwazynjU8e4Vw3Q=="
},
"node_modules/import-fresh": {
- "version": "3.3.0",
- "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
- "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"dev": true,
+ "license": "MIT",
"peer": true,
"dependencies": {
"parent-module": "^1.0.0",
@@ -3734,6 +3922,7 @@
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
"integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
"dev": true,
+ "license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
@@ -4416,7 +4605,6 @@
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"argparse": "^2.0.1"
},
@@ -4441,6 +4629,7 @@
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"dev": true,
+ "license": "MIT",
"peer": true
},
"node_modules/json-parse-even-better-errors": {
@@ -4454,6 +4643,7 @@
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
+ "license": "MIT",
"peer": true
},
"node_modules/json-stable-stringify-without-jsonify": {
@@ -4461,6 +4651,7 @@
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"dev": true,
+ "license": "MIT",
"peer": true
},
"node_modules/json5": {
@@ -4480,6 +4671,7 @@
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"dev": true,
+ "license": "MIT",
"peer": true,
"dependencies": {
"json-buffer": "3.0.1"
@@ -4639,6 +4831,7 @@
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
"dev": true,
+ "license": "MIT",
"peer": true,
"dependencies": {
"prelude-ls": "^1.2.1",
@@ -4683,6 +4876,7 @@
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true,
+ "license": "MIT",
"peer": true,
"dependencies": {
"p-locate": "^5.0.0"
@@ -4710,6 +4904,7 @@
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true,
+ "license": "MIT",
"peer": true
},
"node_modules/lru-cache": {
@@ -4788,6 +4983,7 @@
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">= 8"
}
@@ -4824,10 +5020,11 @@
}
},
"node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -4886,6 +5083,15 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true
},
+ "node_modules/node-diff3": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/node-diff3/-/node-diff3-3.2.0.tgz",
+ "integrity": "sha512-vLh2xJFSyniBLYDEDbXKqD32fQ5vAxmYT4hco8t0EHQ4CQ4BDHhshi7kdvDc6Y1MwGSi1Mhl4unUukPbCayZdw==",
+ "engines": {
+ "bun": ">=1.3.0",
+ "node": ">=18"
+ }
+ },
"node_modules/node-gyp-build": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.1.1.tgz",
@@ -4896,6 +5102,21 @@
"node-gyp-build-test": "build-test.js"
}
},
+ "node_modules/node-gyp-build-optional-packages": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz",
+ "integrity": "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "detect-libc": "^2.0.1"
+ },
+ "bin": {
+ "node-gyp-build-optional-packages": "bin.js",
+ "node-gyp-build-optional-packages-optional": "optional.js",
+ "node-gyp-build-optional-packages-test": "build-test.js"
+ }
+ },
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -5002,6 +5223,7 @@
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"dev": true,
+ "license": "MIT",
"peer": true,
"dependencies": {
"deep-is": "^0.1.3",
@@ -5035,6 +5257,7 @@
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
+ "license": "MIT",
"peer": true,
"dependencies": {
"p-limit": "^3.0.2"
@@ -5060,6 +5283,7 @@
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
"dev": true,
+ "license": "MIT",
"peer": true,
"dependencies": {
"callsites": "^3.0.0"
@@ -5129,6 +5353,7 @@
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=8"
}
@@ -5151,10 +5376,11 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=8.6"
},
@@ -5245,6 +5471,7 @@
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"dev": true,
+ "license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8.0"
@@ -5299,6 +5526,7 @@
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
+ "license": "MIT",
"peer": true,
"engines": {
"node": ">=6"
@@ -5338,7 +5566,8 @@
"type": "consulting",
"url": "https://feross.org/support"
}
- ]
+ ],
+ "license": "MIT"
},
"node_modules/react-is": {
"version": "18.2.0",
@@ -5423,6 +5652,7 @@
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true,
+ "license": "MIT",
"peer": true,
"engines": {
"node": ">=4"
@@ -5438,10 +5668,11 @@
}
},
"node_modules/reusify": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
- "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
+ "license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
@@ -5453,6 +5684,7 @@
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
+ "license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
@@ -5483,6 +5715,7 @@
"url": "https://feross.org/support"
}
],
+ "license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
@@ -5813,9 +6046,10 @@
}
},
"node_modules/svelte": {
- "version": "4.2.19",
- "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.19.tgz",
- "integrity": "sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==",
+ "version": "4.2.20",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.20.tgz",
+ "integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==",
+ "license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.1",
"@jridgewell/sourcemap-codec": "^1.4.15",
@@ -5922,6 +6156,7 @@
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
"dev": true,
+ "license": "MIT",
"peer": true
},
"node_modules/tmpl": {
@@ -5943,10 +6178,11 @@
}
},
"node_modules/ts-api-utils": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz",
- "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==",
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
+ "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=16"
},
@@ -6008,6 +6244,7 @@
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
"dev": true,
+ "license": "MIT",
"peer": true,
"dependencies": {
"prelude-ls": "^1.2.1"
@@ -6030,6 +6267,7 @@
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"dev": true,
+ "license": "(MIT OR CC0-1.0)",
"peer": true,
"engines": {
"node": ">=10"
@@ -6086,6 +6324,7 @@
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true,
+ "license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"punycode": "^2.1.0"
@@ -6157,6 +6396,7 @@
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"dev": true,
+ "license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
@@ -6215,25 +6455,6 @@
"node": ">=0.4"
}
},
- "node_modules/y-indexeddb": {
- "version": "9.0.12",
- "resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz",
- "integrity": "sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==",
- "dependencies": {
- "lib0": "^0.2.74"
- },
- "engines": {
- "node": ">=16.0.0",
- "npm": ">=8.0.0"
- },
- "funding": {
- "type": "GitHub Sponsors ❤",
- "url": "https://github.com/sponsors/dmonad"
- },
- "peerDependencies": {
- "yjs": "^13.0.0"
- }
- },
"node_modules/y-leveldb": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/y-leveldb/-/y-leveldb-0.1.2.tgz",
@@ -6748,6 +6969,42 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true
},
+ "@cbor-extract/cbor-extract-darwin-arm64": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.0.tgz",
+ "integrity": "sha512-P7swiOAdF7aSi0H+tHtHtr6zrpF3aAq/W9FXx5HektRvLTM2O89xCyXF3pk7pLc7QpaY7AoaE8UowVf9QBdh3w==",
+ "optional": true
+ },
+ "@cbor-extract/cbor-extract-darwin-x64": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.2.0.tgz",
+ "integrity": "sha512-1liF6fgowph0JxBbYnAS7ZlqNYLf000Qnj4KjqPNW4GViKrEql2MgZnAsExhY9LSy8dnvA4C0qHEBgPrll0z0w==",
+ "optional": true
+ },
+ "@cbor-extract/cbor-extract-linux-arm": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.2.0.tgz",
+ "integrity": "sha512-QeBcBXk964zOytiedMPQNZr7sg0TNavZeuUCD6ON4vEOU/25+pLhNN6EDIKJ9VLTKaZ7K7EaAriyYQ1NQ05s/Q==",
+ "optional": true
+ },
+ "@cbor-extract/cbor-extract-linux-arm64": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.2.0.tgz",
+ "integrity": "sha512-rQvhNmDuhjTVXSPFLolmQ47/ydGOFXtbR7+wgkSY0bdOxCFept1hvg59uiLPT2fVDuJFuEy16EImo5tE2x3RsQ==",
+ "optional": true
+ },
+ "@cbor-extract/cbor-extract-linux-x64": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.2.0.tgz",
+ "integrity": "sha512-cWLAWtT3kNLHSvP4RKDzSTX9o0wvQEEAj4SKvhWuOVZxiDAeQazr9A+PSiRILK1VYMLeDml89ohxCnUNQNQNCw==",
+ "optional": true
+ },
+ "@cbor-extract/cbor-extract-win32-x64": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.0.tgz",
+ "integrity": "sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w==",
+ "optional": true
+ },
"@codemirror/state": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz",
@@ -6948,18 +7205,18 @@
"optional": true
},
"@eslint-community/eslint-utils": {
- "version": "4.4.0",
- "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
- "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"dev": true,
"requires": {
- "eslint-visitor-keys": "^3.3.0"
+ "eslint-visitor-keys": "^3.4.3"
}
},
"@eslint-community/regexpp": {
- "version": "4.10.0",
- "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz",
- "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==",
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"dev": true
},
"@eslint/eslintrc": {
@@ -6981,20 +7238,20 @@
}
},
"@eslint/js": {
- "version": "8.57.0",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz",
- "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==",
+ "version": "8.57.1",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
+ "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
"dev": true,
"peer": true
},
"@humanwhocodes/config-array": {
- "version": "0.11.14",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
- "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==",
+ "version": "0.13.0",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
+ "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==",
"dev": true,
"peer": true,
"requires": {
- "@humanwhocodes/object-schema": "^2.0.2",
+ "@humanwhocodes/object-schema": "^2.0.3",
"debug": "^4.3.1",
"minimatch": "^3.0.5"
}
@@ -7521,6 +7778,12 @@
"pretty-format": "^29.0.0"
}
},
+ "@types/js-yaml": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
+ "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
+ "dev": true
+ },
"@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -7546,9 +7809,9 @@
"dev": true
},
"@types/semver": {
- "version": "7.5.8",
- "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
- "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==",
+ "version": "7.7.1",
+ "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
+ "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==",
"dev": true
},
"@types/stack-utils": {
@@ -7663,9 +7926,9 @@
},
"dependencies": {
"brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
+ "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0"
@@ -7708,9 +7971,9 @@
}
},
"@ungap/structured-clone": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
- "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz",
+ "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==",
"dev": true,
"peer": true
},
@@ -7740,9 +8003,9 @@
"requires": {}
},
"ajv": {
- "version": "6.12.6",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
- "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true,
"peer": true,
"requires": {
@@ -7798,8 +8061,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
- "dev": true,
- "peer": true
+ "dev": true
},
"aria-query": {
"version": "5.3.0",
@@ -7938,9 +8200,9 @@
"dev": true
},
"brace-expansion": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
+ "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0",
@@ -8031,6 +8293,29 @@
"integrity": "sha512-UWM1zlo3cZfkpBysd7AS+z+v007q9G1+fLTUU42rQnY6t2axoogPW/xol6T7juU5EUoOhML4WgBIdG+9yYqAjQ==",
"dev": true
},
+ "cbor-extract": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/cbor-extract/-/cbor-extract-2.2.0.tgz",
+ "integrity": "sha512-Ig1zM66BjLfTXpNgKpvBePq271BPOvu8MR0Jl080yG7Jsl+wAZunfrwiwA+9ruzm/WEdIV5QF/bjDZTqyAIVHA==",
+ "optional": true,
+ "requires": {
+ "@cbor-extract/cbor-extract-darwin-arm64": "2.2.0",
+ "@cbor-extract/cbor-extract-darwin-x64": "2.2.0",
+ "@cbor-extract/cbor-extract-linux-arm": "2.2.0",
+ "@cbor-extract/cbor-extract-linux-arm64": "2.2.0",
+ "@cbor-extract/cbor-extract-linux-x64": "2.2.0",
+ "@cbor-extract/cbor-extract-win32-x64": "2.2.0",
+ "node-gyp-build-optional-packages": "5.1.1"
+ }
+ },
+ "cbor-x": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/cbor-x/-/cbor-x-1.6.0.tgz",
+ "integrity": "sha512-0kareyRwHSkL6ws5VXHEf8uY1liitysCVJjlmhaLG+IXLqhSaOO+t63coaso7yjwEzWZzLy8fJo06gZDVQM9Qg==",
+ "requires": {
+ "cbor-extract": "^2.2.0"
+ }
+ },
"chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -8232,6 +8517,12 @@
"integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==",
"dev": true
},
+ "detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "optional": true
+ },
"detect-newline": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@@ -8239,9 +8530,9 @@
"dev": true
},
"diff": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz",
- "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A=="
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz",
+ "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="
},
"diff-match-patch": {
"version": "1.0.5",
@@ -8360,9 +8651,9 @@
}
},
"esbuild-svelte": {
- "version": "0.8.0",
- "resolved": "https://registry.npmjs.org/esbuild-svelte/-/esbuild-svelte-0.8.0.tgz",
- "integrity": "sha512-uKcPf1kl2UGMjrfHChv4dLxGAvCNhf9s72mHo19ZhKP+LrVOuQkOM/g8GE7MiGpoqjpk8UHqL08uLRbSKXhmhw==",
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/esbuild-svelte/-/esbuild-svelte-0.8.2.tgz",
+ "integrity": "sha512-tG97WrhH/OH8wFCmRBk6sRggMVMPNZDktGx1jJcvh9Obvjwm8M1UYoXmliuLL+4fs/wfyVVwM2fvyNYz2e6UPQ==",
"dev": true,
"requires": {
"@jridgewell/trace-mapping": "^0.3.19"
@@ -8382,17 +8673,17 @@
"peer": true
},
"eslint": {
- "version": "8.57.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
- "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
+ "version": "8.57.1",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
+ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"dev": true,
"peer": true,
"requires": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
"@eslint/eslintrc": "^2.1.4",
- "@eslint/js": "8.57.0",
- "@humanwhocodes/config-array": "^0.11.14",
+ "@eslint/js": "8.57.1",
+ "@humanwhocodes/config-array": "^0.13.0",
"@humanwhocodes/module-importer": "^1.0.1",
"@nodelib/fs.walk": "^1.2.8",
"@ungap/structured-clone": "^1.2.0",
@@ -8463,9 +8754,9 @@
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
},
"esquery": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz",
- "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==",
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
"dev": true,
"peer": true,
"requires": {
@@ -8561,16 +8852,16 @@
"peer": true
},
"fast-glob": {
- "version": "3.3.2",
- "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
- "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"requires": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
- "micromatch": "^4.0.4"
+ "micromatch": "^4.0.8"
},
"dependencies": {
"glob-parent": {
@@ -8598,9 +8889,9 @@
"peer": true
},
"fastq": {
- "version": "1.17.1",
- "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
- "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==",
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"requires": {
"reusify": "^1.0.4"
@@ -8658,9 +8949,9 @@
}
},
"flatted": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
- "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"peer": true
},
@@ -8830,9 +9121,9 @@
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
},
"ignore": {
- "version": "5.3.1",
- "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz",
- "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==",
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true
},
"immediate": {
@@ -8841,9 +9132,9 @@
"integrity": "sha512-HR7EVodfFUdQCTIeySw+WDRFJlPcLOJbXfwwZ7Oom6tjsvZ3bOkCDJHehQC3nxJrv7+f9XecwazynjU8e4Vw3Q=="
},
"import-fresh": {
- "version": "3.3.0",
- "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
- "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"dev": true,
"peer": true,
"requires": {
@@ -9469,7 +9760,6 @@
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
- "peer": true,
"requires": {
"argparse": "^2.0.1"
}
@@ -9781,9 +10071,9 @@
"dev": true
},
"minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
@@ -9831,11 +10121,25 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true
},
+ "node-diff3": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/node-diff3/-/node-diff3-3.2.0.tgz",
+ "integrity": "sha512-vLh2xJFSyniBLYDEDbXKqD32fQ5vAxmYT4hco8t0EHQ4CQ4BDHhshi7kdvDc6Y1MwGSi1Mhl4unUukPbCayZdw=="
+ },
"node-gyp-build": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.1.1.tgz",
"integrity": "sha512-dSq1xmcPDKPZ2EED2S6zw/b9NKsqzXRE6dVr8TVQnI3FJOTteUMuqF3Qqs6LZg+mLGYJWqQzMbIjMtJqTv87nQ=="
},
+ "node-gyp-build-optional-packages": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz",
+ "integrity": "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==",
+ "optional": true,
+ "requires": {
+ "detect-libc": "^2.0.1"
+ }
+ },
"node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -10029,9 +10333,9 @@
"dev": true
},
"picomatch": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true
},
"pirates": {
@@ -10226,9 +10530,9 @@
"dev": true
},
"reusify": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
- "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true
},
"rimraf": {
@@ -10493,9 +10797,9 @@
"dev": true
},
"svelte": {
- "version": "4.2.19",
- "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.19.tgz",
- "integrity": "sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==",
+ "version": "4.2.20",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.20.tgz",
+ "integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==",
"requires": {
"@ampproject/remapping": "^2.2.1",
"@jridgewell/sourcemap-codec": "^1.4.15",
@@ -10565,9 +10869,9 @@
}
},
"ts-api-utils": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz",
- "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==",
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
+ "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==",
"dev": true,
"requires": {}
},
@@ -10735,14 +11039,6 @@
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="
},
- "y-indexeddb": {
- "version": "9.0.12",
- "resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz",
- "integrity": "sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==",
- "requires": {
- "lib0": "^0.2.74"
- }
- },
"y-leveldb": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/y-leveldb/-/y-leveldb-0.1.2.tgz",
diff --git a/package.json b/package.json
index 57336274..0b0588aa 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs develop",
"release": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"beta": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs debug",
- "staging": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs staging",
+ "staging": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs staging --watch",
"version": "node version-bump.mjs && git add manifest.json versions.json",
"test": "jest",
"lint": "npx eslint . --ext .ts"
@@ -22,6 +22,7 @@
"@types/diff-match-patch": "^1.0.36",
"@types/eventsource": "^1.1.15",
"@types/jest": "^29.5.12",
+ "@types/js-yaml": "^4.0.9",
"@types/node": "^16.11.6",
"@types/path-browserify": "^1.0.2",
"@types/uuid": "^9.0.8",
@@ -32,6 +33,7 @@
"esbuild": "^0.27.0",
"esbuild-svelte": "^0.8.0",
"jest": "^29.7.0",
+ "js-yaml": "^4.1.1",
"obsidian": "^1.7.2",
"svelte": "^4.2.19",
"svelte-preprocess": "^5.1.4",
@@ -40,6 +42,7 @@
"typescript": "^5.4.5"
},
"dependencies": {
+ "cbor-x": "^1.6.0",
"diff": "^5.2.0",
"diff-match-patch": "^1.0.5",
"eventsource": "^2.0.2",
@@ -47,6 +50,7 @@
"jose": "^5.3.0",
"lucide-svelte": "^0.377.0",
"monkey-around": "^3.0.0",
+ "node-diff3": "^3.2.0",
"obsidian-daily-notes-interface": "^0.9.4",
"path-browserify": "^1.0.1",
"pocketbase": "^0.20.3",
diff --git a/src/AwarenessViewPlugin.ts b/src/AwarenessViewPlugin.ts
index 54e5b0ba..f39647d0 100644
--- a/src/AwarenessViewPlugin.ts
+++ b/src/AwarenessViewPlugin.ts
@@ -1,37 +1,66 @@
import { HasLogging } from "./debug";
-import { MarkdownView } from "obsidian";
-import { Document } from "./Document";
-import { type LiveView } from "./LiveViews";
import UserAwareness from "./components/UserAwareness.svelte";
-import type { RelayUser } from "./Relay";
+import { trackPromise } from "./trackPromise";
+import type { HasProvider } from "./HasProvider";
+
+export interface AwarenessHost {
+ /** The Obsidian view whose containerEl owns the avatar overlay. */
+ view: { containerEl: HTMLElement };
+ /**
+ * The provider-backed doc (Document or Canvas). Must expose `_provider`,
+ * `whenReady()`, `guid`, and optionally `path` for logger tagging.
+ */
+ doc: HasProvider & { whenReady(): Promise; path?: string };
+ /**
+ * Resolves the anchor inside `containerEl` that the avatar element is
+ * inserted relative to, along with the insertion position. Called once
+ * during `install()`. Returning `null` skips mounting.
+ */
+ resolveAnchor(containerEl: HTMLElement): {
+ anchor: HTMLElement;
+ position: InsertPosition;
+ } | null;
+ /** Extra class applied to the container (e.g. layout variant). */
+ variantClass?: string;
+ /** Optional hook to apply inline styles after the container is inserted. */
+ configureContainer?: (el: HTMLElement) => void;
+ /** Lay out the stack vertically (column) instead of horizontally (row). */
+ vertical?: boolean;
+ /**
+ * Optional accessor for the CM6-backed editor this awareness surface is
+ * attached to. When present, the popover exposes attribution controls.
+ */
+ getEditor?: () => unknown;
+}
+
export class AwarenessViewPlugin extends HasLogging {
- view: LiveView;
- doc: Document;
+ private host: AwarenessHost;
private destroyed = false;
private awarenessComponent?: UserAwareness;
- private targetElement?: HTMLElement;
private awarenessElement?: HTMLElement;
private relayUsersStore: any;
- constructor(view: LiveView, relayUsersStore: any) {
+ constructor(host: AwarenessHost, relayUsersStore: any) {
super();
- this.view = view;
- this.doc = view.document;
+ this.host = host;
this.relayUsersStore = relayUsersStore;
- this.setLoggers(`[AwarenessView](${this.doc.path})`);
+ this.setLoggers(`[AwarenessView](${this.host.doc.path ?? this.host.doc.guid})`);
this.install();
}
private async install() {
- if (!this.view || this.destroyed) return;
+ if (!this.host || this.destroyed) return;
- this.log("Installing awareness component for", this.view.view.file?.path);
+ this.log("Installing awareness component");
// Wrap the title immediately to avoid focus loss later
this.wrapTitle();
// Wait for the document to be ready
- await this.doc.whenReady();
+ await trackPromise(
+ `awareness:whenReady:${this.host.doc.guid}`,
+ this.host.doc.whenReady(),
+ );
if (this.destroyed) return;
@@ -40,34 +69,37 @@ export class AwarenessViewPlugin extends HasLogging {
}
private wrapTitle() {
- if (!this.view.view.containerEl || this.destroyed) return;
+ const containerEl = this.host.view?.containerEl;
+ if (!containerEl || this.destroyed) return;
// Already created
if (this.awarenessElement) return;
- // Find the target element (inline-title) to position relative to
- const inlineTitle = this.view.view.containerEl.querySelector(
- ".inline-title",
- ) as HTMLElement;
- if (!inlineTitle) {
- this.warn(
- "Could not find inline-title element to position awareness component",
- );
+ const resolved = this.host.resolveAnchor(containerEl);
+ if (!resolved) {
+ this.warn("Could not resolve anchor for awareness component");
return;
}
// Create container for the awareness component
- this.awarenessElement = document.createElement("div");
+ this.awarenessElement = containerEl.ownerDocument.createElement("div");
this.awarenessElement.className = "user-awareness-container";
+ if (this.host.variantClass) {
+ this.awarenessElement.classList.add(this.host.variantClass);
+ }
+
+ resolved.anchor.insertAdjacentElement(resolved.position, this.awarenessElement);
- // Insert as sibling after the inline-title (more robust than wrapping)
- // Use absolute positioning via CSS to place it next to the title
- inlineTitle.insertAdjacentElement("afterend", this.awarenessElement);
+ this.host.configureContainer?.(this.awarenessElement);
- // Make the parent position relative so we can position absolutely
- const parent = inlineTitle.parentElement;
- if (parent) {
- parent.style.position = "relative";
+ // The CSS pins the container top-right of its positioning parent. Make
+ // sure that parent can host absolute children.
+ const positioningParent =
+ resolved.position === "afterbegin" || resolved.position === "beforeend"
+ ? resolved.anchor
+ : resolved.anchor.parentElement;
+ if (positioningParent) {
+ positioningParent.addClass("user-awareness-positioning-parent");
}
}
@@ -75,7 +107,7 @@ export class AwarenessViewPlugin extends HasLogging {
if (!this.awarenessElement || this.destroyed) return;
// Get the awareness instance from the provider
- const provider = this.doc._provider;
+ const provider = this.host.doc._provider;
if (!provider?.awareness) {
this.warn("No awareness provider available");
return;
@@ -88,6 +120,8 @@ export class AwarenessViewPlugin extends HasLogging {
props: {
awareness: provider.awareness,
relayUsers: this.relayUsersStore,
+ vertical: this.host.vertical ?? false,
+ getEditor: this.host.getEditor,
},
});
@@ -115,7 +149,6 @@ export class AwarenessViewPlugin extends HasLogging {
this.awarenessElement = undefined;
}
- this.view = null as any;
- this.doc = null as any;
+ this.host = null as any;
}
}
diff --git a/src/BackgroundSync.ts b/src/BackgroundSync.ts
index 921843e3..56b27ceb 100644
--- a/src/BackgroundSync.ts
+++ b/src/BackgroundSync.ts
@@ -5,17 +5,30 @@ import { S3RN, S3RemoteCanvas, S3RemoteDocument } from "./S3RN";
import { isDocument, type Document } from "./Document";
import { isCanvas } from "./Canvas";
import type { TimeProvider } from "./TimeProvider";
-import { HasLogging, RelayInstances } from "./debug";
-import type { Subscriber, Unsubscriber } from "./observable/Observable";
+import { HasLogging, RelayInstances, metrics } from "./debug";
+import {
+ Observable,
+ type Subscriber,
+ type Unsubscriber,
+} from "./observable/Observable";
import { ObservableSet } from "./observable/ObservableSet";
import { ObservableMap } from "./observable/ObservableMap";
import type { SharedFolder, SharedFolders } from "./SharedFolder";
import { compareFilePaths } from "./FolderSort";
import type { ClientToken } from "./client/types";
import { Canvas } from "./Canvas";
-import { areObjectsEqual } from "./areObjectsEqual";
import type { CanvasData } from "./CanvasView";
+import { areCanvasDataEqual } from "./CanvasData";
import { SyncFile, isSyncFile } from "./SyncFile";
+import { isEmptyDoc, snapshotFromDoc } from "./merge-hsm/state-vectors";
+import {
+ buildFolderSyncSnapshot,
+ FolderSyncSnapshotSmoother,
+ type FolderSyncSnapshot,
+ type FolderSyncWorkItemInput,
+} from "./BackgroundSyncProgress";
+import { errorFromUnknown, formatUserFacingError } from "./UserFacingError";
+import { getRelayRequestHeaders } from "./customFetch";
export interface QueueItem {
guid: string;
@@ -23,17 +36,38 @@ export interface QueueItem {
doc: Document | Canvas | SyncFile;
status: "pending" | "running" | "completed" | "failed";
sharedFolder: SharedFolder;
+ userVisible: boolean;
+ syncIntent?: "sync" | "upload" | "lca-backfill";
+ retryAttempts?: number;
+ nextAttemptAt?: number;
+}
+
+export interface BackgroundSyncFailure {
+ id: string;
+ guid: string;
+ path: string;
+ kind: "sync" | "download" | "local";
+ message: string;
+ sharedFolder: SharedFolder;
}
export interface SyncGroup {
sharedFolder: SharedFolder;
total: number; // Total operations (syncs + downloads)
- completed: number; // Total completed operations
+ completed: number; // Successful operations
status: "pending" | "running" | "completed" | "failed";
downloads: number;
syncs: number;
completedDownloads: number;
completedSyncs: number;
+ failedDownloads: number;
+ failedSyncs: number;
+ skippedDownloads: number;
+ skippedSyncs: number;
+ userDownloads: number;
+ completedUserDownloads: number;
+ failedUserDownloads: number;
+ skippedUserDownloads: number;
}
export interface SyncProgress {
@@ -56,10 +90,46 @@ export interface GroupProgress {
status: "pending" | "running" | "completed" | "failed";
}
+interface FolderSyncSnapshotSubscription {
+ smoother: FolderSyncSnapshotSmoother;
+ subscribers: Set>;
+ latestSnapshot: FolderSyncSnapshot | null;
+ unsubscribers: Unsubscriber[];
+ emit: () => void;
+}
+
+class RetryableProviderSyncError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "RetryableProviderSyncError";
+ }
+}
+
+const MAX_PROVIDER_SYNC_RETRIES = 5;
+
+function isRetryableProviderSyncError(
+ error: unknown,
+): error is RetryableProviderSyncError {
+ return error instanceof RetryableProviderSyncError;
+}
+
+export interface QueueStatus {
+ syncsQueued: number;
+ syncsActive: number;
+ downloadsQueued: number;
+ downloadsActive: number;
+ isPaused: boolean;
+}
+
export class BackgroundSync extends HasLogging {
public activeSync = new ObservableSet();
public activeDownloads = new ObservableSet();
public syncGroups = new ObservableMap();
+ private folderResyncs = new ObservableSet();
+ private failures = new ObservableMap(
+ "BackgroundSync.failures",
+ );
+ private queueStatusChanged = new Observable("BackgroundSync.queueStatus");
private syncQueue: QueueItem[] = [];
private downloadQueue: QueueItem[] = [];
@@ -68,6 +138,8 @@ export class BackgroundSync extends HasLogging {
private isPaused = true;
private inProgressSyncs = new Set();
private inProgressDownloads = new Set();
+ private cancelledSyncs = new Set();
+ private cancelledDownloads = new Set();
private syncCompletionCallbacks = new Map<
string,
{
@@ -75,13 +147,19 @@ export class BackgroundSync extends HasLogging {
reject: (error: Error) => void;
}
>();
+ private syncPromises = new Map>();
private downloadCompletionCallbacks = new Map<
string,
{
- resolve: () => void;
+ resolve: (result?: Uint8Array) => void;
reject: (error: Error) => void;
}
>();
+ private downloadPromises = new Map>();
+ private folderSyncSnapshotSubscriptions = new Map<
+ SharedFolder,
+ FolderSyncSnapshotSubscription
+ >();
// A map to track items we've already logged to avoid duplicates
private loggedItems = new Map();
@@ -100,6 +178,13 @@ export class BackgroundSync extends HasLogging {
this.processSyncQueue();
this.processDownloadQueue();
}, 1000);
+
+ // Add polling timer for disk changes (poll all folders)
+ this.timeProvider.setInterval(() => {
+ this.sharedFolders.forEach((folder) => {
+ folder.poll();
+ });
+ }, 5000); // Poll every 5 seconds
}
/**
@@ -116,6 +201,106 @@ export class BackgroundSync extends HasLogging {
return this.downloadQueue;
}
+ cancelDocumentWork(guid: string): void {
+ let changed = false;
+
+ const queuedSyncs = this.syncQueue.filter((item) => item.guid === guid);
+ if (queuedSyncs.length > 0) {
+ for (const item of queuedSyncs) {
+ this.removeQueuedSyncFromGroup(item);
+ }
+ this.syncQueue = this.syncQueue.filter((item) => item.guid !== guid);
+ changed = true;
+ }
+
+ const queuedDownloads = this.downloadQueue.filter((item) => item.guid === guid);
+ if (queuedDownloads.length > 0) {
+ for (const item of queuedDownloads) {
+ this.removeQueuedDownloadFromGroup(item);
+ }
+ this.downloadQueue = this.downloadQueue.filter((item) => item.guid !== guid);
+ changed = true;
+ }
+
+ if (this.activeSync.some((item) => item.guid === guid)) {
+ this.cancelledSyncs.add(guid);
+ } else {
+ this.resolveSyncCancellation(guid);
+ }
+
+ if (this.activeDownloads.some((item) => item.guid === guid)) {
+ this.cancelledDownloads.add(guid);
+ } else {
+ this.resolveDownloadCancellation(guid);
+ }
+
+ this.clearFailure(this.failureKey("sync", guid));
+ this.clearFailure(this.failureKey("download", guid));
+
+ if (changed) {
+ metrics.setBgSyncQueueLength("sync", this.syncQueue.length);
+ metrics.setBgSyncQueueLength("download", this.downloadQueue.length);
+ this.queueStatusChanged.notifyListeners();
+ }
+ }
+
+ private removeQueuedSyncFromGroup(item: QueueItem): void {
+ const group = this.syncGroups.get(item.sharedFolder);
+ if (!group) return;
+
+ group.total = Math.max(0, group.total - 1);
+ group.syncs = Math.max(0, group.syncs - 1);
+ this.updateGroupTerminalStatus(group);
+ this.syncGroups.set(item.sharedFolder, group);
+ }
+
+ private removeQueuedDownloadFromGroup(item: QueueItem): void {
+ const group = this.syncGroups.get(item.sharedFolder);
+ if (!group) return;
+
+ group.total = Math.max(0, group.total - 1);
+ group.downloads = Math.max(0, group.downloads - 1);
+ if (item.userVisible) {
+ group.userDownloads = Math.max(0, group.userDownloads - 1);
+ }
+ this.updateGroupTerminalStatus(group);
+ this.syncGroups.set(item.sharedFolder, group);
+ }
+
+ private resolveSyncCancellation(guid: string): void {
+ const callback = this.syncCompletionCallbacks.get(guid);
+ if (callback) callback.resolve();
+ this.syncCompletionCallbacks.delete(guid);
+ this.syncPromises.delete(guid);
+ this.inProgressSyncs.delete(guid);
+ this.cancelledSyncs.delete(guid);
+ }
+
+ private resolveDownloadCancellation(guid: string): void {
+ const callback = this.downloadCompletionCallbacks.get(guid);
+ if (callback) callback.resolve(undefined);
+ this.downloadCompletionCallbacks.delete(guid);
+ this.downloadPromises.delete(guid);
+ this.inProgressDownloads.delete(guid);
+ this.cancelledDownloads.delete(guid);
+ }
+
+ private isSyncCancelled(item: QueueItem): boolean {
+ return item.doc.destroyed || this.cancelledSyncs.has(item.guid);
+ }
+
+ private isSyncCancelledForDoc(doc: Document | Canvas | SyncFile): boolean {
+ return doc.destroyed || this.cancelledSyncs.has(doc.guid);
+ }
+
+ private isDownloadCancelled(item: QueueItem): boolean {
+ return item.doc.destroyed || this.cancelledDownloads.has(item.guid);
+ }
+
+ private shouldSkipDocumentSync(item: Document | Canvas | SyncFile): boolean {
+ return isDocument(item) && item.hsm?.getSyncStatus().status === "conflict";
+ }
+
getOverallProgress(): SyncProgress {
let totalItems = 0;
let completedItems = 0;
@@ -126,11 +311,11 @@ export class BackgroundSync extends HasLogging {
this.syncGroups.forEach((group) => {
totalItems += group.total;
- completedItems += group.completed;
+ completedItems += this.groupFinishedTotal(group);
syncItems += group.syncs;
- completedSyncs += group.completedSyncs;
+ completedSyncs += this.groupFinishedSyncs(group);
downloadItems += group.downloads;
- completedDownloads += group.completedDownloads;
+ completedDownloads += this.groupFinishedDownloads(group);
});
const totalPercent =
@@ -156,12 +341,15 @@ export class BackgroundSync extends HasLogging {
const group = this.syncGroups.get(sharedFolder);
if (!group) return null;
- const percent = group.total > 0 ? (group.completed / group.total) * 100 : 0;
+ const percent =
+ group.total > 0 ? (this.groupFinishedTotal(group) / group.total) * 100 : 0;
const syncPercent =
- group.syncs > 0 ? (group.completedSyncs / group.syncs) * 100 : 0;
+ group.syncs > 0
+ ? (this.groupFinishedSyncs(group) / group.syncs) * 100
+ : 0;
const downloadPercent =
group.downloads > 0
- ? (group.completedDownloads / group.downloads) * 100
+ ? (this.groupFinishedDownloads(group) / group.downloads) * 100
: 0;
return {
@@ -173,6 +361,330 @@ export class BackgroundSync extends HasLogging {
};
}
+ /**
+ * Returns download-only progress for a shared folder.
+ * Used to show only user-visible downloads in folder progress indicators.
+ */
+ getUserVisibleProgress(sharedFolder: SharedFolder): GroupProgress | null {
+ const group = this.syncGroups.get(sharedFolder);
+ if (!group) return null;
+
+ const total = group.userDownloads;
+ const finished =
+ group.completedUserDownloads +
+ group.failedUserDownloads +
+ group.skippedUserDownloads;
+ const percent = total > 0 ? (finished / total) * 100 : 0;
+ const status =
+ total === 0
+ ? group.status
+ : finished === total
+ ? group.failedUserDownloads > 0
+ ? "failed"
+ : "completed"
+ : group.status === "failed"
+ ? "failed"
+ : "running";
+
+ return {
+ percent: Math.round(percent),
+ syncPercent: 0,
+ downloadPercent: Math.round(percent),
+ sharedFolder,
+ status,
+ };
+ }
+
+ private groupFinishedSyncs(group: SyncGroup): number {
+ return Math.min(
+ group.syncs,
+ group.completedSyncs + group.failedSyncs + group.skippedSyncs,
+ );
+ }
+
+ private groupFinishedDownloads(group: SyncGroup): number {
+ return Math.min(
+ group.downloads,
+ group.completedDownloads + group.failedDownloads + group.skippedDownloads,
+ );
+ }
+
+ private groupFinishedTotal(group: SyncGroup): number {
+ return Math.min(
+ group.total,
+ this.groupFinishedSyncs(group) + this.groupFinishedDownloads(group),
+ );
+ }
+
+ private groupFailureCount(group: SyncGroup): number {
+ return group.failedSyncs + group.failedDownloads;
+ }
+
+ private updateGroupTerminalStatus(group: SyncGroup): void {
+ if (this.groupFinishedTotal(group) >= group.total) {
+ group.status = this.groupFailureCount(group) > 0 ? "failed" : "completed";
+ } else if (this.groupFailureCount(group) > 0) {
+ group.status = "failed";
+ } else if (group.total > 0) {
+ group.status = "running";
+ } else {
+ group.status = "completed";
+ }
+ }
+
+ private markSyncTerminal(
+ sharedFolder: SharedFolder,
+ outcome: "completed" | "failed" | "skipped",
+ ): void {
+ const group = this.syncGroups.get(sharedFolder);
+ if (!group) return;
+ if (outcome === "completed") {
+ group.completedSyncs++;
+ group.completed++;
+ } else if (outcome === "failed") {
+ group.failedSyncs++;
+ } else {
+ group.skippedSyncs++;
+ }
+ this.updateGroupTerminalStatus(group);
+ this.syncGroups.set(sharedFolder, group);
+ }
+
+ private markDownloadTerminal(
+ item: QueueItem,
+ outcome: "completed" | "failed" | "skipped",
+ ): void {
+ const group = this.syncGroups.get(item.sharedFolder);
+ if (!group) return;
+ if (outcome === "completed") {
+ group.completedDownloads++;
+ group.completed++;
+ if (item.userVisible) {
+ group.completedUserDownloads++;
+ }
+ } else if (outcome === "failed") {
+ group.failedDownloads++;
+ if (item.userVisible) {
+ group.failedUserDownloads++;
+ }
+ } else {
+ group.skippedDownloads++;
+ if (item.userVisible) {
+ group.skippedUserDownloads++;
+ }
+ }
+ this.updateGroupTerminalStatus(group);
+ this.syncGroups.set(item.sharedFolder, group);
+ }
+
+ private requeueRetryableSync(
+ item: QueueItem,
+ error: RetryableProviderSyncError,
+ ): boolean {
+ const retries = (item.retryAttempts ?? 0) + 1;
+ item.retryAttempts = retries;
+ if (retries > MAX_PROVIDER_SYNC_RETRIES) {
+ item.nextAttemptAt = undefined;
+ this.warn(
+ `[syncDocWS] provider sync failed after ${MAX_PROVIDER_SYNC_RETRIES} retries for ${item.path}: ${error.message}`,
+ );
+ return false;
+ }
+
+ const delayMs = Math.min(30_000, 1000 * 2 ** Math.min(retries - 1, 5));
+ item.status = "pending";
+ item.nextAttemptAt = this.timeProvider.now() + delayMs;
+
+ this.clearFailure(this.failureKey("sync", item.guid));
+ if (!this.syncQueue.some((queued) => queued.guid === item.guid)) {
+ this.syncQueue.push(item);
+ this.syncQueue.sort(compareFilePaths);
+ }
+ this.debug(
+ `[syncDocWS] retryable provider sync failure for ${item.path}: ${error.message}; retrying in ${delayMs}ms`,
+ );
+ metrics.setBgSyncQueueLength("sync", this.syncQueue.length);
+ this.queueStatusChanged.notifyListeners();
+ return true;
+ }
+
+ getFolderPillProgress(sharedFolder: SharedFolder): GroupProgress | null {
+ const group = this.syncGroups.get(sharedFolder);
+ if (!group) return null;
+
+ const snapshot = this.getFolderSyncSnapshot(sharedFolder);
+ return {
+ percent: snapshot.percent,
+ syncPercent: snapshot.syncPercent,
+ downloadPercent: snapshot.downloadPercent,
+ sharedFolder,
+ status: snapshot.progressStatus,
+ };
+ }
+
+ getFolderSyncSnapshot(sharedFolder: SharedFolder): FolderSyncSnapshot {
+ const activeDownloads = this.activeDownloads.filter(
+ (item) => item.sharedFolder === sharedFolder,
+ );
+ const activeSync = this.activeSync.filter(
+ (item) => item.sharedFolder === sharedFolder,
+ );
+ const queuedDownloads = this.downloadQueue.filter(
+ (item) => item.sharedFolder === sharedFolder,
+ );
+ const queuedSyncs = this.syncQueue.filter(
+ (item) => item.sharedFolder === sharedFolder,
+ );
+ const folderResyncActive = this.folderResyncs.has(sharedFolder) ? 1 : 0;
+ const activeItem = this.activeItemForSnapshot(activeDownloads, activeSync);
+ const queuedReason = this.queuedReasonForSnapshot(
+ sharedFolder,
+ activeDownloads.length + activeSync.length,
+ queuedDownloads.length + queuedSyncs.length,
+ );
+
+ return buildFolderSyncSnapshot({
+ group: this.syncGroups.get(sharedFolder) ?? null,
+ queued: queuedDownloads.length + queuedSyncs.length,
+ active: activeDownloads.length + activeSync.length + folderResyncActive,
+ isPaused: this.isPaused,
+ failureCount: this.getFailures(sharedFolder).length,
+ canResync: sharedFolder.connected && !sharedFolder.localOnly,
+ folderActivity: folderResyncActive ? "checking" : null,
+ activeItem,
+ queuedReason,
+ });
+ }
+
+ private activeItemForSnapshot(
+ activeDownloads: QueueItem[],
+ activeSync: QueueItem[],
+ ): FolderSyncWorkItemInput | null {
+ const download = activeDownloads[0];
+ if (download) return { kind: "download", path: download.path };
+ const sync = activeSync[0];
+ if (sync) return { kind: "sync", path: sync.path };
+ return null;
+ }
+
+ private queuedReasonForSnapshot(
+ sharedFolder: SharedFolder,
+ active: number,
+ queued: number,
+ ): "connection" | "reconnecting" | null {
+ if (active > 0 || queued === 0) return null;
+ if (!sharedFolder.connected) {
+ return sharedFolder.state.status === "connecting"
+ ? "reconnecting"
+ : "connection";
+ }
+ return null;
+ }
+
+ getFailures(sharedFolder: SharedFolder): BackgroundSyncFailure[] {
+ return this.failures
+ .values()
+ .filter((failure) => failure.sharedFolder === sharedFolder)
+ .sort((a, b) => a.path.localeCompare(b.path));
+ }
+
+ clearFailure(id: string): void {
+ this.failures.delete(id);
+ }
+
+ clearFailuresForFolder(sharedFolder: SharedFolder): void {
+ for (const failure of this.failures.values()) {
+ if (failure.sharedFolder === sharedFolder) {
+ this.clearFailure(failure.id);
+ }
+ }
+ }
+
+ beginFolderResync(sharedFolder: SharedFolder): Unsubscriber {
+ this.clearFailuresForFolder(sharedFolder);
+ this.folderResyncs.add(sharedFolder);
+ return () => {
+ this.folderResyncs.delete(sharedFolder);
+ };
+ }
+
+ async refreshLocalFileFailures(sharedFolder: SharedFolder): Promise {
+ const liveLocalFailureIds = new Set();
+ for (const file of sharedFolder.files.values()) {
+ if (!isCanvas(file)) continue;
+ const id = this.failureKey("local", file.guid);
+ liveLocalFailureIds.add(id);
+ const message = await this.getCanvasLocalStateFailure(file);
+ if (message) {
+ this.setFailure({
+ id,
+ guid: file.guid,
+ path: file.path,
+ kind: "local",
+ message,
+ sharedFolder,
+ });
+ } else {
+ this.clearFailure(id);
+ }
+ }
+
+ for (const failure of this.failures.values()) {
+ if (
+ failure.sharedFolder === sharedFolder &&
+ failure.kind === "local" &&
+ !liveLocalFailureIds.has(failure.id)
+ ) {
+ this.clearFailure(failure.id);
+ }
+ }
+ }
+
+ private async getCanvasLocalStateFailure(
+ canvas: Canvas,
+ ): Promise {
+ await canvas.whenSynced();
+ let currentFileContents: string;
+ try {
+ currentFileContents = await canvas.sharedFolder.read(canvas);
+ } catch (e) {
+ return null;
+ }
+ if (!currentFileContents) return null;
+
+ let currentFileJson: CanvasData;
+ try {
+ currentFileJson = JSON.parse(currentFileContents) as CanvasData;
+ } catch (e) {
+ return "Canvas file contains invalid JSON. Open the canvas and repair it before syncing.";
+ }
+
+ const currentCanvasData = Canvas.exportCanvasData(canvas.ydoc);
+ if (areCanvasDataEqual(currentCanvasData, currentFileJson)) {
+ return null;
+ }
+ if (await this.repairStaleCanvasText(canvas, currentFileJson)) {
+ return null;
+ }
+ return "Canvas file does not match local sync state. Open the canvas and resolve the local changes before syncing.";
+ }
+
+ private async repairStaleCanvasText(
+ canvas: Canvas,
+ currentFileJson: CanvasData,
+ ): Promise {
+ const currentCanvasMapData = Canvas.exportCanvasMapData(canvas.ydoc);
+ if (!areCanvasDataEqual(currentCanvasMapData, currentFileJson)) {
+ return false;
+ }
+
+ await canvas.applyData(currentFileJson);
+ return areCanvasDataEqual(
+ Canvas.exportCanvasData(canvas.ydoc),
+ currentFileJson,
+ );
+ }
+
getAllGroupsProgress(): GroupProgress[] {
const progress: GroupProgress[] = [];
this.syncGroups.forEach((group, sharedFolder) => {
@@ -188,9 +700,26 @@ export class BackgroundSync extends HasLogging {
if (this.isPaused || this.isProcessingSync) return;
this.isProcessingSync = true;
+ // Evict destroyed documents from the queue and clean up their inProgress entries
+ const destroyed = this.syncQueue.filter((item) => item.doc.destroyed);
+ for (const item of destroyed) {
+ this.markSyncTerminal(item.sharedFolder, "skipped");
+ this.inProgressSyncs.delete(item.guid);
+ const callback = this.syncCompletionCallbacks.get(item.guid);
+ if (callback) callback.reject(new Error("Document destroyed"));
+ this.syncCompletionCallbacks.delete(item.guid);
+ this.syncPromises.delete(item.guid);
+ }
+ this.syncQueue = this.syncQueue.filter((item) => !item.doc.destroyed);
+
+ metrics.setBgSyncQueueLength("sync", this.syncQueue.length);
+
// Filter for items with connected folders
+ const now = this.timeProvider.now();
const connectableItems = this.syncQueue.filter(
- (item) => item.sharedFolder.connected,
+ (item) =>
+ item.sharedFolder.connected &&
+ (item.nextAttemptAt === undefined || item.nextAttemptAt <= now),
);
while (
@@ -204,7 +733,10 @@ export class BackgroundSync extends HasLogging {
this.syncQueue = this.syncQueue.filter((i) => i.guid !== item.guid);
item.status = "running";
+ const opStart = performance.now();
this.activeSync.add(item);
+ metrics.setBgSyncActive("sync", this.activeSync.size);
+ metrics.setBgSyncQueueLength("sync", this.syncQueue.length);
try {
const doc = item.doc;
@@ -212,6 +744,10 @@ export class BackgroundSync extends HasLogging {
if (doc instanceof SyncFile) {
syncPromise = this.syncFile(doc);
+ } else if (item.syncIntent === "upload") {
+ syncPromise = this.syncDocumentUpload(doc);
+ } else if (item.syncIntent === "lca-backfill" && isDocument(doc)) {
+ syncPromise = this.syncDocumentLCABackfill(doc);
} else {
syncPromise = this.syncDocument(doc);
}
@@ -219,56 +755,53 @@ export class BackgroundSync extends HasLogging {
syncPromise
.then(() => {
item.status = "completed";
+ metrics.incBgSyncOps("sync", "completed");
const callback = this.syncCompletionCallbacks.get(item.guid);
if (callback) {
callback.resolve();
this.syncCompletionCallbacks.delete(item.guid);
+ this.syncPromises.delete(item.guid);
}
- const group = this.syncGroups.get(item.sharedFolder);
- if (group) {
- this.debug(
- `[Sync Progress] Before: completed=${group.completed}, total=${group.total}, ` +
- `syncs=${group.syncs}, completedSyncs=${group.completedSyncs}`,
- );
-
- group.completedSyncs++;
- group.completed++;
-
- this.debug(
- `[Sync Progress] After: completed=${group.completed}, total=${group.total}, ` +
- `syncs=${group.syncs}, completedSyncs=${group.completedSyncs}`,
- );
-
- if (group.completed === group.total) {
- group.status = "completed";
- this.debug("[Sync Progress] Group completed!");
- }
-
- this.syncGroups.set(item.sharedFolder, group);
- }
+ this.markSyncTerminal(item.sharedFolder, "completed");
})
.catch((error) => {
+ if (this.isSyncCancelled(item)) {
+ item.status = "completed";
+ this.markSyncTerminal(item.sharedFolder, "skipped");
+ this.resolveSyncCancellation(item.guid);
+ return;
+ }
+
+ if (
+ isRetryableProviderSyncError(error) &&
+ this.requeueRetryableSync(item, error)
+ ) {
+ return;
+ }
+
item.status = "failed";
+ metrics.incBgSyncOps("sync", "failed");
const callback = this.syncCompletionCallbacks.get(item.guid);
if (callback) {
- callback.reject(
- error instanceof Error ? error : new Error(String(error)),
- );
+ callback.reject(errorFromUnknown(error));
this.syncCompletionCallbacks.delete(item.guid);
+ this.syncPromises.delete(item.guid);
}
- const group = this.syncGroups.get(item.sharedFolder);
- if (group) {
- this.error("[Sync Failed]", error);
- group.status = "failed";
- this.syncGroups.set(item.sharedFolder, group);
- }
+ this.logError("[Sync Failed]", error);
+ this.recordFailure("sync", item, error);
+ this.markSyncTerminal(item.sharedFolder, "failed");
})
.finally(() => {
+ metrics.observeBgSyncOp("sync", (performance.now() - opStart) / 1000);
this.activeSync.delete(item);
- this.inProgressSyncs.delete(item.guid);
+ metrics.setBgSyncActive("sync", this.activeSync.size);
+ if (!this.syncQueue.some((queued) => queued.guid === item.guid)) {
+ this.inProgressSyncs.delete(item.guid);
+ this.cancelledSyncs.delete(item.guid);
+ }
// Unwind the call stack before checking for more work
this.timeProvider.setTimeout(() => {
@@ -276,24 +809,45 @@ export class BackgroundSync extends HasLogging {
}, 0);
});
} catch (error) {
+ if (this.isSyncCancelled(item)) {
+ item.status = "completed";
+ metrics.observeBgSyncOp("sync", (performance.now() - opStart) / 1000);
+ this.markSyncTerminal(item.sharedFolder, "skipped");
+ this.resolveSyncCancellation(item.guid);
+ this.activeSync.delete(item);
+ metrics.setBgSyncActive("sync", this.activeSync.size);
+ this.inProgressSyncs.delete(item.guid);
+ this.cancelledSyncs.delete(item.guid);
+ continue;
+ }
+
+ if (
+ isRetryableProviderSyncError(error) &&
+ this.requeueRetryableSync(item, error)
+ ) {
+ metrics.observeBgSyncOp("sync", (performance.now() - opStart) / 1000);
+ this.activeSync.delete(item);
+ metrics.setBgSyncActive("sync", this.activeSync.size);
+ continue;
+ }
+
item.status = "failed";
+ metrics.incBgSyncOps("sync", "failed");
+ metrics.observeBgSyncOp("sync", (performance.now() - opStart) / 1000);
const callback = this.syncCompletionCallbacks.get(item.guid);
if (callback) {
- callback.reject(
- error instanceof Error ? error : new Error(String(error)),
- );
+ callback.reject(errorFromUnknown(error));
this.syncCompletionCallbacks.delete(item.guid);
+ this.syncPromises.delete(item.guid);
}
- const group = this.syncGroups.get(item.sharedFolder);
- if (group) {
- this.error("[Sync Startup Failed]", error);
- group.status = "failed";
- this.syncGroups.set(item.sharedFolder, group);
- }
+ this.logError("[Sync Startup Failed]", error);
+ this.recordFailure("sync", item, error);
+ this.markSyncTerminal(item.sharedFolder, "failed");
this.activeSync.delete(item);
+ metrics.setBgSyncActive("sync", this.activeSync.size);
this.inProgressSyncs.delete(item.guid);
}
}
@@ -305,6 +859,20 @@ export class BackgroundSync extends HasLogging {
if (this.isPaused || this.isProcessingDownloads) return;
this.isProcessingDownloads = true;
+ // Evict destroyed documents from the queue and clean up their inProgress entries
+ const destroyedDownloads = this.downloadQueue.filter((item) => item.doc.destroyed);
+ for (const item of destroyedDownloads) {
+ this.markDownloadTerminal(item, "skipped");
+ this.inProgressDownloads.delete(item.guid);
+ const callback = this.downloadCompletionCallbacks.get(item.guid);
+ if (callback) callback.reject(new Error("Document destroyed"));
+ this.downloadCompletionCallbacks.delete(item.guid);
+ this.downloadPromises.delete(item.guid);
+ }
+ this.downloadQueue = this.downloadQueue.filter((item) => !item.doc.destroyed);
+
+ metrics.setBgSyncQueueLength("download", this.downloadQueue.length);
+
// Filter for items with connected folders
const connectableItems = this.downloadQueue.filter(
(item) => item.sharedFolder.connected,
@@ -323,7 +891,10 @@ export class BackgroundSync extends HasLogging {
);
item.status = "running";
+ const opStart = performance.now();
this.activeDownloads.add(item);
+ metrics.setBgSyncActive("download", this.activeDownloads.size);
+ metrics.setBgSyncQueueLength("download", this.downloadQueue.length);
try {
let downloadPromise: Promise;
@@ -338,46 +909,47 @@ export class BackgroundSync extends HasLogging {
}
downloadPromise
- .then(() => {
+ .then((result) => {
item.status = "completed";
+ metrics.incBgSyncOps("download", "completed");
const callback = this.downloadCompletionCallbacks.get(item.guid);
if (callback) {
- callback.resolve();
+ callback.resolve(result as Uint8Array | undefined);
this.downloadCompletionCallbacks.delete(item.guid);
+ this.downloadPromises.delete(item.guid);
}
- const group = this.syncGroups.get(item.sharedFolder);
- if (group) {
- group.completedDownloads++;
- group.completed++;
- if (group.completed === group.total) {
- group.status = "completed";
- }
- this.syncGroups.set(item.sharedFolder, group);
- }
+ this.markDownloadTerminal(item, "completed");
})
.catch((error) => {
+ if (this.isDownloadCancelled(item)) {
+ item.status = "completed";
+ this.markDownloadTerminal(item, "skipped");
+ this.resolveDownloadCancellation(item.guid);
+ return;
+ }
+
item.status = "failed";
+ metrics.incBgSyncOps("download", "failed");
const callback = this.downloadCompletionCallbacks.get(item.guid);
if (callback) {
- callback.reject(
- error instanceof Error ? error : new Error(String(error)),
- );
+ callback.reject(errorFromUnknown(error));
this.downloadCompletionCallbacks.delete(item.guid);
+ this.downloadPromises.delete(item.guid);
}
- const group = this.syncGroups.get(item.sharedFolder);
- if (group) {
- group.status = "failed";
- this.syncGroups.set(item.sharedFolder, group);
- }
- this.error("[processDownloadQueue]", error);
+ this.recordFailure("download", item, error);
+ this.logError("[processDownloadQueue]", error);
+ this.markDownloadTerminal(item, "failed");
})
.finally(() => {
+ metrics.observeBgSyncOp("download", (performance.now() - opStart) / 1000);
this.activeDownloads.delete(item);
+ metrics.setBgSyncActive("download", this.activeDownloads.size);
this.inProgressDownloads.delete(item.guid);
+ this.cancelledDownloads.delete(item.guid);
// Unwind the call stack before checking for more work
this.timeProvider.setTimeout(() => {
@@ -385,24 +957,35 @@ export class BackgroundSync extends HasLogging {
}, 0);
});
} catch (error) {
+ if (this.isDownloadCancelled(item)) {
+ item.status = "completed";
+ metrics.observeBgSyncOp("download", (performance.now() - opStart) / 1000);
+ this.markDownloadTerminal(item, "skipped");
+ this.resolveDownloadCancellation(item.guid);
+ this.activeDownloads.delete(item);
+ metrics.setBgSyncActive("download", this.activeDownloads.size);
+ this.inProgressDownloads.delete(item.guid);
+ this.cancelledDownloads.delete(item.guid);
+ continue;
+ }
+
item.status = "failed";
+ metrics.incBgSyncOps("download", "failed");
+ metrics.observeBgSyncOp("download", (performance.now() - opStart) / 1000);
const callback = this.downloadCompletionCallbacks.get(item.guid);
if (callback) {
- callback.reject(
- error instanceof Error ? error : new Error(String(error)),
- );
+ callback.reject(errorFromUnknown(error));
this.downloadCompletionCallbacks.delete(item.guid);
+ this.downloadPromises.delete(item.guid);
}
- const group = this.syncGroups.get(item.sharedFolder);
- if (group) {
- this.error("[Download Startup Failed]", error);
- group.status = "failed";
- this.syncGroups.set(item.sharedFolder, group);
- }
+ this.logError("[Download Startup Failed]", error);
+ this.recordFailure("download", item, error);
+ this.markDownloadTerminal(item, "failed");
this.activeDownloads.delete(item);
+ metrics.setBgSyncActive("download", this.activeDownloads.size);
this.inProgressDownloads.delete(item.guid);
}
}
@@ -420,22 +1003,17 @@ export class BackgroundSync extends HasLogging {
* @returns A promise that resolves when the sync completes
*/
async enqueueSync(item: SyncFile | Document | Canvas): Promise {
- // Skip if already in progress
+ if (this.shouldSkipDocumentSync(item)) {
+ this.clearFailure(this.failureKey("sync", item.guid));
+ return Promise.resolve();
+ }
+
+ // Skip if already in progress — return the same promise all callers share
if (this.inProgressSyncs.has(item.guid)) {
this.debug(
- `[enqueueSync] Item ${item.guid} already in progress, skipping`,
+ `[enqueueSync] Item ${item.guid} already in progress, sharing promise`,
);
-
- // Return existing promise if already processing
- const existingCallback = this.syncCompletionCallbacks.get(item.guid);
- if (existingCallback) {
- return new Promise((resolve, reject) => {
- existingCallback.resolve = resolve;
- existingCallback.reject = reject;
- });
- }
- this.processSyncQueue();
- return Promise.resolve();
+ return this.syncPromises.get(item.guid) ?? Promise.resolve();
}
const sharedFolder = item.sharedFolder;
@@ -445,7 +1023,9 @@ export class BackgroundSync extends HasLogging {
doc: item,
status: "pending",
sharedFolder,
+ userVisible: false,
};
+ this.clearFailure(this.failureKey("sync", item.guid));
// Get or create the sync group
let group = this.syncGroups.get(sharedFolder);
@@ -459,10 +1039,19 @@ export class BackgroundSync extends HasLogging {
syncs: 0,
completedDownloads: 0,
completedSyncs: 0,
+ failedDownloads: 0,
+ failedSyncs: 0,
+ skippedDownloads: 0,
+ skippedSyncs: 0,
+ userDownloads: 0,
+ completedUserDownloads: 0,
+ failedUserDownloads: 0,
+ skippedUserDownloads: 0,
};
}
group.total++;
group.syncs++;
+ group.status = "running";
this.syncGroups.set(sharedFolder, group);
this.inProgressSyncs.add(item.guid);
@@ -473,46 +1062,54 @@ export class BackgroundSync extends HasLogging {
reject,
});
});
+ this.syncPromises.set(item.guid, syncPromise);
this.syncQueue.push(queueItem);
this.syncQueue.sort(compareFilePaths);
+ this.queueStatusChanged.notifyListeners();
this.processSyncQueue();
return syncPromise;
}
/**
- * Enqueues a document for download
- *
- * This method adds a document to the download queue and creates/updates
- * the associated sync group to track progress.
- *
- * @param item The document to download
- * @returns A promise that resolves when the download completes
+ * Enqueue a local-authoritative upload before markUploaded(). For documents,
+ * this seeds remoteDoc from the enrolled local CRDT before provider sync
+ * resolves; other file types use their normal sync mechanics.
*/
- enqueueDownload(item: SyncFile | Document | Canvas): Promise {
- // Skip if already in progress
- if (this.inProgressDownloads.has(item.guid)) {
- this.debug(
- `[enqueueDownload] Item ${item.guid} already in progress, skipping`,
- );
+ async enqueueUpload(item: SyncFile | Document | Canvas): Promise {
+ if (this.shouldSkipDocumentSync(item)) {
+ this.clearFailure(this.failureKey("sync", item.guid));
+ return Promise.resolve();
+ }
- // Return existing promise if already processing
- const existingCallback = this.downloadCompletionCallbacks.get(item.guid);
- if (existingCallback) {
- this.processDownloadQueue();
- return new Promise((resolve, reject) => {
- existingCallback.resolve = resolve;
- existingCallback.reject = reject;
- });
+ if (this.inProgressSyncs.has(item.guid)) {
+ const queued = this.syncQueue.find((queued) => queued.guid === item.guid);
+ if (queued) {
+ queued.syncIntent = "upload";
+ return this.syncPromises.get(item.guid) ?? Promise.resolve();
}
- this.processDownloadQueue();
- return Promise.resolve();
+
+ const active = this.activeSync.find((active) => active.guid === item.guid);
+ if (active?.syncIntent === "upload") {
+ return this.syncPromises.get(item.guid) ?? Promise.resolve();
+ }
+
+ return this.enqueueUploadAfterCurrentSync(item);
}
const sharedFolder = item.sharedFolder;
+ const queueItem: QueueItem = {
+ guid: item.guid,
+ path: sharedFolder.getPath(item.path),
+ doc: item,
+ status: "pending",
+ sharedFolder,
+ userVisible: false,
+ syncIntent: "upload",
+ };
+ this.clearFailure(this.failureKey("sync", item.guid));
- // Get or create the sync group for this folder
let group = this.syncGroups.get(sharedFolder);
if (!group) {
group = {
@@ -524,34 +1121,135 @@ export class BackgroundSync extends HasLogging {
syncs: 0,
completedDownloads: 0,
completedSyncs: 0,
+ failedDownloads: 0,
+ failedSyncs: 0,
+ skippedDownloads: 0,
+ skippedSyncs: 0,
+ userDownloads: 0,
+ completedUserDownloads: 0,
+ failedUserDownloads: 0,
+ skippedUserDownloads: 0,
};
}
-
- // Update the counters for individual document download
- group.downloads++;
group.total++;
+ group.syncs++;
+ group.status = "running";
this.syncGroups.set(sharedFolder, group);
- // Create the queue item
- const queueItem: QueueItem = {
- guid: item.guid,
- path: sharedFolder.getPath(item.path),
- doc: item,
- status: "pending",
- sharedFolder,
- };
+ this.inProgressSyncs.add(item.guid);
- // Mark as in progress
+ const syncPromise = new Promise((resolve, reject) => {
+ this.syncCompletionCallbacks.set(item.guid, {
+ resolve,
+ reject,
+ });
+ });
+ this.syncPromises.set(item.guid, syncPromise);
+
+ this.syncQueue.push(queueItem);
+ this.syncQueue.sort(compareFilePaths);
+ this.queueStatusChanged.notifyListeners();
+ this.processSyncQueue();
+
+ return syncPromise;
+ }
+
+ private async enqueueUploadAfterCurrentSync(
+ item: SyncFile | Document | Canvas,
+ ): Promise {
+ try {
+ await (this.syncPromises.get(item.guid) ?? Promise.resolve());
+ } catch {
+ // The upload request is the stronger follow-up operation. Let it run
+ // even if the weaker sync attempt failed.
+ }
+ await new Promise((resolve) => {
+ this.timeProvider.setTimeout(resolve, 0);
+ });
+ return this.enqueueUpload(item);
+ }
+
+ /**
+ * Enqueues a document for download
+ *
+ * This method adds a document to the download queue and creates/updates
+ * the associated sync group to track progress.
+ *
+ * @param item The document to download
+ * @returns A promise that resolves when the download completes
+ */
+ enqueueDownload(
+ item: SyncFile | Document | Canvas,
+ userVisible = true,
+ ): Promise {
+ // Skip if already in progress — return the same promise all callers share
+ if (this.inProgressDownloads.has(item.guid)) {
+ this.debug(
+ `[enqueueDownload] Item ${item.guid} already in progress, sharing promise`,
+ );
+ return this.downloadPromises.get(item.guid) ?? Promise.resolve(undefined);
+ }
+
+ const sharedFolder = item.sharedFolder;
+
+ // Get or create the sync group for this folder
+ let group = this.syncGroups.get(sharedFolder);
+ if (!group) {
+ group = {
+ sharedFolder,
+ total: 0,
+ completed: 0,
+ status: "pending",
+ downloads: 0,
+ syncs: 0,
+ completedDownloads: 0,
+ completedSyncs: 0,
+ failedDownloads: 0,
+ failedSyncs: 0,
+ skippedDownloads: 0,
+ skippedSyncs: 0,
+ userDownloads: 0,
+ completedUserDownloads: 0,
+ failedUserDownloads: 0,
+ skippedUserDownloads: 0,
+ };
+ }
+
+ // Update the counters for individual document download
+ group.downloads++;
+ group.total++;
+ if (userVisible) {
+ group.userDownloads++;
+ }
+ group.status = "running";
+ this.syncGroups.set(sharedFolder, group);
+
+ // Create the queue item
+ const queueItem: QueueItem = {
+ guid: item.guid,
+ path: sharedFolder.getPath(item.path),
+ doc: item,
+ status: "pending",
+ sharedFolder,
+ userVisible,
+ };
+ this.clearFailure(this.failureKey("download", item.guid));
+
+ // Mark as in progress
this.inProgressDownloads.add(item.guid);
// Create a promise that will resolve when the download completes
- const downloadPromise = new Promise((resolve, reject) => {
- this.downloadCompletionCallbacks.set(item.guid, { resolve, reject });
- });
+ const downloadPromise = new Promise(
+ (resolve, reject) => {
+ this.downloadCompletionCallbacks.set(item.guid, { resolve, reject });
+ },
+ );
+ this.downloadPromises.set(item.guid, downloadPromise);
// Add to the queue and start processing
this.downloadQueue.push(queueItem);
this.downloadQueue.sort(compareFilePaths);
+ this.queueStatusChanged.notifyListeners();
this.processDownloadQueue();
return downloadPromise;
@@ -571,35 +1269,197 @@ export class BackgroundSync extends HasLogging {
const docs = [...sharedFolder.files.values()].filter(isDocument);
const canvases = [...sharedFolder.files.values()].filter(isCanvas);
const syncFiles = [...sharedFolder.files.values()].filter(isSyncFile);
- const allItems = [...docs, ...canvases, ...syncFiles];
+ const allItems = [...docs, ...canvases, ...syncFiles].filter((item) =>
+ this.shouldEnqueueForSharedFolderSync(item),
+ );
// Create sync group with properly initialized counters
const group: SyncGroup = {
sharedFolder,
total: allItems.length,
completed: 0,
- status: "pending",
+ status: allItems.length > 0 ? "pending" : "completed",
downloads: 0,
syncs: allItems.length,
completedDownloads: 0,
completedSyncs: 0,
+ failedDownloads: 0,
+ failedSyncs: 0,
+ skippedDownloads: 0,
+ skippedSyncs: 0,
+ userDownloads: 0,
+ completedUserDownloads: 0,
+ failedUserDownloads: 0,
+ skippedUserDownloads: 0,
};
// Register the group before enqueueing items
this.syncGroups.set(sharedFolder, group);
+ if (allItems.length === 0) return;
// Sort items by path for consistent sync order
- const sortedDocs = [...docs, ...canvases, ...syncFiles].sort(
- compareFilePaths,
- );
+ const sortedDocs = allItems.sort(compareFilePaths);
+ const queueLengthBefore = this.syncQueue.length;
for (const doc of sortedDocs) {
this.enqueueForGroupSync(doc);
}
- // Update group status to running
+ if (this.syncQueue.length > queueLengthBefore) {
+ this.syncQueue.sort(compareFilePaths);
+ this.queueStatusChanged.notifyListeners();
+ this.processSyncQueue();
+ }
+
+ this.updateGroupTerminalStatus(group);
+ this.syncGroups.set(sharedFolder, group);
+ }
+
+ enqueueLCABackfill(sharedFolder: SharedFolder): number {
+ if (!sharedFolder.connected) return 0;
+
+ const docs = [...sharedFolder.files.values()]
+ .filter(isDocument)
+ .filter((doc) => this.shouldEnqueueForLCABackfill(doc))
+ .filter((doc) => !this.inProgressSyncs.has(doc.guid));
+
+ if (docs.length === 0) return 0;
+
+ for (const doc of docs.sort(compareFilePaths)) {
+ void this.enqueueLCABackfillDoc(doc);
+ }
+ return docs.length;
+ }
+
+ private async enqueueLCABackfillDoc(doc: Document): Promise {
+ if (this.shouldSkipDocumentSync(doc)) {
+ this.clearFailure(this.failureKey("sync", doc.guid));
+ return Promise.resolve();
+ }
+
+ if (this.inProgressSyncs.has(doc.guid)) {
+ this.debug(
+ `[enqueueLCABackfillDoc] Item ${doc.guid} already in progress, sharing promise`,
+ );
+ return this.syncPromises.get(doc.guid) ?? Promise.resolve();
+ }
+
+ const sharedFolder = doc.sharedFolder;
+ const queueItem: QueueItem = {
+ guid: doc.guid,
+ path: sharedFolder.getPath(doc.path),
+ doc,
+ status: "pending",
+ sharedFolder,
+ userVisible: false,
+ syncIntent: "lca-backfill",
+ };
+ this.clearFailure(this.failureKey("sync", doc.guid));
+
+ let group = this.syncGroups.get(sharedFolder);
+ if (!group) {
+ group = {
+ sharedFolder,
+ total: 0,
+ completed: 0,
+ status: "pending",
+ downloads: 0,
+ syncs: 0,
+ completedDownloads: 0,
+ completedSyncs: 0,
+ failedDownloads: 0,
+ failedSyncs: 0,
+ skippedDownloads: 0,
+ skippedSyncs: 0,
+ userDownloads: 0,
+ completedUserDownloads: 0,
+ failedUserDownloads: 0,
+ skippedUserDownloads: 0,
+ };
+ }
+ group.total++;
+ group.syncs++;
group.status = "running";
this.syncGroups.set(sharedFolder, group);
+
+ this.inProgressSyncs.add(doc.guid);
+
+ const syncPromise = new Promise((resolve, reject) => {
+ this.syncCompletionCallbacks.set(doc.guid, {
+ resolve,
+ reject,
+ });
+ });
+ this.syncPromises.set(doc.guid, syncPromise);
+
+ this.syncQueue.push(queueItem);
+ this.syncQueue.sort(compareFilePaths);
+ this.queueStatusChanged.notifyListeners();
+ this.processSyncQueue();
+
+ return syncPromise;
+ }
+
+ enqueueRemoteHeadSyncs(
+ sharedFolder: SharedFolder,
+ guids: Iterable,
+ ): number {
+ if (!sharedFolder.connected) return 0;
+
+ const advertisedGuids = new Set(guids);
+ if (advertisedGuids.size === 0) return 0;
+
+ const docs = [...sharedFolder.files.values()]
+ .filter(isDocument)
+ .filter((doc) => advertisedGuids.has(doc.guid))
+ .filter((doc) => !this.inProgressSyncs.has(doc.guid))
+ .filter((doc) => this.shouldEnqueueForRemoteHeadSync(doc));
+
+ for (const doc of docs.sort(compareFilePaths)) {
+ void this.enqueueSync(doc);
+ }
+ return docs.length;
+ }
+
+ private shouldEnqueueForSharedFolderSync(
+ item: Document | Canvas | SyncFile,
+ ): boolean {
+ if (isCanvas(item)) {
+ const mergeManager = item.sharedFolder.mergeManager;
+ if (!mergeManager) return true;
+ return !mergeManager.isServerAdvertisedInSync(
+ item.guid,
+ snapshotFromDoc(item.ydoc).snapshot,
+ );
+ }
+ if (!isDocument(item)) return true;
+
+ const hsm = item.hsm;
+ if (!hsm) return true;
+ if (hsm.getSyncStatus().status === "conflict") return false;
+ if (!hsm.state.lca) return true;
+ if (hsm.getSyncStatus().status !== "synced") return true;
+
+ const mergeManager = item.sharedFolder.mergeManager;
+ if (!mergeManager) return true;
+
+ return !mergeManager.isServerAdvertisedInSync(item.guid);
+ }
+
+ private shouldEnqueueForRemoteHeadSync(doc: Document): boolean {
+ if (this.shouldSkipDocumentSync(doc)) return false;
+ return doc.sharedFolder.mergeManager?.isServerAdvertisedRemoteAhead(doc.guid)
+ ?? false;
+ }
+
+ private shouldEnqueueForLCABackfill(doc: Document): boolean {
+ const hsm = doc.hsm;
+ if (!hsm) return false;
+ if (doc.sharedFolder.isPendingUpload(doc.path)) return false;
+ if (hsm.isActive()) return false;
+ if (hsm.state.lca) return false;
+ if (hsm.hasFork()) return false;
+ return hsm.getSyncStatus().status === "pending";
}
/**
@@ -616,22 +1476,17 @@ export class BackgroundSync extends HasLogging {
private async enqueueForGroupSync(
item: Document | Canvas | SyncFile,
): Promise {
- // Skip if already in progress
+ if (this.shouldSkipDocumentSync(item)) {
+ this.clearFailure(this.failureKey("sync", item.guid));
+ return Promise.resolve();
+ }
+
+ // Skip if already in progress — return the same promise all callers share
if (this.inProgressSyncs.has(item.guid)) {
this.debug(
- `[enqueueForGroupSync] Item ${item.guid} already in progress, skipping`,
+ `[enqueueForGroupSync] Item ${item.guid} already in progress, sharing promise`,
);
-
- // Return existing promise if already processing
- const existingCallback = this.syncCompletionCallbacks.get(item.guid);
- if (existingCallback) {
- this.processSyncQueue();
- return new Promise((resolve, reject) => {
- existingCallback.resolve = resolve;
- existingCallback.reject = reject;
- });
- }
- return Promise.resolve();
+ return this.syncPromises.get(item.guid) ?? Promise.resolve();
}
const sharedFolder = item.sharedFolder;
@@ -641,7 +1496,9 @@ export class BackgroundSync extends HasLogging {
doc: item,
status: "pending",
sharedFolder,
+ userVisible: false,
};
+ this.clearFailure(this.failureKey("sync", item.guid));
this.inProgressSyncs.add(item.guid);
@@ -651,10 +1508,9 @@ export class BackgroundSync extends HasLogging {
reject,
});
});
+ this.syncPromises.set(item.guid, syncPromise);
this.syncQueue.push(queueItem);
- this.syncQueue.sort(compareFilePaths);
- this.processSyncQueue();
return syncPromise;
}
@@ -662,6 +1518,7 @@ export class BackgroundSync extends HasLogging {
private getAuthHeader(clientToken: ClientToken) {
return {
Authorization: `Bearer ${clientToken.token}`,
+ ...getRelayRequestHeaders(),
};
}
@@ -724,58 +1581,218 @@ export class BackgroundSync extends HasLogging {
return response;
}
- async syncDocumentWebsocket(doc: Document | Canvas): Promise {
- // if the local file is synced, then we do the two step process
- // check if file is tracking
- let currentFileContents = "";
+ /**
+ * Download raw CRDT bytes for a document by guid, without needing a
+ * Document instance. Used by the SharedFolder guid-remap path, where
+ * the server's content must be fetched *before* the old Document is
+ * destroyed — a failure here leaves old state intact and retriable.
+ *
+ * Does not participate in the download queue, syncGroups, or
+ * in-progress tracking. It is a bare HTTP fetch.
+ *
+ * Returns undefined if the server has the guid registered but no
+ * peer has uploaded content yet (empty contents, empty users map).
+ */
+ async downloadByGuid(
+ sharedFolder: SharedFolder,
+ guid: string,
+ path: string,
+ ): Promise {
+ const entity = new S3RemoteDocument(
+ sharedFolder.relayId!,
+ sharedFolder.guid,
+ guid,
+ );
+ this.log("[downloadByGuid]", path, S3RN.encode(entity));
- // Handle different document types
- let currentTextStr = "";
- let currentCanvasData: CanvasData | null = null;
+ const clientToken = await sharedFolder.tokenStore.getToken(
+ S3RN.encode(entity),
+ path,
+ () => {},
+ );
+ const headers = this.getAuthHeader(clientToken);
+ const baseUrl = this.getBaseUrl(clientToken, entity);
+ const url = `${baseUrl}/as-update`;
- if (isCanvas(doc)) {
- // Store the exported canvas data rather than a stringified version
- currentCanvasData = Canvas.exportCanvasData(doc.ydoc);
- currentTextStr = JSON.stringify(currentCanvasData);
- } else if (isDocument(doc)) {
- currentTextStr = doc.text;
- }
- try {
- currentFileContents = await doc.sharedFolder.read(doc);
- } catch (e) {
- // File does not exist
- }
+ const response = await requestUrl({
+ url,
+ method: "GET",
+ headers,
+ throw: false,
+ });
- // Only proceed with update if file matches current ydoc state
- let contentsMatch = false;
- if (isCanvas(doc) && currentCanvasData) {
- // For canvas, use deep object comparison instead of string equality
- const currentFileJson = currentFileContents
- ? JSON.parse(currentFileContents)
- : { nodes: [], edges: [] };
- contentsMatch = areObjectsEqual(currentCanvasData, currentFileJson);
- } else {
- contentsMatch = currentTextStr === currentFileContents;
+ if (response.status !== 200) {
+ this.error(
+ "[downloadByGuid]",
+ path,
+ url,
+ response.status,
+ response.text,
+ );
+ throw new Error(
+ `downloadByGuid: status ${response.status} for ${S3RN.encode(entity)}`,
+ );
}
- if (!contentsMatch && currentFileContents) {
+ const updateBytes = new Uint8Array(response.arrayBuffer);
+
+ // Peek at the update in a throwaway doc to detect empty-server.
+ const tmpDoc = new Y.Doc();
+ Y.applyUpdate(tmpDoc, updateBytes);
+ if (isEmptyDoc(tmpDoc)) {
this.log(
- "file is not tracking local disk. resolve merge conflicts before syncing.",
+ "[downloadByGuid] server has guid registered but no content",
+ path,
);
+ return undefined;
+ }
+ return updateBytes;
+ }
+
+ async syncDocumentWebsocket(doc: Document | Canvas): Promise {
+ if (doc.destroyed) return false;
+ this.log(`[syncDocWS] start: ${doc.path} guid=${doc.guid} intent=${doc.intent} connected=${doc.connected}`);
+ if (this.isSyncCancelledForDoc(doc)) return false;
+ // if the local file is synced, then we do the two step process
+ if (isCanvas(doc)) {
+ // Store the exported canvas data rather than a stringified version
+ const currentCanvasData = Canvas.exportCanvasData(doc.ydoc);
+ let canvasContentsMismatch = false;
+ try {
+ const currentFileContents = await doc.sharedFolder.read(doc);
+
+ // Only proceed with update if file matches current ydoc state
+ let contentsMatch = false;
+ if (isCanvas(doc) && currentCanvasData) {
+ // For canvas, use deep object comparison instead of string equality
+ const currentFileJson = currentFileContents
+ ? JSON.parse(currentFileContents)
+ : { nodes: [], edges: [] };
+ contentsMatch = areCanvasDataEqual(currentCanvasData, currentFileJson);
+ if (
+ !contentsMatch &&
+ await this.repairStaleCanvasText(doc, currentFileJson)
+ ) {
+ contentsMatch = true;
+ }
+ if (!contentsMatch && currentFileContents) {
+ canvasContentsMismatch = true;
+ }
+ }
+ } catch (e) {
+ // File does not exist
+ }
+ if (canvasContentsMismatch) {
+ throw new Error(
+ "Canvas file does not match local sync state. Open the canvas and resolve the local changes before syncing.",
+ );
+ }
+ }
+ const sharedFolder = doc.sharedFolder;
+ const refreshQueueKey = S3RN.encode(doc.s3rn);
+ const isActive = doc.userLock || sharedFolder?.mergeManager?.isActive(doc.guid);
+ const startedDisconnected = doc.intent === "disconnected";
+ const hadProviderIntegration = isDocument(doc) && doc.hasProviderIntegration();
+ const acquiredIdleIntegration =
+ isDocument(doc) && !isActive
+ ? doc.ensureIdleProviderIntegration({
+ freshRemoteDoc: !!doc.hsm?.hasFork(),
+ })
+ : false;
+ const shouldCleanupIdleSession = () =>
+ startedDisconnected &&
+ !(doc.userLock || sharedFolder?.mergeManager?.isActive(doc.guid));
+ const cleanupIdleSession = () => {
+ if (isDocument(doc)) {
+ if (acquiredIdleIntegration) {
+ doc.destroyIdleProviderIntegration();
+ if (shouldCleanupIdleSession()) {
+ sharedFolder?.tokenStore.removeFromRefreshQueue(refreshQueueKey);
+ }
+ return;
+ }
+ if (!shouldCleanupIdleSession()) return;
+ if (hadProviderIntegration || doc.hasProviderIntegration()) {
+ return;
+ }
+ }
+ if (!shouldCleanupIdleSession()) return;
+ doc.disconnect();
+ sharedFolder?.tokenStore.removeFromRefreshQueue(refreshQueueKey);
+ };
+ if (doc.destroyed) return false;
+ const connected = await doc.connect();
+ if (!connected) {
+ if (shouldCleanupIdleSession()) {
+ cleanupIdleSession();
+ }
+ if (this.isSyncCancelledForDoc(doc)) return false;
+ throw new RetryableProviderSyncError(
+ `Provider connection is not ready for ${this.fileName(doc.path)}`,
+ );
+ }
+ if (this.isSyncCancelledForDoc(doc)) {
+ if (shouldCleanupIdleSession()) {
+ cleanupIdleSession();
+ }
return false;
}
+ // Always wait for provider sync — _providerSynced fast-path resolves
+ // immediately if already synced. Connected does not imply synced.
+ // Timeout prevents hanging the sync queue if the connection drops.
+ const SYNC_TIMEOUT_MS = 10_000;
+ let timerId: number | undefined;
+ let cancelTimerId: number | undefined;
+ let providerSyncFailure: unknown;
+ const synced = await Promise.race([
+ doc.onceProviderSynced().then(
+ () => true,
+ (e) => {
+ providerSyncFailure = e;
+ return false;
+ },
+ ),
+ new Promise((resolve) => {
+ timerId = this.timeProvider.setTimeout(
+ () => resolve(false),
+ SYNC_TIMEOUT_MS,
+ );
+ }),
+ new Promise((resolve) => {
+ cancelTimerId = this.timeProvider.setInterval(() => {
+ if (this.isSyncCancelledForDoc(doc)) resolve(false);
+ }, 100);
+ }),
+ ]);
+ if (timerId !== undefined) this.timeProvider.clearTimeout(timerId);
+ if (cancelTimerId !== undefined) this.timeProvider.clearInterval(cancelTimerId);
+ if (!synced) {
+ if (shouldCleanupIdleSession()) {
+ cleanupIdleSession();
+ }
+ if (this.isSyncCancelledForDoc(doc)) return false;
+ if (providerSyncFailure) {
+ this.warn(
+ `[syncDocWS] provider sync failed: ${doc.path} guid=${doc.guid}: ${this.errorMessage(providerSyncFailure)}`,
+ );
+ throw new RetryableProviderSyncError(
+ `Provider sync is not ready for ${this.fileName(doc.path)}: ${this.errorMessage(providerSyncFailure)}`,
+ );
+ } else {
+ this.warn(`[syncDocWS] provider sync timed out: ${doc.path} guid=${doc.guid}`);
+ throw new RetryableProviderSyncError(
+ `Provider sync timed out for ${this.fileName(doc.path)}`,
+ );
+ }
+ }
- const promise = doc.onceProviderSynced();
- const intent = doc.intent;
- doc.connect();
- if (intent === "disconnected") {
- await promise;
+ if (isDocument(doc)) {
+ await this.maybeBootstrapDocumentLCA(doc);
}
// promise can take some time
- if (intent === "disconnected" && !doc.userLock) {
- doc.disconnect();
- doc.sharedFolder.tokenStore.removeFromRefreshQueue(S3RN.encode(doc.s3rn));
+ if (shouldCleanupIdleSession()) {
+ cleanupIdleSession();
}
return true;
}
@@ -785,8 +1802,11 @@ export class BackgroundSync extends HasLogging {
* @param canvas The canvas to download
* @returns A promise that resolves when the download completes
*/
- enqueueCanvasDownload(canvas: Canvas): Promise {
- return this.enqueueDownload(canvas);
+ enqueueCanvasDownload(
+ canvas: Canvas,
+ userVisible = true,
+ ): Promise {
+ return this.enqueueDownload(canvas, userVisible);
}
async getCanvas(canvas: Canvas, retry = 3, wait = 3000) {
@@ -802,9 +1822,7 @@ export class BackgroundSync extends HasLogging {
}
// Only proceed with update if file matches current ydoc state
- const contentsMatch =
- areObjectsEqual(currentJson.edges, currentFileContents.edges) &&
- areObjectsEqual(currentJson.nodes, currentFileContents.nodes);
+ const contentsMatch = areCanvasDataEqual(currentJson, currentFileContents);
const hasContents = currentFileContents.nodes.length > 0;
const response = await this.downloadItem(canvas);
@@ -823,74 +1841,94 @@ export class BackgroundSync extends HasLogging {
this.log("[getCanvas] flushed");
}
} catch (e) {
- this.error(e);
+ this.logError("[getCanvas] failed", e);
throw e;
}
}
- private async getDocument(doc: Document, retry = 3, wait = 3000) {
+ private async getDocument(
+ doc: Document,
+ retry = 3,
+ wait = 3000,
+ ): Promise {
try {
- // Get the current contents before applying the update
- const currentText = doc.text;
- let currentFileContents = "";
- try {
- currentFileContents = await doc.sharedFolder.read(doc);
- } catch (e) {
- // File doesn't exist
- }
-
- // Only proceed with update if file matches current ydoc state
- const contentsMatch = currentText === currentFileContents;
- const hasContents = currentFileContents !== "";
-
const response = await this.downloadItem(doc);
const rawUpdate = response.arrayBuffer;
const updateBytes = new Uint8Array(rawUpdate);
- // Check for newly created documents without content, and reject them
+ // Validate: reject uninitialized documents.
const newDoc = new Y.Doc();
Y.applyUpdate(newDoc, updateBytes);
- const users = newDoc.getMap("users");
- const contents = newDoc.getText("contents").toString();
- if (contents === "") {
- if (users.size === 0) {
- // Hack for better compat with < 0.4.2.
- this.log(
- "[getDocument] Server contains uninitialized document. Waiting for peer to upload.",
- users.size,
- retry,
- wait,
- );
- if (retry > 0) {
- this.timeProvider.setTimeout(() => {
- this.getDocument(doc, retry - 1, wait * 2);
- }, wait);
- }
- return;
- }
+ if (isEmptyDoc(newDoc)) {
if (doc.text) {
this.log(
- "[getDocument] local crdt has contents, but remote is empty",
+ "[getDocument] server CRDT empty, local has content — uploading",
);
this.enqueueSync(doc);
- return;
+ return undefined;
}
+ this.log(
+ "[getDocument] Server contains uninitialized document. Waiting for peer to upload.",
+ retry,
+ wait,
+ );
+ if (retry > 0) {
+ this.timeProvider.setTimeout(() => {
+ this.getDocument(doc, retry - 1, wait * 2);
+ }, wait);
+ }
+ return undefined;
}
this.log("[getDocument] applying content from server");
Y.applyUpdate(doc.ydoc, updateBytes);
+ doc.hsm?.setRemoteDoc(doc.ydoc);
+ this.notifyDownloadedRemoteHead(doc);
+ await this.maybeBootstrapDocumentLCA(doc);
+ return updateBytes;
+ } catch (e) {
+ this.logError("[getDocument] failed", e);
+ throw e;
+ }
+ }
- if (hasContents && !contentsMatch) {
- this.log("Skipping flush - file requires merge conflict resolution.");
- return;
+ private notifyDownloadedRemoteHead(doc: Document): void {
+ const hsm = doc.hsm;
+ if (!hsm) return;
+
+ hsm.send({ type: "PROVIDER_SYNCED" });
+ }
+
+ private async maybeBootstrapDocumentLCA(doc: Document): Promise {
+ const hsm = doc.hsm;
+ if (!hsm || hsm.state.lca || hsm.isActive()) return;
+
+ try {
+ const mergeManager = doc.sharedFolder.mergeManager;
+ if (mergeManager?.getHibernationState(doc.guid) === "hibernated") {
+ mergeManager.wake(doc.guid, doc.ensureRemoteDoc());
+ await hsm.awaitPersistenceReady();
}
- if (doc.sharedFolder.syncStore.has(doc.path)) {
- doc.sharedFolder.flush(doc, doc.text);
- this.log("[getDocument] flushed");
+
+ const diskState = await doc.readDiskContent();
+ const repaired = await hsm.bootstrapLCAFromDisk(diskState);
+ if (!repaired && hsm.getSyncStatus().status === "pending") {
+ if (!hsm.hasPersistenceUserData()) {
+ this.debug(
+ `[bootstrapLCA] deferred for ${doc.path}: awaiting local CRDT enrollment`,
+ );
+ return;
+ }
+ this.debug(
+ `[bootstrapLCA] skipped for ${doc.path}: local CRDT is not enrolled or remote state is not ready`,
+ );
}
} catch (e) {
- this.error(e);
+ this.warn(
+ `[bootstrapLCA] failed for ${doc.path}: ${this.errorMessage(e)}`,
+ e,
+ );
throw e;
}
}
@@ -903,17 +1941,123 @@ export class BackgroundSync extends HasLogging {
await file.pull();
}
- private async syncDocument(doc: Document | Canvas) {
+ private async syncDocument(doc: Document | Canvas): Promise {
+ if (doc.destroyed) {
+ return;
+ }
+ if (this.shouldSkipDocumentSync(doc)) {
+ return;
+ }
try {
- if (isDocument(doc)) {
- await this.syncDocumentWebsocket(doc);
- } else if (isCanvas(doc)) {
- await this.syncDocumentWebsocket(doc);
+ if (isDocument(doc) || isCanvas(doc)) {
+ const synced = await this.syncDocumentWebsocket(doc);
+ if (!synced) {
+ if (this.isSyncCancelledForDoc(doc)) return;
+ throw new Error(`Unable to sync ${this.fileName(doc.path)}`);
+ }
}
} catch (e) {
- console.error(e);
+ if (!isRetryableProviderSyncError(e)) {
+ this.logError("[syncDocument] failed", e);
+ }
+ throw e;
+ }
+ }
+
+ private async syncDocumentUpload(doc: Document | Canvas): Promise {
+ if (isDocument(doc) && doc.hsm) {
+ await this.prepareDocumentUpload(doc);
+ }
+ await this.syncDocument(doc);
+ if (isDocument(doc) && doc.hsm) {
+ this.assertUploadedDocumentHasRemoteContent(doc);
+ }
+ }
+
+ private async syncDocumentLCABackfill(doc: Document): Promise {
+ if (doc.destroyed || this.shouldSkipDocumentSync(doc)) {
return;
}
+
+ const hsm = doc.hsm;
+ if (!hsm || hsm.state.lca || hsm.isActive() || hsm.hasFork()) {
+ return;
+ }
+
+ let updateBytes: Uint8Array | undefined;
+ try {
+ updateBytes = await this.downloadByGuid(
+ doc.sharedFolder,
+ doc.guid,
+ doc.path,
+ );
+ } catch (error) {
+ throw new RetryableProviderSyncError(
+ `LCA backfill download failed for ${this.fileName(doc.path)}: ${this.errorMessage(error)}`,
+ );
+ }
+ if (!updateBytes) {
+ throw new RetryableProviderSyncError(
+ `Remote document is empty while backfilling LCA: ${this.fileName(doc.path)}`,
+ );
+ }
+
+ const remoteDoc = new Y.Doc();
+ Y.applyUpdate(remoteDoc, updateBytes);
+ if (isEmptyDoc(remoteDoc)) {
+ throw new RetryableProviderSyncError(
+ `Remote document is empty while backfilling LCA: ${this.fileName(doc.path)}`,
+ );
+ }
+
+ hsm.setRemoteDoc(remoteDoc);
+ const diskState = await doc.readDiskContent();
+ const settled = await hsm.bootstrapLCAFromDisk(diskState);
+ if (!settled && hsm.getSyncStatus().status === "pending") {
+ throw new RetryableProviderSyncError(
+ `LCA backfill deferred for ${this.fileName(doc.path)}`,
+ );
+ }
+ }
+
+ private async prepareDocumentUpload(doc: Document): Promise {
+ const hsm = doc.hsm;
+ if (!hsm) return;
+ if (hsm.hasFork()) {
+ throw new Error(`Cannot upload ${this.fileName(doc.path)} while a fork exists`);
+ }
+
+ const remoteDoc = doc.ensureRemoteDoc();
+ const mergeManager = doc.sharedFolder.mergeManager;
+ if (!doc.userLock && !mergeManager?.isActive(doc.guid)) {
+ mergeManager?.wake(doc.guid, remoteDoc);
+ } else {
+ hsm.setRemoteDoc(remoteDoc);
+ }
+ await hsm.awaitPersistenceReady();
+
+ if (hsm.hasFork()) {
+ throw new Error(`Cannot upload ${this.fileName(doc.path)} while a fork exists`);
+ }
+ const localDoc = hsm.getLocalDoc();
+ if (!localDoc) {
+ throw new RetryableProviderSyncError(
+ `Local document is not ready for upload: ${this.fileName(doc.path)}`,
+ );
+ }
+
+ Y.applyUpdate(remoteDoc, Y.encodeStateAsUpdate(localDoc), hsm);
+ hsm.setRemoteDoc(remoteDoc);
+ this.assertUploadedDocumentHasRemoteContent(doc);
+ }
+
+ private assertUploadedDocumentHasRemoteContent(doc: Document): void {
+ const remoteDoc = doc.hsm?.getRemoteDoc() ?? doc.remoteDocOrNull;
+ if (!remoteDoc || isEmptyDoc(remoteDoc)) {
+ throw new RetryableProviderSyncError(
+ `Remote document is empty after upload preparation: ${this.fileName(doc.path)}`,
+ );
+ }
}
subscribeToSync(
@@ -966,6 +2110,74 @@ export class BackgroundSync extends HasLogging {
});
}
+ subscribeToFolderSyncSnapshot(
+ sharedFolder: SharedFolder,
+ callback: Subscriber,
+ ): Unsubscriber {
+ const state = this.getFolderSyncSnapshotSubscription(sharedFolder);
+ state.subscribers.add(callback);
+ if (state.latestSnapshot) callback(state.latestSnapshot);
+
+ return () => {
+ state.subscribers.delete(callback);
+ if (state.subscribers.size === 0) {
+ this.disposeFolderSyncSnapshotSubscription(sharedFolder, state);
+ }
+ };
+ }
+
+ private getFolderSyncSnapshotSubscription(
+ sharedFolder: SharedFolder,
+ ): FolderSyncSnapshotSubscription {
+ const existing = this.folderSyncSnapshotSubscriptions.get(sharedFolder);
+ if (existing) return existing;
+
+ const state: FolderSyncSnapshotSubscription = {
+ smoother: null as any,
+ subscribers: new Set(),
+ latestSnapshot: null,
+ unsubscribers: [],
+ emit: () => {},
+ };
+ state.smoother = new FolderSyncSnapshotSmoother(
+ this.timeProvider,
+ (snapshot) => {
+ state.latestSnapshot = snapshot;
+ for (const subscriber of state.subscribers) {
+ subscriber(snapshot);
+ }
+ },
+ );
+ state.emit = () => {
+ state.smoother.update(this.getFolderSyncSnapshot(sharedFolder));
+ };
+ const folderStateKey = { type: "folder-sync-snapshot", sharedFolder };
+ state.unsubscribers = [
+ this.activeSync.on(state.emit),
+ this.activeDownloads.on(state.emit),
+ this.syncGroups.on(state.emit),
+ this.failures.on(state.emit),
+ this.folderResyncs.on(state.emit),
+ this.queueStatusChanged.on(state.emit),
+ sharedFolder.subscribe(folderStateKey, state.emit),
+ ];
+ this.folderSyncSnapshotSubscriptions.set(sharedFolder, state);
+ state.emit();
+ return state;
+ }
+
+ private disposeFolderSyncSnapshotSubscription(
+ sharedFolder: SharedFolder,
+ state: FolderSyncSnapshotSubscription,
+ ): void {
+ if (this.folderSyncSnapshotSubscriptions.get(sharedFolder) !== state) return;
+ this.folderSyncSnapshotSubscriptions.delete(sharedFolder);
+ state.unsubscribers.forEach((unsubscribe) => unsubscribe());
+ state.smoother.destroy();
+ state.subscribers.clear();
+ state.latestSnapshot = null;
+ }
+
/**
* Pauses all sync and download queue processing
*
@@ -974,6 +2186,7 @@ export class BackgroundSync extends HasLogging {
*/
pause(): void {
this.isPaused = true;
+ this.queueStatusChanged.notifyListeners();
}
/**
@@ -985,6 +2198,7 @@ export class BackgroundSync extends HasLogging {
resume(): void {
this.debug("starting");
this.isPaused = false;
+ this.queueStatusChanged.notifyListeners();
this.processSyncQueue();
this.processDownloadQueue();
}
@@ -995,13 +2209,7 @@ export class BackgroundSync extends HasLogging {
*
* @returns An object with queue statistics
*/
- getQueueStatus(): {
- syncsQueued: number;
- syncsActive: number;
- downloadsQueued: number;
- downloadsActive: number;
- isPaused: boolean;
- } {
+ getQueueStatus(): QueueStatus {
return {
syncsQueued: this.syncQueue.length,
syncsActive: this.activeSync.size,
@@ -1011,6 +2219,21 @@ export class BackgroundSync extends HasLogging {
};
}
+ subscribeToQueueStatus(callback: Subscriber): Unsubscriber {
+ const emit = () => callback(this.getQueueStatus());
+ const unsubscribers = [
+ this.activeSync.subscribe(emit),
+ this.activeDownloads.subscribe(emit),
+ this.syncGroups.subscribe(emit),
+ this.failures.subscribe(emit),
+ this.queueStatusChanged.subscribe(emit),
+ ];
+
+ return () => {
+ unsubscribers.forEach((unsubscribe) => unsubscribe());
+ };
+ }
+
/**
* Destroys this instance and cleans up all resources
*
@@ -1031,16 +2254,27 @@ export class BackgroundSync extends HasLogging {
this.downloadCompletionCallbacks.delete(guid);
}
+ for (const [sharedFolder, state] of [
+ ...this.folderSyncSnapshotSubscriptions.entries(),
+ ]) {
+ this.disposeFolderSyncSnapshotSubscription(sharedFolder, state);
+ }
+
// Destroy observable collections
this.activeSync.destroy();
this.activeDownloads.destroy();
+ this.folderResyncs.destroy();
this.syncGroups.destroy();
+ this.failures.destroy();
+ this.queueStatusChanged.destroy();
// Clear queues and tracking
this.syncQueue = [];
this.downloadQueue = [];
this.inProgressSyncs.clear();
this.inProgressDownloads.clear();
+ this.syncPromises.clear();
+ this.downloadPromises.clear();
this.loggedItems.clear();
// Clean up references
@@ -1050,4 +2284,53 @@ export class BackgroundSync extends HasLogging {
// Unsubscribe from all subscriptions
this.subscriptions.forEach((off) => off());
}
+
+ private recordFailure(
+ kind: BackgroundSyncFailure["kind"],
+ item: QueueItem,
+ error: unknown,
+ ): void {
+ const id = this.failureKey(kind, item.guid);
+ this.setFailure({
+ id,
+ guid: item.guid,
+ path: item.doc.path,
+ kind,
+ message: this.errorMessage(error),
+ sharedFolder: item.sharedFolder,
+ });
+ }
+
+ private setFailure(failure: BackgroundSyncFailure): void {
+ const existing = this.failures.get(failure.id);
+ if (
+ existing &&
+ existing.guid === failure.guid &&
+ existing.path === failure.path &&
+ existing.kind === failure.kind &&
+ existing.message === failure.message &&
+ existing.sharedFolder === failure.sharedFolder
+ ) {
+ return;
+ }
+ this.failures.set(failure.id, failure);
+ }
+
+ private failureKey(kind: BackgroundSyncFailure["kind"], guid: string): string {
+ return `${kind}:${guid}`;
+ }
+
+ private errorMessage(error: unknown): string {
+ return formatUserFacingError(error);
+ }
+
+ private logError(context: string, error: unknown): void {
+ this.error(`${context}: ${this.errorMessage(error)}`, error);
+ }
+
+ private fileName(path: string): string {
+ const normalized = path.replace(/\\/g, "/");
+ const parts = normalized.split("/").filter(Boolean);
+ return parts[parts.length - 1] || "file";
+ }
}
diff --git a/src/BackgroundSyncProgress.ts b/src/BackgroundSyncProgress.ts
new file mode 100644
index 00000000..5d0664f8
--- /dev/null
+++ b/src/BackgroundSyncProgress.ts
@@ -0,0 +1,524 @@
+import type { TimeProvider } from "./TimeProvider";
+
+export interface FolderPillProgressDecisionInput {
+ hasQueuedOrActiveWork: boolean;
+ userDownloads: number;
+ completedUserDownloads: number;
+ failedUserDownloads: number;
+ skippedUserDownloads?: number;
+}
+
+export type FolderSyncVisibleState =
+ | "synced"
+ | "syncing"
+ | "queued"
+ | "paused"
+ | "sync-issue";
+
+export type FolderSyncAction = "pause" | "resume" | "resync" | null;
+
+export type FolderSyncProgressStatus =
+ | "pending"
+ | "running"
+ | "completed"
+ | "failed";
+
+export interface FolderSyncGroupInput {
+ total: number;
+ completed: number;
+ status: FolderSyncProgressStatus;
+ downloads: number;
+ syncs: number;
+ completedDownloads: number;
+ completedSyncs: number;
+ failedDownloads?: number;
+ failedSyncs?: number;
+ skippedDownloads?: number;
+ skippedSyncs?: number;
+ userDownloads: number;
+ completedUserDownloads: number;
+ failedUserDownloads: number;
+ skippedUserDownloads?: number;
+}
+
+export interface FolderSyncWorkItemInput {
+ kind: "sync" | "download";
+ path: string;
+}
+
+export interface FolderSyncSnapshotInput {
+ group?: FolderSyncGroupInput | null;
+ queued: number;
+ active: number;
+ isPaused: boolean;
+ failureCount: number;
+ canResync?: boolean;
+ folderActivity?: "checking" | null;
+ activeItem?: FolderSyncWorkItemInput | null;
+ queuedReason?:
+ | "connection"
+ | "reconnecting"
+ | null;
+}
+
+export interface FolderSyncSnapshot {
+ percent: number;
+ syncPercent: number;
+ downloadPercent: number;
+ showProgress: boolean;
+ progressStatus: FolderSyncProgressStatus;
+ visibleState: FolderSyncVisibleState;
+ label: "Synced" | "Syncing" | "Queued" | "Paused" | "Sync issue";
+ latestActivity: string | null;
+ syncAction: FolderSyncAction;
+ queued: number;
+ active: number;
+ total: number;
+ failureCount: number;
+ isPaused: boolean;
+}
+
+export function shouldShowCompletedFolderPillProgress(
+ input: FolderPillProgressDecisionInput,
+): boolean {
+ return !input.hasQueuedOrActiveWork;
+}
+
+export function shouldUseUserVisibleFolderProgress(
+ input: FolderPillProgressDecisionInput,
+): boolean {
+ const visibleDownloadsFinished =
+ input.completedUserDownloads +
+ input.failedUserDownloads +
+ (input.skippedUserDownloads ?? 0);
+ return (
+ input.userDownloads > 0 &&
+ visibleDownloadsFinished < input.userDownloads
+ );
+}
+
+export function buildFolderSyncSnapshot(
+ input: FolderSyncSnapshotInput,
+): FolderSyncSnapshot {
+ const progress = computeFolderProgress({
+ group: input.group,
+ queued: input.queued,
+ active: input.active,
+ isPaused: input.isPaused,
+ failureCount: input.failureCount,
+ });
+ const visibleState = deriveVisibleState({
+ isPaused: input.isPaused,
+ active: input.active,
+ queued: input.queued,
+ failureCount: input.failureCount,
+ });
+ return {
+ ...progress,
+ showProgress: shouldShowProgress(progress.percent, progress.progressStatus),
+ visibleState,
+ label: labelForVisibleState(visibleState),
+ latestActivity: latestActivityForSnapshot(input, visibleState),
+ syncAction: actionForVisibleState(
+ visibleState,
+ input.active + input.queued,
+ input.canResync ?? true,
+ ),
+ queued: input.queued,
+ active: input.active,
+ total: progress.total,
+ failureCount: input.failureCount,
+ isPaused: input.isPaused,
+ };
+}
+
+const PROGRESS_TRANSITION_MS = 1000;
+
+export class FolderSyncSnapshotSmoother {
+ private latestSnapshot: FolderSyncSnapshot | null = null;
+ private displayedPercent = 0;
+ private targetPercent = 0;
+ private progressTimer: number | null = null;
+ private transitionStartedAt: number | null = null;
+ private transitionEndsAt: number | null = null;
+ private lastEmittedSnapshot: FolderSyncSnapshot | null = null;
+ private hasBaseline = false;
+ private hasProgressActivity = false;
+
+ constructor(
+ private readonly timeProvider: TimeProvider,
+ private readonly emit: (snapshot: FolderSyncSnapshot) => void,
+ ) {}
+
+ update(snapshot: FolderSyncSnapshot): void {
+ const target = normalizeProgress(snapshot.percent);
+ this.latestSnapshot = snapshot;
+
+ if (!this.hasBaseline) {
+ this.hasBaseline = true;
+ this.displayedPercent = target;
+ this.targetPercent = target;
+ this.emitCurrent(false);
+ return;
+ }
+
+ if (target < this.displayedPercent) {
+ this.displayedPercent = target;
+ this.targetPercent = target;
+ this.hasProgressActivity = false;
+ this.transitionStartedAt = null;
+ this.transitionEndsAt = null;
+ this.clearTimer();
+ this.emitCurrent(false);
+ return;
+ }
+
+ if (target !== this.targetPercent) {
+ this.targetPercent = target;
+ this.hasProgressActivity = true;
+ if (this.transitionStartedAt === null) {
+ this.transitionStartedAt = this.timeProvider.now();
+ this.transitionEndsAt =
+ this.transitionStartedAt + PROGRESS_TRANSITION_MS;
+ }
+ this.clearTimer();
+ this.scheduleProgressStep();
+ return;
+ }
+
+ this.emitCurrent();
+ }
+
+ destroy(): void {
+ this.clearTimer();
+ }
+
+ private scheduleProgressStep(): void {
+ if (!this.latestSnapshot) return;
+ if (this.displayedPercent === this.targetPercent) {
+ this.transitionStartedAt = null;
+ this.transitionEndsAt = null;
+ this.emitCurrent();
+ return;
+ }
+
+ const remainingMs = Math.max(
+ 0,
+ (this.transitionEndsAt ?? this.timeProvider.now()) -
+ this.timeProvider.now(),
+ );
+ const remainingSteps = Math.abs(this.targetPercent - this.displayedPercent);
+ const stepDelayMs =
+ remainingMs > 0
+ ? Math.max(1, Math.ceil(remainingMs / remainingSteps))
+ : 1;
+
+ this.progressTimer = this.timeProvider.setTimeout(() => {
+ this.progressTimer = null;
+ this.advanceProgress();
+ }, stepDelayMs);
+ }
+
+ private advanceProgress(): void {
+ if (!this.latestSnapshot) return;
+ if (this.displayedPercent === this.targetPercent) {
+ this.scheduleProgressStep();
+ return;
+ }
+
+ this.displayedPercent +=
+ this.displayedPercent < this.targetPercent ? 1 : -1;
+ this.emitCurrent();
+ this.scheduleProgressStep();
+ }
+
+ private emitCurrent(forceHide = false): void {
+ if (!this.latestSnapshot) return;
+ const isFinalizing = this.isFinalizingProgress();
+ const progressStatus = isFinalizing
+ ? "running"
+ : this.latestSnapshot.progressStatus;
+ const visibleState = isFinalizing
+ ? "syncing"
+ : this.latestSnapshot.visibleState;
+ const next = {
+ ...this.latestSnapshot,
+ percent: this.displayedPercent,
+ progressStatus,
+ visibleState,
+ label: isFinalizing ? "Syncing" : this.latestSnapshot.label,
+ latestActivity: isFinalizing
+ ? (this.latestSnapshot.latestActivity ?? "Finalizing...")
+ : this.latestSnapshot.latestActivity,
+ syncAction: isFinalizing ? null : this.latestSnapshot.syncAction,
+ showProgress:
+ !forceHide &&
+ this.hasProgressActivity &&
+ shouldShowProgress(this.displayedPercent, progressStatus),
+ };
+ if (shallowFolderSyncSnapshotEqual(this.lastEmittedSnapshot, next)) return;
+ this.lastEmittedSnapshot = { ...next };
+ this.emit(next);
+ }
+
+ private isFinalizingProgress(): boolean {
+ if (!this.latestSnapshot) return false;
+ if (!this.hasProgressActivity) return false;
+ if (this.displayedPercent === this.targetPercent) return false;
+ return this.latestSnapshot.visibleState === "synced";
+ }
+
+ private clearTimer(): void {
+ if (this.progressTimer === null) return;
+ this.timeProvider.clearTimeout(this.progressTimer);
+ this.progressTimer = null;
+ }
+}
+
+function normalizeProgress(value: number): number {
+ if (!Number.isFinite(value)) return 0;
+ return Math.max(0, Math.min(100, Math.round(value)));
+}
+
+function shallowFolderSyncSnapshotEqual(
+ a: FolderSyncSnapshot | null,
+ b: FolderSyncSnapshot,
+): boolean {
+ if (a === null) return false;
+ const aKeys = Object.keys(a) as Array;
+ const bKeys = Object.keys(b) as Array;
+ if (aKeys.length !== bKeys.length) return false;
+ for (const key of aKeys) {
+ if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
+ if (!Object.is(a[key], b[key])) return false;
+ }
+ return true;
+}
+
+function shouldShowProgress(
+ percent: number,
+ status: FolderSyncProgressStatus,
+): boolean {
+ return (
+ percent > 0 &&
+ (status === "running" ||
+ status === "failed" ||
+ (status === "completed" && percent < 100))
+ );
+}
+
+function computeFolderProgress(input: {
+ group?: FolderSyncGroupInput | null;
+ queued: number;
+ active: number;
+ isPaused: boolean;
+ failureCount: number;
+}): Pick<
+ FolderSyncSnapshot,
+ "percent" | "syncPercent" | "downloadPercent" | "progressStatus" | "total"
+> {
+ const hasQueuedOrActiveWork = input.queued + input.active > 0;
+ const queuedWithoutActiveWork =
+ !input.isPaused && input.active === 0 && input.queued > 0;
+ const group = input.group;
+ if (!group) {
+ return {
+ percent: hasQueuedOrActiveWork && !queuedWithoutActiveWork ? 0 : 100,
+ syncPercent: hasQueuedOrActiveWork && !queuedWithoutActiveWork ? 0 : 100,
+ downloadPercent:
+ hasQueuedOrActiveWork && !queuedWithoutActiveWork ? 0 : 100,
+ progressStatus:
+ input.failureCount > 0
+ ? "failed"
+ : hasQueuedOrActiveWork && !queuedWithoutActiveWork
+ ? "running"
+ : "completed",
+ total: 0,
+ };
+ }
+
+ if (queuedWithoutActiveWork) {
+ return {
+ percent: 100,
+ syncPercent: 100,
+ downloadPercent: 100,
+ progressStatus: input.failureCount > 0 ? "failed" : "completed",
+ total: group.total,
+ };
+ }
+
+ const progressInput: FolderPillProgressDecisionInput = {
+ hasQueuedOrActiveWork,
+ userDownloads: group.userDownloads,
+ completedUserDownloads: group.completedUserDownloads,
+ failedUserDownloads: group.failedUserDownloads,
+ skippedUserDownloads: group.skippedUserDownloads,
+ };
+
+ if (shouldShowCompletedFolderPillProgress(progressInput)) {
+ return {
+ percent: 100,
+ syncPercent: 100,
+ downloadPercent: 100,
+ progressStatus: input.failureCount > 0 ? "failed" : "completed",
+ total: group.total,
+ };
+ }
+
+ if (shouldUseUserVisibleFolderProgress(progressInput)) {
+ const total = group.userDownloads;
+ const finished =
+ group.completedUserDownloads +
+ group.failedUserDownloads +
+ (group.skippedUserDownloads ?? 0);
+ const percent = total > 0 ? (finished / total) * 100 : 0;
+ const progressStatus =
+ finished === total
+ ? group.failedUserDownloads > 0
+ ? "failed"
+ : "completed"
+ : group.status === "failed"
+ ? "failed"
+ : "running";
+ return {
+ percent: Math.round(percent),
+ syncPercent: 0,
+ downloadPercent: Math.round(percent),
+ progressStatus,
+ total: group.total,
+ };
+ }
+
+ const terminal = terminalCounts(group);
+ const percent = group.total > 0 ? (terminal.total / group.total) * 100 : 0;
+ const syncPercent =
+ group.syncs > 0 ? (terminal.syncs / group.syncs) * 100 : 0;
+ const downloadPercent =
+ group.downloads > 0
+ ? (terminal.downloads / group.downloads) * 100
+ : 0;
+ const progressStatus =
+ terminal.total >= group.total
+ ? terminal.failed > 0
+ ? "failed"
+ : "completed"
+ : group.status;
+ return {
+ percent: Math.round(percent),
+ syncPercent: Math.round(syncPercent),
+ downloadPercent: Math.round(downloadPercent),
+ progressStatus,
+ total: group.total,
+ };
+}
+
+function terminalCounts(group: FolderSyncGroupInput): {
+ total: number;
+ syncs: number;
+ downloads: number;
+ failed: number;
+} {
+ const failedSyncs = group.failedSyncs ?? 0;
+ const failedDownloads = group.failedDownloads ?? 0;
+ const skippedSyncs = group.skippedSyncs ?? 0;
+ const skippedDownloads = group.skippedDownloads ?? 0;
+ const syncs = Math.min(
+ group.syncs,
+ group.completedSyncs + failedSyncs + skippedSyncs,
+ );
+ const downloads = Math.min(
+ group.downloads,
+ group.completedDownloads + failedDownloads + skippedDownloads,
+ );
+ return {
+ total: Math.min(group.total, syncs + downloads),
+ syncs,
+ downloads,
+ failed: failedSyncs + failedDownloads,
+ };
+}
+
+function deriveVisibleState(input: {
+ isPaused: boolean;
+ active: number;
+ queued: number;
+ failureCount: number;
+}): FolderSyncVisibleState {
+ if (input.isPaused) return "paused";
+ if (input.active > 0) return "syncing";
+ if (input.queued > 0) return "queued";
+ if (input.failureCount > 0) return "sync-issue";
+ return "synced";
+}
+
+function labelForVisibleState(
+ visibleState: FolderSyncVisibleState,
+): FolderSyncSnapshot["label"] {
+ switch (visibleState) {
+ case "syncing":
+ return "Syncing";
+ case "queued":
+ return "Queued";
+ case "paused":
+ return "Paused";
+ case "sync-issue":
+ return "Sync issue";
+ case "synced":
+ default:
+ return "Synced";
+ }
+}
+
+function actionForVisibleState(
+ visibleState: FolderSyncVisibleState,
+ workCount: number,
+ canResync: boolean,
+): FolderSyncAction {
+ if (visibleState === "syncing") return "pause";
+ if (visibleState === "paused" && workCount > 0) return "resume";
+ if (
+ canResync &&
+ (visibleState === "synced" || visibleState === "sync-issue")
+ ) {
+ return "resync";
+ }
+ return null;
+}
+
+function latestActivityForSnapshot(
+ input: FolderSyncSnapshotInput,
+ visibleState: FolderSyncVisibleState,
+): string | null {
+ if (visibleState === "syncing" && input.activeItem) {
+ const filename = filenameFromPath(input.activeItem.path);
+ return input.activeItem.kind === "download"
+ ? `Downloading ${filename}`
+ : `Syncing ${filename}`;
+ }
+ if (visibleState === "syncing" && input.folderActivity === "checking") {
+ return "Checking folder";
+ }
+ if (visibleState === "queued") {
+ switch (input.queuedReason) {
+ case "connection":
+ return "Waiting for connection";
+ case "reconnecting":
+ return "Reconnecting";
+ default:
+ return "Queued";
+ }
+ }
+ if (visibleState === "paused") return "Sync paused";
+ if (visibleState === "sync-issue") {
+ return input.failureCount === 1
+ ? "1 sync issue"
+ : `${input.failureCount} sync issues`;
+ }
+ return null;
+}
+
+function filenameFromPath(path: string): string {
+ const normalized = path.replace(/\\/g, "/");
+ const parts = normalized.split("/").filter(Boolean);
+ return parts[parts.length - 1] || normalized || "file";
+}
diff --git a/src/Canvas.ts b/src/Canvas.ts
index 004f62c4..96b374de 100644
--- a/src/Canvas.ts
+++ b/src/Canvas.ts
@@ -8,6 +8,7 @@ import type { SharedFolder } from "./SharedFolder";
import { getMimeType } from "./mimetypes";
import { IndexeddbPersistence } from "./storage/y-indexeddb";
import { Dependency } from "./promiseUtils";
+import { trackAsyncCleanup } from "./reloadUtils";
import type { Unsubscriber } from "./observable/Observable";
import type {
CanvasData,
@@ -16,11 +17,50 @@ import type {
CanvasView,
} from "./CanvasView";
import { areObjectsEqual } from "./areObjectsEqual";
+import { trackPromise } from "./trackPromise";
+import { formatCanvasData } from "./CanvasData";
export function isCanvas(file?: IFile | null): file is Canvas {
return file instanceof Canvas;
}
+function replaceYTextContent(ytext: Y.Text, nextText: string): void {
+ const currentText = ytext.toString();
+ if (currentText === nextText) return;
+
+ let prefixLength = 0;
+ const maxPrefixLength = Math.min(currentText.length, nextText.length);
+ while (
+ prefixLength < maxPrefixLength &&
+ currentText[prefixLength] === nextText[prefixLength]
+ ) {
+ prefixLength++;
+ }
+
+ let suffixLength = 0;
+ const maxSuffixLength = maxPrefixLength - prefixLength;
+ while (
+ suffixLength < maxSuffixLength &&
+ currentText[currentText.length - 1 - suffixLength] ===
+ nextText[nextText.length - 1 - suffixLength]
+ ) {
+ suffixLength++;
+ }
+
+ const deleteLength = currentText.length - prefixLength - suffixLength;
+ if (deleteLength > 0) {
+ ytext.delete(prefixLength, deleteLength);
+ }
+
+ const insertedText = nextText.slice(
+ prefixLength,
+ nextText.length - suffixLength,
+ );
+ if (insertedText.length > 0) {
+ ytext.insert(prefixLength, insertedText);
+ }
+}
+
export class Canvas extends HasProvider implements IFile, HasMimeType {
private _parent: SharedFolder;
private _persistence: IndexeddbPersistence;
@@ -31,6 +71,7 @@ export class Canvas extends HasProvider implements IFile, HasMimeType {
_tfile: TFile | null;
name: string;
userLock: boolean = false;
+ destroyed: boolean = false;
extension: string;
basename: string;
vault: Vault;
@@ -53,6 +94,7 @@ export class Canvas extends HasProvider implements IFile, HasMimeType {
? new S3RemoteCanvas(parent.relayId, parent.guid, guid)
: new S3Canvas(parent.guid, guid);
super(guid, s3rn, parent.tokenStore, loginManager);
+ this.timeProvider = parent.timeProvider;
this._parent = parent;
this.path = path;
this.name = "[CRDT] " + path.split("/").pop() || "";
@@ -76,32 +118,42 @@ export class Canvas extends HasProvider implements IFile, HasMimeType {
this.setLoggers(`[Canvas](${this.path})`);
try {
const key = `${this.sharedFolder.appId}-relay-canvas-${this.guid}`;
- this._persistence = new IndexeddbPersistence(key, this.ydoc);
+ this._persistence = new IndexeddbPersistence(
+ key,
+ this.ydoc,
+ null,
+ null,
+ this.timeProvider,
+ );
} catch (e) {
this.warn("Unable to open persistence.", this.guid);
console.error(e);
throw e;
}
- this.whenSynced().then(() => {
- this.updateStats();
- try {
- this._persistence.set("path", this.path);
- this._persistence.set("relay", this.sharedFolder.relayId || "");
- this._persistence.set("appId", this.sharedFolder.appId);
- this._persistence.set("s3rn", S3RN.encode(this.s3rn));
- } catch (e) {
- // pass
- }
-
- (async () => {
- const serverSynced = await this.getServerSynced();
- if (!serverSynced) {
- await this.onceProviderSynced();
- await this.markSynced();
+ this.whenSynced()
+ .then(() => {
+ this.updateStats();
+ try {
+ this._persistence.set("path", this.path);
+ this._persistence.set("relay", this.sharedFolder.relayId || "");
+ this._persistence.set("appId", this.sharedFolder.appId);
+ this._persistence.set("s3rn", S3RN.encode(this.s3rn));
+ } catch (e) {
+ // pass
}
- })();
- });
+
+ (async () => {
+ const serverSynced = await this.getServerSynced();
+ if (!serverSynced) {
+ const connected = await this.connect();
+ if (!connected) return;
+ await trackPromise(`canvasSync:${this.guid}`, this.onceProviderSynced());
+ await this.markSynced();
+ }
+ })().catch((e) => this.warn("canvas provider sync failed", e));
+ })
+ .catch((e) => this.warn("canvas persistence sync failed", e));
this._tfile = null;
}
@@ -137,7 +189,21 @@ export class Canvas extends HasProvider implements IFile, HasMimeType {
...{ text: ytext.toString() || ynode.text },
});
}
- return { edges: edges, nodes: nodes };
+ return { nodes: nodes, edges: edges };
+ }
+
+ static exportCanvasMapData(ydoc: Y.Doc): CanvasData {
+ const yedges = ydoc.getMap("edges");
+ const ynodes = ydoc.getMap("nodes");
+ const edges = [];
+ const nodes = [];
+ for (const [, yedge] of yedges.entries()) {
+ edges.push({ ...yedge });
+ }
+ for (const [, ynode] of ynodes.entries()) {
+ nodes.push({ ...ynode });
+ }
+ return { nodes: nodes, edges: edges };
}
async markSynced(): Promise {
@@ -182,8 +248,8 @@ export class Canvas extends HasProvider implements IFile, HasMimeType {
async awaitingUpdates(): Promise {
await this.whenSynced();
await this.getServerSynced();
- if (!this._awaitingUpdates) {
- return false;
+ if (this._awaitingUpdates !== undefined) {
+ return this._awaitingUpdates;
}
this._awaitingUpdates = !this.hasLocalDB();
return this._awaitingUpdates;
@@ -196,9 +262,9 @@ export class Canvas extends HasProvider implements IFile, HasMimeType {
// If this is a brand new shared folder, we want to wait for a connection before we start reserving new guids for local files.
this.log("awaiting updates");
this.connect();
- await this.onceConnected();
+ await trackPromise(`canvasConnected:${this.guid}`, this.onceConnected());
this.log("connected");
- await this.onceProviderSynced();
+ await trackPromise(`canvasReady:${this.guid}`, this.onceProviderSynced());
this.log("synced");
return this;
}
@@ -208,36 +274,37 @@ export class Canvas extends HasProvider implements IFile, HasMimeType {
this.readyPromise ||
new Dependency