diff --git a/.github/workflows/bake-windows-image.yml b/.github/workflows/bake-windows-image.yml index ce3b51883..4f4524154 100644 --- a/.github/workflows/bake-windows-image.yml +++ b/.github/workflows/bake-windows-image.yml @@ -61,12 +61,12 @@ jobs: test -f "$HOME/relay-harness/infra/windows-setup.ps1" - name: Auth to GCP - uses: google-github-actions/auth@v2 + uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2.1.13 with: credentials_json: ${{ secrets.GCP_SA_KEY_TACTILE }} - name: Setup gcloud - uses: google-github-actions/setup-gcloud@v2 + uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f # v2.2.1 - name: Generate build SSH key and startup script shell: bash diff --git a/.github/workflows/e2e-burnin.yml b/.github/workflows/e2e-burnin.yml index 7e1c2656a..602af6076 100644 --- a/.github/workflows/e2e-burnin.yml +++ b/.github/workflows/e2e-burnin.yml @@ -112,12 +112,12 @@ jobs: plan: ${{ fromJSON(needs.prepare.outputs.matrix) }} steps: - name: Auth to GCP - uses: google-github-actions/auth@v2 + uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2.1.13 with: credentials_json: ${{ secrets.GCP_SA_KEY_TACTILE }} - name: Setup gcloud - uses: google-github-actions/setup-gcloud@v2 + uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f # v2.2.1 - name: Lease a user pool by creating its VM id: lease @@ -481,7 +481,7 @@ jobs: - name: Upload shard metadata if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: testplans-burnin-${{ matrix.plan }}-metadata path: | @@ -491,7 +491,7 @@ jobs: - name: Generate job summary if: always() - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 env: PLAN_NAME: ${{ matrix.plan }} with: @@ -628,7 +628,7 @@ jobs: || { echo "No existing ledger; starting fresh"; : > ledger.jsonl; } - name: Append tonight's verdicts and compute flake rates - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 env: PRODUCT_SHA: ${{ needs.prepare.outputs.product_sha }} SOAK_REF: ${{ needs.prepare.outputs.ref }} diff --git a/.github/workflows/e2e-multinode.yml b/.github/workflows/e2e-multinode.yml index a46ef49f8..739b11242 100644 --- a/.github/workflows/e2e-multinode.yml +++ b/.github/workflows/e2e-multinode.yml @@ -145,12 +145,12 @@ jobs: esac - name: Auth to GCP - uses: google-github-actions/auth@v2 + uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2.1.13 with: credentials_json: ${{ secrets.GCP_SA_KEY_TACTILE }} - name: Setup gcloud - uses: google-github-actions/setup-gcloud@v2 + uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f # v2.2.1 - name: Lease a user pool by creating its VM if: github.event.inputs.slots == 'linux,linux' @@ -505,7 +505,7 @@ jobs: - name: Upload shard metadata if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: testplans-mn-metadata path: | @@ -516,7 +516,7 @@ jobs: - name: Generate job summary if: always() - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const fs = require('fs'); diff --git a/.github/workflows/e2e-parallel-lane.yml b/.github/workflows/e2e-parallel-lane.yml index 44e586c5f..2a221cfd8 100644 --- a/.github/workflows/e2e-parallel-lane.yml +++ b/.github/workflows/e2e-parallel-lane.yml @@ -131,12 +131,12 @@ jobs: echo "Uploaded s3://${CI_R2_BUCKET}/${MANIFEST_KEY}" - name: Auth to GCP - uses: google-github-actions/auth@v2 + uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2.1.13 with: credentials_json: ${{ secrets.GCP_SA_KEY_TACTILE }} - name: Setup gcloud - uses: google-github-actions/setup-gcloud@v2 + uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f # v2.2.1 - name: Lease a user pool by creating its VM id: lease @@ -487,7 +487,7 @@ jobs: - name: Upload lane metadata if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: testplans-lane-metadata path: | @@ -541,7 +541,7 @@ jobs: - name: Generate job summary if: always() - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const fs = require('fs'); diff --git a/.github/workflows/e2e-test-plans.yml b/.github/workflows/e2e-test-plans.yml index 222d7e655..056772552 100644 --- a/.github/workflows/e2e-test-plans.yml +++ b/.github/workflows/e2e-test-plans.yml @@ -43,7 +43,7 @@ jobs: shard: - name: scripted-all slug: all - tests: tp000,tp001,tp002,tp003,tp004,tp006,tp007,tp008,tp010,tp011,tp012,tp013,tp014,tp015,tp016,tp017,tp018,tp019,tp020,tp021,tp022,tp023,tp024,tp025,tp028,tp029,tp030,tp031,tp032,tp033,tp034 + tests: default env: VM_NAME: ${{ format('{0}-tp-{1}', secrets.GCP_VM_NAME, matrix.shard.slug) }} SHARD_NAME: ${{ matrix.shard.name }} @@ -54,12 +54,12 @@ jobs: steps: - name: Auth to GCP - uses: google-github-actions/auth@v2 + uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2.1.13 with: credentials_json: ${{ secrets.GCP_SA_KEY }} - name: Setup gcloud - uses: google-github-actions/setup-gcloud@v2 + uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f # v2.2.1 - name: Select tests for shard id: select-tests @@ -67,34 +67,12 @@ jobs: 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" - } - + # The suite runner resolves ids against the harness manifest and + # fails loudly on unknown ids, so targeted dispatches pass through; + # a roster intersection here would silently skip unknown ids. SELECTED_TESTS="$SHARD_TESTS" if [ -n "$TARGETED_TESTS" ]; then - SELECTED_TESTS="$(select_tests "$SHARD_TESTS" "$TARGETED_TESTS")" + SELECTED_TESTS="$TARGETED_TESTS" fi if [ -n "$SELECTED_TESTS" ]; then @@ -476,7 +454,7 @@ jobs: - name: Upload shard metadata if: always() && steps.select-tests.outputs.should_run == 'true' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: testplans-${{ matrix.shard.slug }}-metadata path: | @@ -503,7 +481,7 @@ jobs: - name: Generate job summary if: always() - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const fs = require('fs'); diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 5c8981656..04cf8bf94 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -10,7 +10,7 @@ jobs: outputs: should_release: ${{ steps.check.outputs.should_release }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Check if tag does not match manifest version id: check run: | @@ -31,9 +31,9 @@ jobs: id-token: write attestations: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20.x' - run: npm ci @@ -46,13 +46,13 @@ jobs: mv "$tmp" manifest.json jq -e --arg version "$TAG" '.version == $version' manifest.json - name: Generate artifact attestations - uses: actions/attest@v4 + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 with: subject-path: | main.js styles.css manifest.json - - uses: ncipollo/release-action@v1 + - uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0 with: artifacts: "main.js,styles.css,manifest.json" immutableCreate: true diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 5eca26a3f..381058565 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -10,7 +10,7 @@ jobs: outputs: should_release: ${{ steps.check.outputs.should_release }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Check if tag matches manifest version id: check run: | @@ -31,21 +31,21 @@ jobs: id-token: write attestations: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20.x' - run: npm ci - run: npm run release - name: Generate artifact attestations - uses: actions/attest@v4 + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 with: subject-path: | main.js styles.css manifest.json - - uses: ncipollo/release-action@v1 + - uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0 with: artifacts: "main.js,styles.css,manifest.json" draft: true diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index 6e7aae51f..87032f1d1 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -33,12 +33,12 @@ jobs: steps: - name: Auth to GCP - uses: google-github-actions/auth@v2 + uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2.1.13 with: credentials_json: ${{ secrets.GCP_SA_KEY }} - name: Setup gcloud - uses: google-github-actions/setup-gcloud@v2 + uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f # v2.2.1 - name: Clone relay-harness run: | @@ -513,7 +513,7 @@ jobs: - name: Generate job summary if: always() - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const fs = require('fs'); diff --git a/__tests__/AwarenessViewPlugin.test.ts b/__tests__/AwarenessViewPlugin.test.ts new file mode 100644 index 000000000..71ca49d6e Binary files /dev/null and b/__tests__/AwarenessViewPlugin.test.ts differ diff --git a/__tests__/SharedFolder.folder-hsm.test.ts b/__tests__/SharedFolder.folder-hsm.test.ts index 41aef6056..9378eeda7 100644 Binary files a/__tests__/SharedFolder.folder-hsm.test.ts and b/__tests__/SharedFolder.folder-hsm.test.ts differ diff --git a/__tests__/folder-hsm/FolderHSM.test.ts b/__tests__/folder-hsm/FolderHSM.test.ts index d1dead85a..8360d42bf 100644 Binary files a/__tests__/folder-hsm/FolderHSM.test.ts and b/__tests__/folder-hsm/FolderHSM.test.ts differ diff --git a/__tests__/folder-hsm/delete-collector.test.ts b/__tests__/folder-hsm/delete-collector.test.ts index a5d991c9b..593acb912 100644 Binary files a/__tests__/folder-hsm/delete-collector.test.ts and b/__tests__/folder-hsm/delete-collector.test.ts differ diff --git a/__tests__/folder-hsm/engine-invariants.test.ts b/__tests__/folder-hsm/engine-invariants.test.ts new file mode 100644 index 000000000..b2438434a Binary files /dev/null and b/__tests__/folder-hsm/engine-invariants.test.ts differ diff --git a/__tests__/folder-hsm/engine-properties.test.ts b/__tests__/folder-hsm/engine-properties.test.ts new file mode 100644 index 000000000..6664a073e Binary files /dev/null and b/__tests__/folder-hsm/engine-properties.test.ts differ diff --git a/__tests__/folder-hsm/engine-scenarios.test.ts b/__tests__/folder-hsm/engine-scenarios.test.ts new file mode 100644 index 000000000..bedf846b5 Binary files /dev/null and b/__tests__/folder-hsm/engine-scenarios.test.ts differ diff --git a/__tests__/folder-hsm/entry-machine.test.ts b/__tests__/folder-hsm/entry-machine.test.ts new file mode 100644 index 000000000..f55e28433 Binary files /dev/null and b/__tests__/folder-hsm/entry-machine.test.ts differ diff --git a/__tests__/folder-hsm/folder-state-store.test.ts b/__tests__/folder-hsm/folder-state-store.test.ts new file mode 100644 index 000000000..df1121da0 Binary files /dev/null and b/__tests__/folder-hsm/folder-state-store.test.ts differ diff --git a/package-lock.json b/package-lock.json index 1400fac8d..dfc38d4f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2017,9 +2017,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -2378,9 +2378,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -7414,9 +7414,9 @@ }, "dependencies": { "brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "requires": { "balanced-match": "^1.0.0" @@ -7671,9 +7671,9 @@ "dev": true }, "brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "requires": { "balanced-match": "^1.0.0", diff --git a/src/AwarenessViewPlugin.ts b/src/AwarenessViewPlugin.ts index b9fe87e54..a956281f1 100644 --- a/src/AwarenessViewPlugin.ts +++ b/src/AwarenessViewPlugin.ts @@ -13,8 +13,8 @@ export interface AwarenessHost { 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. + * inserted relative to, along with the insertion position. Called whenever + * the host view refreshes. Returning `null` skips mounting. */ resolveAnchor(containerEl: HTMLElement): { anchor: HTMLElement; @@ -33,11 +33,48 @@ export interface AwarenessHost { getEditor?: () => unknown; } +export interface AwarenessAnchor { + anchor: HTMLElement; + position: InsertPosition; +} + +export function resolveMarkdownAwarenessAnchor( + containerEl: HTMLElement, + mode: "preview" | "source", +): AwarenessAnchor | null { + const modeRoot = containerEl.querySelector( + mode === "preview" ? ".markdown-reading-view" : ".markdown-source-view", + ) as HTMLElement | null; + const inlineTitle = modeRoot?.querySelector( + ".inline-title", + ) as HTMLElement | null; + if (inlineTitle) { + return { anchor: inlineTitle, position: "afterend" }; + } + + const modeContent = modeRoot?.querySelector( + mode === "preview" ? ".markdown-preview-sizer" : ".cm-sizer", + ) as HTMLElement | null; + if (modeContent) { + return { anchor: modeContent, position: "afterbegin" }; + } + + const viewContent = containerEl.querySelector( + ".view-content", + ) as HTMLElement | null; + return viewContent + ? { anchor: viewContent, position: "afterbegin" } + : null; +} + export class AwarenessViewPlugin extends HasLogging { private host: AwarenessHost; private destroyed = false; + private ready = false; private awarenessComponent?: UserAwareness; private awarenessElement?: HTMLElement; + private positioningParent?: HTMLElement; + private addedPositioningClass = false; private relayUsersStore: any; constructor(host: AwarenessHost, relayUsersStore: any) { @@ -57,8 +94,8 @@ export class AwarenessViewPlugin extends HasLogging { this.log("Installing awareness component"); - // Wrap the title immediately to avoid focus loss later - this.wrapTitle(); + // Create the empty container immediately to avoid focus loss later. + this.refresh(); // Wait for the document to be ready await trackPromise( @@ -68,16 +105,15 @@ export class AwarenessViewPlugin extends HasLogging { if (this.destroyed) return; - // Mount the Svelte component (needs awareness to be available) - this.mountAwarenessComponent(); + this.ready = true; + this.refresh(); } - private wrapTitle() { - const containerEl = this.host.view?.containerEl; - if (!containerEl || this.destroyed) return; + public refresh() { + if (this.destroyed) return; - // Already created - if (this.awarenessElement) return; + const containerEl = this.host.view?.containerEl; + if (!containerEl) return; const resolved = this.host.resolveAnchor(containerEl); if (!resolved) { @@ -85,25 +121,87 @@ export class AwarenessViewPlugin extends HasLogging { return; } - // Create container for the awareness component - this.awarenessElement = containerEl.ownerDocument.createElement("div"); - this.awarenessElement.className = "user-awareness-container"; - if (this.host.variantClass) { - this.awarenessElement.classList.add(this.host.variantClass); + if (!this.awarenessElement) { + 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); + if (!this.isAtResolvedAnchor(resolved)) { + const inserted = resolved.anchor.insertAdjacentElement( + resolved.position, + this.awarenessElement, + ); + if (!inserted) { + this.warn("Could not insert awareness component at resolved anchor"); + return; + } + } this.host.configureContainer?.(this.awarenessElement); // The CSS pins the container top-right of its positioning parent. Make // sure that parent can host absolute children. - const positioningParent = + this.setPositioningParent( resolved.position === "afterbegin" || resolved.position === "beforeend" ? resolved.anchor - : resolved.anchor.parentElement; - if (positioningParent) { - positioningParent.addClass("user-awareness-positioning-parent"); + : resolved.anchor.parentElement, + ); + + if (this.ready && !this.awarenessComponent) { + this.mountAwarenessComponent(); + } + } + + private isAtResolvedAnchor(resolved: AwarenessAnchor): boolean { + if (!this.awarenessElement) return false; + + switch (resolved.position) { + case "afterbegin": + return resolved.anchor.firstElementChild === this.awarenessElement; + case "beforeend": + return resolved.anchor.lastElementChild === this.awarenessElement; + case "beforebegin": + return ( + resolved.anchor.previousElementSibling === this.awarenessElement + ); + case "afterend": + return resolved.anchor.nextElementSibling === this.awarenessElement; + } + } + + private setPositioningParent(parent: HTMLElement | null) { + if (parent === this.positioningParent) { + if ( + parent && + !parent.classList.contains("user-awareness-positioning-parent") + ) { + parent.classList.add("user-awareness-positioning-parent"); + this.addedPositioningClass = true; + } + return; + } + + if (this.positioningParent && this.addedPositioningClass) { + this.positioningParent.classList.remove( + "user-awareness-positioning-parent", + ); + } + + this.positioningParent = parent ?? undefined; + this.addedPositioningClass = false; + if ( + this.positioningParent && + !this.positioningParent.classList.contains( + "user-awareness-positioning-parent", + ) + ) { + this.positioningParent.classList.add( + "user-awareness-positioning-parent", + ); + this.addedPositioningClass = true; } } @@ -153,6 +251,7 @@ export class AwarenessViewPlugin extends HasLogging { this.awarenessElement = undefined; } + this.setPositioningParent(null); this.host = null as any; } } diff --git a/src/CanvasPlugin.ts b/src/CanvasPlugin.ts index a4605e225..c49fa2a4b 100644 --- a/src/CanvasPlugin.ts +++ b/src/CanvasPlugin.ts @@ -232,7 +232,14 @@ export class CanvasPlugin extends HasLogging { this.trackedEmbedViews.add(embedView); this.unsubscribes.push( (() => { - const document = this.relayCanvas.sharedFolder.proxy.getDoc(embedView.file.path); + let document: Document; + try { + document = this.relayCanvas.sharedFolder.proxy.getDoc(embedView.file.path); + } catch { + // No shared handle (membership refused or undecided): the + // embed renders without live sync. + return () => {}; + } const viewRef = this.createEmbedEditorViewRef(embedView); const syncEmbedViewToDocument = this.syncEmbedViewToDocument.bind(this); const syncDocumentToEmbedView = this.syncDocumentToEmbedView.bind(this); diff --git a/src/LiveViews.ts b/src/LiveViews.ts index 37be4b031..ccd10c77a 100644 --- a/src/LiveViews.ts +++ b/src/LiveViews.ts @@ -38,7 +38,10 @@ import { isCanvas, type Canvas } from "./Canvas"; import { CanvasPlugin } from "./CanvasPlugin"; import { LiveNode } from "./y-codemirror.next/LiveNodePlugin"; import { flags } from "./flagManager"; -import { AwarenessViewPlugin } from "./AwarenessViewPlugin"; +import { + AwarenessViewPlugin, + resolveMarkdownAwarenessAnchor, +} from "./AwarenessViewPlugin"; import { TextFileViewPlugin } from "./TextViewPlugin"; import { ViewHookPlugin } from "./plugins/ViewHookPlugin"; import { DiskBuffer } from "./DiskBuffer"; @@ -910,24 +913,25 @@ export class LiveView this.setConnectionDot(); - // Initialize awareness plugin if not already created. - if (isLiveMd(this) && !this._awarenessPlugin) { - this._awarenessPlugin = new AwarenessViewPlugin( - { - view: this.view, - doc: this.document, - resolveAnchor: (containerEl) => { - const inlineTitle = containerEl.querySelector( - ".inline-title", - ) as HTMLElement | null; - return inlineTitle - ? { anchor: inlineTitle, position: "afterend" } - : null; + if (isLiveMd(this)) { + if (!this._awarenessPlugin) { + this._awarenessPlugin = new AwarenessViewPlugin( + { + view: this.view, + doc: this.document, + resolveAnchor: (containerEl) => + resolveMarkdownAwarenessAnchor( + containerEl, + this.view.getMode(), + ), + variantClass: "user-awareness-container--markdown", + getEditor: () => this.view.editor, }, - getEditor: () => this.view.editor, - }, - this._parent.sharedFolders.manager.users, - ); + this._parent.sharedFolders.manager.users, + ); + } else { + this._awarenessPlugin.refresh(); + } } return new Promise((resolve, reject) => { @@ -1293,7 +1297,13 @@ export class LiveViewManager { const folder = this.sharedFolders.lookup(filePath); if (!folder?.mergeManager || !folder.ready) continue; - const embeddedDoc = folder.proxy.getDoc(filePath); + let embeddedDoc: Document; + try { + embeddedDoc = folder.proxy.getDoc(filePath); + } catch { + // No shared handle (membership refused or undecided). + continue; + } if (!embeddedDoc) continue; if (!folderToGuids.has(folder)) { @@ -1413,13 +1423,22 @@ export class LiveViewManager { }); views.push(view); } else if (folder.ready) { - const doc = folder.proxy.getDoc(viewFilePath); - const view = new LiveView( - this, - textFileView, - doc, - ); - views.push(view); + try { + const doc = folder.proxy.getDoc(viewFilePath); + const view = new LiveView( + this, + textFileView, + doc, + ); + views.push(view); + } catch (e) { + // No shared handle (membership refused or undecided): + // the file opens as a plain local editor. + this.log( + `No shared document for ${viewFilePath}; skipping view.`, + e, + ); + } } else { this.log(`Folder not ready, skipping views. folder=${folder.path}`); } diff --git a/src/SharedFolder.ts b/src/SharedFolder.ts index 62cdd16a9..664796001 100644 --- a/src/SharedFolder.ts +++ b/src/SharedFolder.ts @@ -43,19 +43,26 @@ import { FolderHSM, FolderDocBridge, DeleteCollector, + FolderHSMStore, BRIDGE_IN_ORIGIN, FOLDER_LOCAL_DELETE_ORIGIN, deriveRecoveryDelta, isEmptyRecoveryDelta, pathWasDeleted, + docsHavePendingSyncState, + observeSyncDrain, type FolderEffect, + type FolderFork, type LocalFileKind, + type FolderMapName, type FolderSyncSnapshot, type DeletionGateSnapshot, type DeleteCollectorOptions, type GateResolution, type HeldDelete, - type SerializedCollectorState, + type PersistedFolderState, + type RemoteIndexCache, + type RetainedDoc, } from "./folder-hsm"; import { SyncType, @@ -196,6 +203,18 @@ export const DOWNLOAD_SWEEP_INTERVAL_MS = 10_000; // wait — only novel interactive creates settle. export const NEW_FILE_REGISTRATION_DEBOUNCE_MS = 500; +/** Unique logical paths of a held-deletion batch (keys are vpaths). */ +function heldPaths(deletes: HeldDelete[]): string[] { + return [...new Set(deletes.map((deleted) => deleted.key))]; +} + +/** + * Transaction origin of remote-index cache replay onto the provider doc. + * Replay is a boot-time restoration of server-owned state, not provider + * traffic: the bridge must not treat it as inbound intent. + */ +export const REMOTE_INDEX_ORIGIN = "relay:folder-remote-index"; + class Files extends ObservableSet { // Startup performance optimization notifyListeners = debounce(() => super.notifyListeners(), 100); @@ -273,14 +292,32 @@ export class SharedFolder extends HasProvider { * ride it. Null flag-off: the provider doc is the only folder doc. */ private _localDoc: Y.Doc | null = null; - /** Persistence for the provider-facing doc under the split. */ - private _remotePersistence: IndexeddbPersistence | null = null; + /** + * The provider-facing doc's persisted picture: the remote-index cache + * carried by the folder's state row. Refreshed at natural sync + * moments, never streamed per-update. + */ + private _lastRemoteIndex: RemoteIndexCache | null = null; + private _remoteIndexDirty = false; /** Sole conduit between localDoc and the provider doc (flag-on). */ folderBridge: FolderDocBridge | null = null; /** Outbound deletion policy at the bridge (flag-on). */ deleteCollector: DeleteCollector | null = null; - /** Deleted docs awaiting expired teardown (split only). */ - private _deferredTeardown: Array<{ guid: string; deletedAt: number }> = []; + /** + * Deleted docs whose local data is retained for undo until expiry + * (split only). Persisted as the `retained` half of the folder's + * fork-class state. + */ + private _deferredTeardown: RetainedDoc[] = []; + /** + * Fork-class persistence: one row per folder holding the withheld + * deletion fork and the retained-doc ledger. Writes flow only through + * the machine's PERSIST_STATE effect; the row retires with the + * folder. + */ + private _folderStateStore: FolderHSMStore | null = null; + /** The persisted folder row has been loaded (or migrated); writes may flow. */ + private _folderStateLoaded = false; /** Host hook: the collector classified a burst as root detach. */ onRootDetach: (() => void) | null = null; proxy: SharedFolder; @@ -309,10 +346,21 @@ export class SharedFolder extends HasProvider { folderHSM: FolderHSM | null = null; /** * Synchronous local-record lookups for the FolderHSM guards: vpath → - * guid, assembled from persisted HSM state metadata and guid-bearing - * hash-store entries before hydration completes. + * identity plus the content evidence (mtime/hash captured when the + * record was written), assembled from persisted HSM state metadata + * and guid-bearing hash-store entries before hydration completes. + * The evidence is what ties the recorded identity to the file now on + * disk; a record without it never authorizes destruction. + */ + private _localRecordCache: Map< + string, + { guid: string; hash?: string; mtime?: number } + > = new Map(); + /** + * Upload effect executions in flight, vpath-keyed, so concurrent + * dispatches of the same decision collapse to one enrollment. */ - private _localRecordCache: Map = new Map(); + private _uploadDispatches: Set = new Set(); /** * True once the bootstrap discovery pass over the local tree has run — * the boundary the origin discriminator uses to tell interactive vault @@ -407,6 +455,15 @@ export class SharedFolder extends HasProvider { await this.syncFileTree(); }); + // The newly-enabled-types diff in syncFileTree compares against this + // baseline. It must be populated before the first syncFileTree can + // run: an empty baseline reads as "every type was just enabled" and + // runs addLocalDocs while the folder is still disconnected, before + // readiness gates that discovery. + this.enabledSyncTypes = new Set( + this.syncStore.typeRegistry.getEnabledFileSyncTypes(), + ); + this.folderHSM = this.maybeConstructFolderHSM(); if (this.folderHSM) { // Remote map deltas (provider-applied transactions) drive @@ -424,6 +481,12 @@ export class SharedFolder extends HasProvider { return; this.folderHSM?.send({ type: "MAP_DELTA", ...delta }); }; + // A classification pass deferred on pending sync state re-runs + // when that state drains; the observer's logic lives with the + // engine — this is only its installation on the folder docs. + this.unsubscribes.push( + observeSyncDrain(this.folderHSM, [this.ydoc, this._localDoc]), + ); } this.unsubscribes.push( @@ -492,15 +555,9 @@ export class SharedFolder extends HasProvider { migrateFrom, this.timeProvider, ); - if (this._localDoc) { - this._remotePersistence = new IndexeddbPersistence( - `${folderDbName}-remote`, - this.ydoc, - null, - null, - this.timeProvider, - ); - } + // The provider doc has no database of its own: it boots from the + // remote-index cache in the folder's state row (loaded below) + // and refills from the server on handshake. } catch (e) { this.warn("Unable to open persistence.", this.guid); console.error(e); @@ -522,12 +579,12 @@ export class SharedFolder extends HasProvider { // converges through its own persistence and reconcile(). skipOutboundOrigin: (origin) => origin != null && origin === this._persistence, - // The remote doc's persisted snapshot can be stale — the - // localDoc's database also advances while the split is - // inactive — so its replay must not overwrite the localDoc. + // The remote-index cache can be stale — the localDoc's + // database also advances while the split is inactive — so + // its boot-time replay must not overwrite the localDoc. // reconcile() at provider sync converges the docs. skipInboundOrigin: (origin) => - origin != null && origin === this._remotePersistence, + origin != null && origin === REMOTE_INDEX_ORIGIN, // Publication staged the membership to an empty relay; the // per-document rooms there are empty shells until content // re-uploads. Stage every registered doc's content. @@ -539,26 +596,41 @@ export class SharedFolder extends HasProvider { { membershipSize: () => this.syncStore.committedEntryCount(), onDetach: (deletes) => this.handleCollectorDetach(deletes), - onReplicated: () => this.notifyListeners(), + onReplicated: (deletes) => { + this.folderHSM?.send({ + type: "DELETE_REPLICATED", + paths: heldPaths(deletes), + }); + this.notifyListeners(); + }, onGated: (deletes) => { this.log( `[DeleteCollector] gated ${new Set(deletes.map((deleted) => deleted.key)).size} deletions pending send/restore`, ); + this.folderHSM?.send({ + type: "DELETE_HELD", + paths: heldPaths(deletes), + }); this.notifyListeners(); }, onRestored: (deletes) => this.handleCollectorRestore(deletes), - persist: (state) => { - this.persistCollectorState(state); - // A gated burst that grows or shrinks (keys absorbed, or - // keys re-asserted and dropped) reaches the deletion - // surface through the same listeners the pill uses. + onDroppedStale: (deletes) => + this.handleCollectorDroppedStale(deletes), + currentRemoteIdentity: (mapName, key) => + this.currentRemoteIdentity(mapName, key), + persist: () => { + // Fork-class state persists only through the machine's + // PERSIST_STATE effect; the collector's change also + // reaches the deletion surface through the same + // listeners the pill uses. + this.folderHSM?.requestPersist(); this.notifyListeners(); }, }, options.deleteCollector, ); - // Retention: captured deletion bursts and deferred doc teardown - // expire together. + // Retention: captured deletion bursts and the retained-doc + // ledger expire together. void this.whenSynced() .then(() => { if (this.destroyed) return; @@ -568,38 +640,12 @@ export class SharedFolder extends HasProvider { this.sweepDeferredTeardown(); }) .catch(() => {}); - void this._persistence - .get("deferredDocTeardown") - .then((raw: unknown) => { - if (this.destroyed || typeof raw !== "string" || !raw) return; - try { - const entries = JSON.parse(raw); - if (Array.isArray(entries)) { - this._deferredTeardown.push(...entries); - } - } catch (e) { - this.warn("failed to parse deferred-teardown ledger", e); - } - }) - .catch(() => {}); - void this._persistence - .get("deleteCollector") - .then((raw: unknown) => { - if (this.destroyed || typeof raw !== "string" || !raw) return; - try { - const state = JSON.parse(raw) as SerializedCollectorState; - this.deleteCollector?.loadPersisted(state); - if (this.deleteCollector?.currentPhase === "gated") { - this.log( - `[DeleteCollector] rehydrated gated burst of ${state.deletes.length} deletions`, - ); - this.notifyListeners(); - } - } catch (e) { - this.warn("failed to parse persisted delete-collector state", e); - } - }) - .catch(() => {}); + this._folderStateStore = new FolderHSMStore(this.appId); + const loadPromise = this.loadPersistedFolderState().catch((e) => { + this.warn("failed to load persisted folder state", e); + this._folderStateLoaded = true; + }); + trackPromise(`folderStateLoad:${this.guid}`, loadPromise); } // If folder is authoritative (local-only, not awaiting server updates), @@ -742,9 +788,6 @@ export class SharedFolder extends HasProvider { // Remote folder metadata can also land before SyncStore observers are // installed, so replay both local doc discovery and file-tree sync after // start() to avoid missing the first batch of remote entries. - this.enabledSyncTypes = new Set( - this.syncStore.typeRegistry.getEnabledFileSyncTypes(), - ); if (this.folderHSM) { // Assemble the local-record evidence before declaring the // folder persistence loaded, so the provenance ladder never @@ -769,9 +812,16 @@ export class SharedFolder extends HasProvider { // that completed the sync handshake before (hasServerSync) // or that is authoritative is hydrated as soon as // persistence loads; fresh folders wait for the first - // provider handshake (handleProviderSynced). + // provider handshake (handleProviderSynced). The latch is + // a BLIND claim: it hydrates the machine, but decisions + // made under it are provisional — nothing destructive or + // publishing dispatches until the session's first real + // handshake confirms the picture. if (this.ready) { - this.folderHSM.send({ type: "PROVIDER_SYNCED" }); + this.folderHSM.send({ + type: "PROVIDER_SYNCED", + tier: "blind", + }); } } else { this.addLocalDocs(); @@ -1784,10 +1834,14 @@ export class SharedFolder extends HasProvider { // converged map. this.folderBridge?.reconcile(); // The FolderHSM hydration gate rides the same handshake as the - // readiness latch; the machine itself dedups repeat syncs (the - // ladder reruns only after a disconnect). + // readiness latch; the machine itself dedups repeat syncs + // (classification re-runs only after a disconnect or a blind + // boot's first confirmed exchange). A completed handshake is a + // natural sync moment for the remote-index cache. + this._remoteIndexDirty = true; this.folderHSM?.send({ type: "CONNECTED" }); this.folderHSM?.send({ type: "PROVIDER_SYNCED" }); + this.folderHSM?.requestPersist(); // The folder provider completing a sync is the connectivity-level signal // that the transport has returned. It fires on the provider's own // reconnect-backoff self-heal, which never routes through connect(), so a @@ -1817,17 +1871,126 @@ export class SharedFolder extends HasProvider { return this._localDoc ?? this.ydoc; } - private persistCollectorState(state: SerializedCollectorState | null): void { - try { - if (state === null) { - void this._persistence.del("deleteCollector"); - } else { - void this._persistence.set("deleteCollector", JSON.stringify(state)); + /** + * Load the folder's persisted state row (the withheld deletion fork, + * the retained-doc ledger, and the remote-index cache). The engine + * manages only its own storage and builds its state fresh when no + * row exists. + */ + private async loadPersistedFolderState(): Promise { + const store = this._folderStateStore; + if (!store) { + this._folderStateLoaded = true; + return; + } + const row = await store.loadState(this.guid); + if (this.destroyed) return; + if (row?.remoteIndex?.snapshot) { + // The provider doc boots from the cached picture of the + // server-owned folder doc; a stale cache only costs a fuller + // resync at the next handshake. + try { + Y.applyUpdate( + this.ydoc, + row.remoteIndex.snapshot, + REMOTE_INDEX_ORIGIN, + ); + this._lastRemoteIndex = row.remoteIndex; + } catch (e) { + this.warn("failed to apply cached remote index", e); + } + } + if (row?.retained && row.retained.length > 0) { + this._deferredTeardown.push(...row.retained); + } + if (row?.fork && row.fork.deletes.length > 0) { + this.deleteCollector?.loadPersisted({ + phase: "gated", + deletes: row.fork.deletes.map((deleted) => ({ + mapName: deleted.mapName as FolderMapName, + key: deleted.key, + ...(deleted.guid !== undefined ? { guid: deleted.guid } : {}), + })), + gatedAt: row.fork.created, + }); + if (this.deleteCollector?.currentPhase === "gated") { + this.log( + `[DeleteCollector] rehydrated withheld burst of ${row.fork.deletes.length} deletions`, + ); + this.folderHSM?.send({ + type: "DELETE_HELD", + paths: heldPaths(this.deleteCollector.heldDeletes()), + }); + this.notifyListeners(); } - } catch (e) { - // Gate persistence is best-effort; the localDoc/remoteDoc - // divergence still carries the held deletions. } + this._folderStateLoaded = true; + } + + /** + * The PERSIST_STATE executor: write the approved fork-class subset — + * the collector's withheld burst and the retained-doc ledger — to the + * folder's row. The machine snapshot itself is observability only and + * is not stored. Writes wait until the initial load (and migration) + * has finished so a fresh boot cannot blank an existing row. + */ + private executeFolderStatePersist(): void { + const store = this._folderStateStore; + if (!store || !this._folderStateLoaded || this.destroyed) return; + const serialized = this.deleteCollector?.serialize() ?? null; + const fork: FolderFork | null = serialized + ? { + deletes: serialized.deletes.map((deleted) => ({ + mapName: deleted.mapName, + key: deleted.key, + ...(deleted.guid !== undefined + ? { guid: deleted.guid } + : {}), + })), + origin: "bulk-delete", + created: serialized.gatedAt, + } + : null; + // The remote-index cache refreshes only at natural sync moments + // (marked dirty by the handshake and the post-classification + // settle), never per-update. + if (this._remoteIndexDirty) { + this._remoteIndexDirty = false; + try { + this._lastRemoteIndex = { + snapshot: Y.encodeStateAsUpdate(this.ydoc), + stateVector: Y.encodeStateVector(this.ydoc), + updated: this.timeProvider.now(), + }; + } catch (e) { + this.warn("failed to capture remote index", e); + } + } + const row: PersistedFolderState = { + guid: this.guid, + version: 1, + fork, + retained: this._deferredTeardown.map((doc) => ({ ...doc })), + remoteIndex: this._lastRemoteIndex, + }; + const p = store.saveState(this.guid, row).catch((e) => { + this.warn("failed to persist folder state", e); + }); + trackAsyncCleanup(p); + } + + /** + * Retire the folder's persisted row — unsharing the folder removes + * it; the row's lifecycle is bound to the folder's. + */ + retireFolderState(): void { + const store = this._folderStateStore; + if (!store) return; + const p = store + .deleteState(this.guid) + .catch(() => {}) + .then(() => store.flush()); + trackAsyncCleanup(p); } /** @@ -1850,9 +2013,52 @@ export class SharedFolder extends HasProvider { */ private handleCollectorRestore(deletes: HeldDelete[]): void { this.folderBridge?.refreshFromRemote(deletes); + this.folderHSM?.send({ + type: "DELETE_RESTORED", + paths: heldPaths(deletes), + }); + this.notifyListeners(); + } + + /** + * Deletions dropped at send() resolution because their target changed + * since the intent was recorded: the intent expired. The keys stay on + * the remote replica; re-assert them locally so the replicas converge + * and the machine re-materializes from present truth. + */ + private handleCollectorDroppedStale(deletes: HeldDelete[]): void { + this.log( + `[DeleteCollector] dropped ${deletes.length} stale deletions whose targets changed`, + ); + this.folderBridge?.refreshFromRemote(deletes); + this.folderHSM?.send({ + type: "DELETE_RESTORED", + paths: heldPaths(deletes), + }); this.notifyListeners(); } + /** + * The identity the committed remote replica currently holds for a + * map key, for the collector's expired-intent check at resolution. + */ + private currentRemoteIdentity( + mapName: FolderMapName, + key: string, + ): string | undefined { + const value = this.ydoc.getMap(mapName).get(key); + if (typeof value === "string") return value; + if ( + value !== null && + typeof value === "object" && + "id" in value && + typeof (value as { id: unknown }).id === "string" + ) { + return (value as { id: string }).id; + } + return undefined; + } + /** Deletions currently held by the outbound gate. */ heldDeletions(): HeldDelete[] { return this.deleteCollector?.heldDeletes() ?? []; @@ -2406,19 +2612,91 @@ export class SharedFolder extends HasProvider { ? { path: vpath, guid: meta.id, type: meta.type } : undefined; }, - getPendingUploadGuid: (vpath: string) => - this.pendingUpload.get(vpath) ?? undefined, - getLocalRecordGuid: (vpath: string) => - this._localRecordCache.get(vpath), + // Upload holds ride the existing pending-upload persistence in + // its current format: the identity minted at placeHold time, + // reused by retries across restarts. + holds: { + getHold: (vpath: string) => + this.pendingUpload.get(vpath) ?? undefined, + moveHold: (from: string, to: string) => { + const guid = this.pendingUpload.get(from); + if (guid === undefined || guid === null) return; + this.pendingUpload.delete(from); + this.pendingUpload.set(to, guid); + }, + }, + // Local records: the in-memory evidence cache assembled from + // persisted merge-state metadata and guid-bearing hash rows. + // Retirement removes only the cache row; the underlying stores + // keep their own lifecycles. + records: { + getRecordGuid: (vpath: string) => + this._localRecordCache.get(vpath)?.guid, + recordMatchesDisk: (vpath: string) => + this.localRecordMatchesFile(vpath), + retireRecord: (vpath: string) => { + this._localRecordCache.delete(vpath); + }, + moveRecord: (from: string, to: string) => { + const record = this._localRecordCache.get(from); + if (!record) return; + this._localRecordCache.delete(from); + this._localRecordCache.set(to, record); + }, + }, pathTombstoned: (vpath: string) => pathWasDeleted(this.folderDoc.getMap("filemeta_v0"), vpath), + // Live doc state only — a persisted readiness marker can declare + // a folder synced while the session's exchange is incomplete. + hasPendingSyncState: () => + docsHavePendingSyncState(this.ydoc, this._localDoc), + // Only documents carry content-merge machinery; everything else + // conflicts rather than silently merging. + mergeableKind: (fileType?: string) => + fileType === SyncType.Document, onEffect: (effect) => this.handleFolderHSMEffect(effect), onTransition: (from, to, eventType) => { this.debug(`[FolderHSM] ${from} -> ${to} (${eventType})`); + // A classification pass settling at confirmed confidence is + // a natural sync moment: refresh the remote-index cache on + // the next persist. + if (from === "reconciling" && to === "tracking") { + this._remoteIndexDirty = true; + } }, }); } + /** + * Content-evidence agreement between the local record for `vpath` and + * the file now on disk. The record proves that SOME file at this path + * synced under its guid; only its stored mtime agreeing with the + * file's current stat ties that identity to the current content. A + * record without evidence, or a path without a file, never agrees. + */ + private localRecordMatchesFile(vpath: string): boolean { + const record = this._localRecordCache.get(vpath); + if (!record || record.mtime === undefined) return false; + const tfile = this.vault.getAbstractFileByPath(this.getPath(vpath)); + if (!(tfile instanceof TFile)) return false; + return tfile.stat.mtime === record.mtime; + } + + /** + * A record for a path whose on-disk state IS the synced state (a + * download that just landed, an upload that just committed): capture + * the file's current mtime as the record's content evidence. + */ + private recordSyncedNow( + vpath: string, + guid: string, + ): { guid: string; mtime?: number } { + const tfile = this.vault.getAbstractFileByPath(this.getPath(vpath)); + return tfile instanceof TFile + ? { guid, mtime: tfile.stat.mtime } + : { guid }; + } + /** Live membership snapshot for status surfaces; null when the engine is off. */ public getFolderSyncSnapshot(): FolderSyncSnapshot | null { return this.folderHSM?.getSnapshot() ?? null; @@ -2639,7 +2917,11 @@ export class SharedFolder extends HasProvider { // classify a fresh local file as a stale materialization. if (stateMeta?.folder !== this.guid) continue; if (stateMeta?.path && stateMeta?.guid) { - this._localRecordCache.set(stateMeta.path, stateMeta.guid); + this._localRecordCache.set(stateMeta.path, { + guid: stateMeta.guid, + hash: stateMeta.disk?.hash ?? undefined, + mtime: stateMeta.disk?.mtime ?? undefined, + }); } } } catch (e) { @@ -2651,10 +2933,11 @@ export class SharedFolder extends HasProvider { if (!entry.guid) continue; // Hash store keys are vault-absolute paths. if (!this.checkPath(entry.path)) continue; - this._localRecordCache.set( - this.getVirtualPath(entry.path), - entry.guid, - ); + this._localRecordCache.set(this.getVirtualPath(entry.path), { + guid: entry.guid, + hash: entry.hash, + mtime: entry.modifiedAt, + }); } } catch (e) { this.warn("local record cache: hash store unavailable", e); @@ -2688,29 +2971,101 @@ export class SharedFolder extends HasProvider { case "MAP_DELETE": this.executeMapDelete(effect.path); return; + case "RETRACT_UPLOAD": + this.executeRetractUpload( + effect.path, + effect.guid, + effect.releaseHold, + ); + return; case "PARK": + // The parked file AND its persisted hold both stay: a hold + // marks content the server does not have, and its identity + // is never dropped without a completed publication or an + // explicit user action. The machine's row state keeps the + // host's retry paths from publishing it (holdIsPublishable). this.log(`[FolderHSM] parked ${effect.path}: ${effect.reason}`); return; case "SURFACE_STATUS": this.notifyListeners(); return; + case "PERSIST_STATE": + this.executeFolderStatePersist(); + return; } } private executeEnqueueUpload(vpath: string): void { + // Idempotent under concurrent invocation: the machine re-emits + // decided-but-unacknowledged work at-least-once, so a dispatch + // already in flight for this path is the same work item, not new + // work. + if (this._uploadDispatches.has(vpath)) return; try { const tfile = this.vault.getAbstractFileByPath(this.getPath(vpath)); if (!tfile || !this.isSyncableTFile(tfile)) return; if (this.skipStorageBlockedUpload(vpath)) return; // The guid is minted here (placeHold) — pendingUpload is the // durable record that this file is ours, awaiting first upload. + // placeHold reuses an existing hold's identity, so retries after + // restart never mint fresh guids. + this._uploadDispatches.add(vpath); this.placeHold([tfile]); this.uploadFile(tfile); + const guid = this.pendingUpload.get(vpath) ?? this.syncStore.get(vpath); + if (guid) { + // Durable acceptance: the hold row is persisted and the + // upload queued. Reported exactly once per work item. + this.folderHSM?.send({ + type: "WORK_STARTED", + kind: "upload", + path: vpath, + guid, + }); + } } catch (e) { + this._uploadDispatches.delete(vpath); this.warn("[FolderHSM] upload effect failed", vpath, e); } } + /** + * Withdraw queued upload work for a path. Always cancels the queued + * work and tears down provisional live-doc state; releases the + * persisted hold only when the machine sanctioned it (the local file + * is gone, or a committed identity superseded the mint) — otherwise + * the hold's identity is preserved with the file. + */ + private executeRetractUpload( + vpath: string, + guid: string | null, + releaseHold: boolean, + ): void { + this._uploadDispatches.delete(vpath); + const heldGuid = this.pendingUpload.get(vpath) ?? guid ?? undefined; + if (heldGuid) { + this.backgroundSync.cancelDocumentWork(heldGuid); + const file = this.files.get(heldGuid); + if (file) { + this.fset.delete(file); + this.files.delete(heldGuid); + file.cleanup(); + file.destroy(); + this.fset.update(); + } + } + if (releaseHold) { + this.pendingUpload.delete(vpath); + if (heldGuid) { + if (this._localDoc) { + this.deferDocTeardown([{ guid: heldGuid, path: vpath }]); + } else { + this.teardownDocState(heldGuid); + } + } + } + } + private executeEnqueueDownload(vpath: string, guid: string): void { if (this.existsSync(vpath)) return; if (this._pendingDownloads.has(vpath)) return; @@ -2729,10 +3084,21 @@ export class SharedFolder extends HasProvider { this.armDownloadSweep(); return; } + // Durable acceptance: the download enters the in-flight set and the + // server-create pipeline. Reported exactly once per work item. + this.folderHSM?.send({ + type: "WORK_STARTED", + kind: "download", + path: vpath, + guid, + }); const promise = this._handleServerCreate(vpath, meta) .then((file) => { if (file) { - this._localRecordCache.set(vpath, guid); + this._localRecordCache.set( + vpath, + this.recordSyncedNow(vpath, guid), + ); this.folderHSM?.send({ type: "DOWNLOAD_COMPLETE", path: vpath, @@ -2740,8 +3106,15 @@ export class SharedFolder extends HasProvider { }); } else { // Deferred: the room exists but carries no content yet - // (the sharer has not finished staging). The sweep - // retries once content lands. + // (the sharer has not finished staging). The accepted + // work item died without executing — report it so the + // machine returns the row to pending; the sweep retries + // once content lands. + this.folderHSM?.send({ + type: "DOWNLOAD_FAILED", + path: vpath, + guid, + }); this.armDownloadSweep(); } return file; @@ -2812,13 +3185,14 @@ export class SharedFolder extends HasProvider { doc.cleanup(); doc.destroy(); if (this._localDoc) { - this.deferDocTeardown([doc.guid]); + this.deferDocTeardown([{ guid: doc.guid, path: vpath }]); } else { this.teardownDocState(doc.guid); } } this.pendingUpload.delete(vpath); this._localRecordCache.delete(vpath); + this._uploadDispatches.delete(vpath); this.fset.update(); this.folderHSM?.send({ type: "TRASH_COMPLETE", path: vpath, guid }); })(); @@ -2863,10 +3237,11 @@ export class SharedFolder extends HasProvider { if (!tfile) return; if (oldPath) { this.renameFile(tfile, this.getPath(oldPath)); - } else if (this.isSyncableTFile(tfile)) { - this.placeHold([tfile]); - this.uploadFile(tfile); } + // No oldPath: nothing to rename, and nothing to mint — identity + // is minted only in the execution of the machine's upload + // effect. (The machine's MAP_SET always carries oldPath; this + // branch exists only so a malformed effect cannot mint.) } catch (e) { this.warn("[FolderHSM] map rename effect failed", newPath, e); } @@ -2913,6 +3288,14 @@ export class SharedFolder extends HasProvider { return { op: "noop", path, promise: Promise.resolve() }; } + // The membership row is the only per-file authority: a hold whose + // row the machine parked, condemned, or contested must not flush + // through the sweep's retry path — the preserved hold is identity + // safekeeping, not publication intent. + if (this.folderHSM && !this.folderHSM.holdIsPublishable(path)) { + return { op: "noop", path, promise: Promise.resolve() }; + } + // Server-authoritative rule: if committed filemeta already points at a // different GUID for this path, do not publish/overwrite local pending // metadata. Adopt the committed GUID instead. @@ -3155,11 +3538,27 @@ export class SharedFolder extends HasProvider { } } else { // the File exists, but the ID doesn't - this.warn("[getDoc]: creating new shared ID for existing tfile"); const tfile = this.vault.getAbstractFileByPath(this.getPath(vpath)); if (!(tfile instanceof TFile)) { throw new Error("unexpectedly missing tfile or got tfolder"); } + if (this.folderHSM) { + this.log("[getDoc]: no shared ID; requesting membership decision"); + const shared = this.requestMembershipDecision(vpath); + if (shared !== undefined) { + if (!isDocument(shared)) { + throw new Error("getDoc(): unexpected ifile type"); + } + shared.move(vpath, this); + return shared; + } + throw new Error( + `getDoc(): no shared identity for ${vpath}; membership is ${ + this.folderHSM.getRowState(vpath) ?? "undecided" + }`, + ); + } + this.warn("[getDoc]: creating new shared ID for existing tfile"); const newDocs = this.placeHold([tfile]); if (newDocs.length > 0) { return this.uploadDoc(vpath); @@ -3169,6 +3568,31 @@ export class SharedFolder extends HasProvider { } } + /** + * The engine-on path for a local file with no shared identity. The + * machine is the only authority that may mint one — minting happens in + * the execution of its upload effect — so the lookup becomes a + * membership decision request: hand the machine the file's presence as + * evidence and honor its verdict. A publication verdict has already + * executed synchronously by the time send() returns (identity minted, + * live file object created); any other verdict — parked, conflicted, + * awaiting trust or confidence, read-only — leaves the file + * local-only, surfaced by the machine, with no handle to return. + */ + private requestMembershipDecision(vpath: string): IFile | undefined { + const machine = this.folderHSM; + if (!machine) return undefined; + machine.send({ + type: "FILE_DISCOVERED", + path: vpath, + origin: "bootstrap", + kind: "file", + }); + const minted = this.syncStore.get(vpath); + if (minted === undefined) return undefined; + return this.files.get(minted); + } + public getCanvas(vpath: string, update = true): Canvas { const id = this.syncStore.get(vpath); if (id !== undefined) { @@ -3189,11 +3613,29 @@ export class SharedFolder extends HasProvider { } } else { // the File exists, but the ID doesn't - this.warn("[getCanvas]: creating new shared ID for existing tfile"); const tfile = this.vault.getAbstractFileByPath(this.getPath(vpath)); if (!(tfile instanceof TFile)) { throw new Error("unexpectedly missing tfile or got tfolder"); } + if (this.folderHSM) { + this.log( + "[getCanvas]: no shared ID; requesting membership decision", + ); + const shared = this.requestMembershipDecision(vpath); + if (shared !== undefined) { + if (!isCanvas(shared)) { + throw new Error("getCanvas(): unexpected ifile type"); + } + shared.move(vpath, this); + return shared; + } + throw new Error( + `getCanvas(): no shared identity for ${vpath}; membership is ${ + this.folderHSM.getRowState(vpath) ?? "undecided" + }`, + ); + } + this.warn("[getCanvas]: creating new shared ID for existing tfile"); const newDocs = this.placeHold([tfile]); if (newDocs.length > 0) { return this.uploadCanvas(vpath); @@ -3218,6 +3660,20 @@ export class SharedFolder extends HasProvider { return; } + // An upload can resolve after the machine refused its path (the + // row parked or was condemned while the work was in flight). + // With the row outside the upload states there is no membership + // to record — writing one would publish the very file the + // machine refused. Defense at both layers: the machine also + // refuses the late completion. + if (this.folderHSM && !this.folderHSM.holdIsPublishable(file.path)) { + this.warn( + "[markUploaded] dropped: the membership row no longer accepts publication", + file.path, + ); + return; + } + // Server-authoritative rule: never overwrite an existing committed // GUID for this path with a local pending GUID. const committedMeta = this.syncStore.getCommittedMeta(file.path); @@ -3261,8 +3717,13 @@ export class SharedFolder extends HasProvider { } if (this.folderHSM) { // A committed upload is a durable local record of this path's - // identity, and it settles the machine's membership entry. - this._localRecordCache.set(file.path, meta.id); + // identity (with the content evidence that ties it to the + // bytes just uploaded), and it settles the membership row. + this._localRecordCache.set( + file.path, + this.recordSyncedNow(file.path, meta.id), + ); + this._uploadDispatches.delete(file.path); this.folderHSM.send({ type: "UPLOAD_COMPLETE", path: file.path, @@ -3346,14 +3807,24 @@ export class SharedFolder extends HasProvider { if (tfile instanceof TFolder) { return this.getSyncFolder(vpath, update); } else if (tfile instanceof TFile) { - if (Document.checkExtension(vpath)) { - return this.getDoc(vpath); - } - if ( - Canvas.checkExtension(vpath) && - this.syncSettingsManager.isExtensionEnabled(vpath) - ) { - return this.getCanvas(vpath); + try { + if (Document.checkExtension(vpath)) { + return this.getDoc(vpath); + } + if ( + Canvas.checkExtension(vpath) && + this.syncSettingsManager.isExtensionEnabled(vpath) + ) { + return this.getCanvas(vpath); + } + } catch (e) { + if (this.folderHSM) { + // The machine declined to mint an identity: the file + // stays local-only and there is no shared handle. + this.debug("[getFile] no shared handle", vpath, e); + return null; + } + throw e; } if (this.isSyncableTFile(tfile)) { return this.getSyncFile(vpath, update); @@ -3439,6 +3910,15 @@ export class SharedFolder extends HasProvider { awaitingUpdatesPromise, ]); if (!awaitingUpdates && origin === undefined) { + // The entry row is the per-file authority: a refused row's + // content never ships (see uploadDoc). + if (this.folderHSM && !this.folderHSM.holdIsPublishable(vpath)) { + this.warn( + "[uploadCanvas] skipped: the membership row does not accept publication", + vpath, + ); + return; + } this.log(`[${canvas.path}] No Known Peers: Syncing file into ytext.`); this.folderDoc.transact(() => { try { @@ -3478,7 +3958,13 @@ export class SharedFolder extends HasProvider { const synced = await canvas.getServerSynced(); if (canvas.stat.size === 0 && !synced) { this.backgroundSync.enqueueCanvasDownload(canvas); - } else if (this.pendingUpload.get(canvas.path)) { + } else if ( + this.pendingUpload.get(canvas.path) && + // A preserved hold on a refused row must not resume its + // first upload here (see uploadDoc). + (!this.folderHSM || + this.folderHSM.holdIsPublishable(canvas.path)) + ) { await this.backgroundSync.enqueueUpload(canvas); await this.markUploaded(canvas); } @@ -3634,6 +4120,18 @@ export class SharedFolder extends HasProvider { throw new Error(`Upload failed, doc does not exist at ${vpath}`); } if (!awaitingUpdates) { + // The entry row is the per-file authority: a preserved hold + // on a row the machine parked or condemned is identity + // safekeeping, not publication intent — neither content nor + // membership may ship through this path (defense in depth + // with the markUploaded fence). + if (this.folderHSM && !this.folderHSM.holdIsPublishable(vpath)) { + this.warn( + "[uploadDoc] skipped: the membership row does not accept publication", + vpath, + ); + return; + } await doc.hsm?.initializeWithContent(); await this.backgroundSync.enqueueUpload(doc); await this.markUploaded(doc); @@ -3663,7 +4161,12 @@ export class SharedFolder extends HasProvider { const synced = await doc.getServerSynced(); if (doc.tfile?.stat.size === 0 && !synced) { this.backgroundSync.enqueueDownload(doc, false); - } else if (this.pendingUpload.get(doc.path)) { + } else if ( + this.pendingUpload.get(doc.path) && + // A preserved hold on a refused row must not resume its + // first upload here (see uploadDoc). + (!this.folderHSM || this.folderHSM.holdIsPublishable(doc.path)) + ) { await this.backgroundSync.enqueueUpload(doc); await this.markUploaded(doc); } @@ -3941,7 +4444,7 @@ export class SharedFolder extends HasProvider { if (paths.length === 0) { return; } - const cleanupGuids = new Set(); + const cleanupGuids = new Map(); this.folderDoc.transact(() => { for (const vpath of paths) { this.pendingUpload.delete(vpath); @@ -3955,7 +4458,7 @@ export class SharedFolder extends HasProvider { doc.cleanup(); doc.destroy(); } - cleanupGuids.add(guid); + cleanupGuids.set(guid, vpath); } else { // syncStore entry already gone (remote delete) - find by path const doc = this.fset.find((f) => f.path === vpath); @@ -3965,7 +4468,7 @@ export class SharedFolder extends HasProvider { this.files.delete(docGuid); doc.cleanup(); doc.destroy(); - cleanupGuids.add(docGuid); + cleanupGuids.set(docGuid, vpath); } } } @@ -3977,9 +4480,14 @@ export class SharedFolder extends HasProvider { // Under the split, teardown of a deleted doc's local CRDT // persistence and HSM state defers for the capture retention // window, so a deletion undo reattaches instead of re-downloading. - this.deferDocTeardown(cleanupGuids); + this.deferDocTeardown( + Array.from(cleanupGuids.entries()).map(([guid, path]) => ({ + guid, + path, + })), + ); } else { - for (const guid of cleanupGuids) { + for (const guid of cleanupGuids.keys()) { this.teardownDocState(guid); } } @@ -3991,25 +4499,20 @@ export class SharedFolder extends HasProvider { trackAsyncCleanup(p); } - private deferDocTeardown(guids: Iterable): void { - const now = this.timeProvider.now(); + private deferDocTeardown( + docs: Iterable<{ guid: string; path: string }>, + ): void { + const expiresAt = + this.timeProvider.now() + FOLDER_DELETION_RETENTION_MS; let changed = false; - for (const guid of guids) { - this._deferredTeardown.push({ guid, deletedAt: now }); + for (const { guid, path } of docs) { + this._deferredTeardown.push({ guid, path, expiresAt }); changed = true; } - if (changed) this.persistDeferredTeardown(); - } - - private persistDeferredTeardown(): void { - try { - void this._persistence.set( - "deferredDocTeardown", - JSON.stringify(this._deferredTeardown), - ); - } catch (e) { - // Ledger persistence is best-effort; an unswept entry only delays - // cleanup, never loses data. + if (changed) { + // The retained-doc ledger persists as fork-class state, written + // only through the machine's PERSIST_STATE effect. + this.folderHSM?.requestPersist(); } } @@ -4019,19 +4522,21 @@ export class SharedFolder extends HasProvider { */ private sweepDeferredTeardown(): void { if (this._deferredTeardown.length === 0) return; - const cutoff = this.timeProvider.now() - FOLDER_DELETION_RETENTION_MS; + const now = this.timeProvider.now(); const live = new Set(this.syncStore.getCommittedSubdocGuids()); - const keep: Array<{ guid: string; deletedAt: number }> = []; + const keep: RetainedDoc[] = []; for (const entry of this._deferredTeardown) { if (live.has(entry.guid)) continue; - if (entry.deletedAt <= cutoff) { + if (entry.expiresAt <= now) { this.teardownDocState(entry.guid); } else { keep.push(entry); } } - this._deferredTeardown = keep; - this.persistDeferredTeardown(); + if (keep.length !== this._deferredTeardown.length) { + this._deferredTeardown = keep; + this.folderHSM?.requestPersist(); + } } renameFile(tfile: TAbstractFile, oldPath: string) { @@ -4179,10 +4684,10 @@ export class SharedFolder extends HasProvider { const p = this._persistence.destroy().catch(() => {}); trackAsyncCleanup(p); } - if (this._remotePersistence) { - const p = this._remotePersistence.destroy().catch(() => {}); + if (this._folderStateStore) { + const p = this._folderStateStore.destroy().catch(() => {}); trackAsyncCleanup(p); - this._remotePersistence = null; + this._folderStateStore = null; } this._localDoc?.destroy(); this._localDoc = null; @@ -4279,6 +4784,9 @@ export class SharedFolders extends ObservableSet { // The folder's pending-upload records live in localStorage, not // IDB; removal is the only point where they become garbage. item.clearPendingUploads(); + // The fork-class row's lifecycle is bound to the folder's: + // unsharing retires it. + item.retireFolderState(); // Folder-scoped HSM states and in-folder hash rows, including // records for files outside the current in-memory enumeration. void item.reclaimOwnedRecords(); @@ -4382,6 +4890,12 @@ export class SharedFolders extends ObservableSet { indexedDB.deleteDatabase(`${this.appId}-relay-folder-${guid}`); indexedDB.deleteDatabase(`${this.appId}-relay-folder-${guid}-remote`); indexedDB.deleteDatabase(guid); + // The folder's fork-class row retires with the rest of its state. + const folderStateStore = new FolderHSMStore(this.appId); + void folderStateStore + .deleteState(guid) + .catch(() => {}) + .then(() => folderStateStore.destroy()); } /** diff --git a/src/components/AddToVaultModalContent.svelte b/src/components/AddToVaultModalContent.svelte index 8a223c3ea..84200050b 100644 --- a/src/components/AddToVaultModalContent.svelte +++ b/src/components/AddToVaultModalContent.svelte @@ -109,6 +109,7 @@ {/if} @@ -575,7 +579,11 @@ {@const on = category.enabled && !locked} {@const disabled = isUpdating || !category.canToggle} -
+
{#if locked} {/if} @@ -604,6 +612,7 @@