From 7656761ff881e3c1f88bf5c9083ce5f0d7b2bd38 Mon Sep 17 00:00:00 2001 From: Daniel Grossmann-Kavanagh Date: Wed, 22 Jul 2026 01:21:10 -0700 Subject: [PATCH 1/9] fix: seed the enabled-sync-types baseline before the first syncFileTree The baseline was assigned after syncStore.start() in the readiness chain, but the syncStore observer and the merge manager both exist from construction, so a tree sync can run earlier. The newly-enabled-types diff then reads the empty baseline as "every type was just enabled" and runs addLocalDocs before readiness gates that discovery -- the exact early-discovery hazard the readiness chain documents: placeholder GUIDs reserved for already-shared files and HSMs built without their persisted fork and LCA state. Seed the baseline at construction, immediately after the SyncStore exists, so the first tree sync diffs against reality and enqueues nothing early. Toggling a type on still enqueues its files: the diff against a real baseline catches it on the next tree sync. --- src/SharedFolder.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/SharedFolder.ts b/src/SharedFolder.ts index 62cdd16a9..6aa2632f7 100644 --- a/src/SharedFolder.ts +++ b/src/SharedFolder.ts @@ -407,6 +407,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 @@ -742,9 +751,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 From 5a37d0e0f71a89fe481cdc41e9ec118d0389bf4a Mon Sep 17 00:00:00 2001 From: Daniel Grossmann-Kavanagh Date: Wed, 22 Jul 2026 11:23:43 -0700 Subject: [PATCH 2/9] ci: pin GitHub Actions to commit SHAs --- .github/workflows/bake-windows-image.yml | 4 ++-- .github/workflows/e2e-burnin.yml | 10 +++++----- .github/workflows/e2e-multinode.yml | 8 ++++---- .github/workflows/e2e-parallel-lane.yml | 8 ++++---- .github/workflows/e2e-test-plans.yml | 8 ++++---- .github/workflows/pre-release.yml | 10 +++++----- .github/workflows/release.yaml | 10 +++++----- .github/workflows/unit-tests.yaml | 6 +++--- 8 files changed, 32 insertions(+), 32 deletions(-) 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..d30925398 100644 --- a/.github/workflows/e2e-test-plans.yml +++ b/.github/workflows/e2e-test-plans.yml @@ -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 @@ -476,7 +476,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 +503,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'); From 6898726deb003e726af250497769580697c6580f Mon Sep 17 00:00:00 2001 From: Daniel Grossmann-Kavanagh Date: Wed, 22 Jul 2026 18:24:57 -0700 Subject: [PATCH 3/9] feat: rebuild folder membership on a declarative per-file entry machine Shared-folder membership decisions now run through a declared per-file state machine: explicit entry states with legal transitions, evidence requirements on every destructive or publishing move, and refusal semantics for colliding events. Folder state persists in a dedicated per-app database holding the folder fork (withheld deletion bursts with observed identities), the deletion-retention ledger, and a snapshot cache of the server-side index that replaces the per-folder remote database. Held uploads are never trashed and never lose their identity silently. Behavior with the folder engine disabled is unchanged. Files with no shared identity acquire one only through the engine's own publication path: the editor lookup fallbacks request a membership decision instead of minting and uploading directly. Remote assertions and keep-remote resolutions at refused paths converge on the committed identity, retracting a superseded local mint, and deleting a refused file releases its held identity with it. --- __tests__/SharedFolder.folder-hsm.test.ts | Bin 31865 -> 41556 bytes __tests__/folder-hsm/FolderHSM.test.ts | Bin 19817 -> 23675 bytes __tests__/folder-hsm/delete-collector.test.ts | Bin 10777 -> 13780 bytes .../folder-hsm/engine-invariants.test.ts | Bin 0 -> 6124 bytes .../folder-hsm/engine-properties.test.ts | Bin 0 -> 6895 bytes __tests__/folder-hsm/engine-scenarios.test.ts | Bin 0 -> 15919 bytes __tests__/folder-hsm/entry-machine.test.ts | Bin 0 -> 39573 bytes .../folder-hsm/folder-state-store.test.ts | Bin 0 -> 2440 bytes src/CanvasPlugin.ts | 9 +- src/LiveViews.ts | 31 +- src/SharedFolder.ts | 774 ++++++-- src/folder-hsm/FolderHSM.ts | 1742 +++++++++++++---- src/folder-hsm/delete-collector.ts | 76 +- src/folder-hsm/entry-machine.ts | 529 +++++ src/folder-hsm/index.ts | 26 + src/folder-hsm/invariants.ts | 120 ++ src/folder-hsm/machine-definition.ts | 140 +- src/folder-hsm/pending-sync.ts | 70 + src/folder-hsm/persistence/FolderHSMStore.ts | 350 ++++ src/folder-hsm/testing/createTestEngine.ts | 269 +++ src/folder-hsm/testing/events.ts | 152 ++ src/folder-hsm/testing/index.ts | 8 + src/folder-hsm/types.ts | 378 +++- src/y-codemirror.next/RemoteSelections.ts | 9 +- .../UserAttributionPlugin.ts | 8 +- 25 files changed, 4062 insertions(+), 629 deletions(-) create mode 100644 __tests__/folder-hsm/engine-invariants.test.ts create mode 100644 __tests__/folder-hsm/engine-properties.test.ts create mode 100644 __tests__/folder-hsm/engine-scenarios.test.ts create mode 100644 __tests__/folder-hsm/entry-machine.test.ts create mode 100644 __tests__/folder-hsm/folder-state-store.test.ts create mode 100644 src/folder-hsm/entry-machine.ts create mode 100644 src/folder-hsm/invariants.ts create mode 100644 src/folder-hsm/pending-sync.ts create mode 100644 src/folder-hsm/persistence/FolderHSMStore.ts create mode 100644 src/folder-hsm/testing/createTestEngine.ts create mode 100644 src/folder-hsm/testing/events.ts create mode 100644 src/folder-hsm/testing/index.ts diff --git a/__tests__/SharedFolder.folder-hsm.test.ts b/__tests__/SharedFolder.folder-hsm.test.ts index 41aef605695d6f3fe636837353e6d30c935ad18d..9378eeda78d582c066ed89ecc5efbdcd867fe599 100644 GIT binary patch literal 41556 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 literal 31865 zcmV(pK=8i+M@dveQdv+`04Z`RM{UrM7@CFngbRqh%vLs-*jKIYfBkrAwCR z^4zj8Awa61EH@|E>7KHg-kN9hA*{t(@HI}!V`FJ{>f|F0&Zi;@>>GYum54@O{w?pL zSR7~ArhOLBV21?+1@M?|;L_{Vb`i=u_6Lw?cxeEPelv*Rj5OwyU1NQB)p5k*y|hM2 zndaFf)>{2kB_V?HyIq9XrA|5V6;E+YGqlA7m(ZlQ zUzA#&Y-r8-pZ8|*AE)-)?X2IOS!oyw5PY9Bm^5FZdCCA^5u99w6#D}Qjx zGPwp=dR2c2_j+f1cZBJmsPvN!Z4P@)kGa2p_I$BG?PDZ8mhr zXd1m0x6;D=2kBpE@Z8hWFFUqzbz1aU!WWVm-yEqt<*@n@`p^X|${cDQCGkmdhdDYC z3iGf2eAD3TxBF*i_HjRRPAbVp?C>OItvJbN@@Uz?o-Re+$hDmHcVd(4Pq) zvcWQA4-%i=4@H_yP8kK8_fSSR*p_WaW0$2dFL9YuFlCWRK;yXOgHRc!l^uOv`z?Vd zZ|>_BMtDUP{u+UzG(cL9E3%Z`+N=Bn$Vn9;d*>%mY|HE1L6n8;O)MC-D42)%qaFlq z?Nf)639WamZgXp`abC=bZ%}PjO-jjwA`C90={nvVufaSDcZyVrI_GG(-=VF+&wWaK zUFeSx(3YoZK(fqMRrUfC>5i}4cZ9veK*V#ZBT3inP8ywm6E;8zseuyPuMT$kXLbtj0;&P0_arc3cdbu zv$B%bAbjXU!;AO|Y=1uEv_9`rD4Og7^vHeTY^gw0aR1T$L0qPXQD2+KZuKtV^B=Y5Hxt?umOC0JjObnn{fT2;=0;(x zP<-GhFAFHjDOt}DH0D}9By_IpmQ(&J7A7n4q#J*AU5IoMYInO|)TfYRpNN+R)lj>_ zo4<_yLDfSV>X?sMFNgf9;u+?<)<42N##OVtw1uSw5S-v6<(28Q_+ zM>R3iKvWed%CiZ&P};1~PJSetFVCwKSX<$4LRx7e*=!8?@=V|pAQ(4$`7Rk$l{=hl{>7a#-U4j9_ZUi5z_DG@ExBMqq!#)R+b@0BZnJ{Oz~SRXJl388Tks zS>$H#@Ee;dzb6)u1`SUbm}+a3i%kvzChZk@K2r&3IO)hL1o`Q4K8~D{bx247iF}K{ zgFV9Gt*{lRza^Jcb0y3rSw9st~_hcrz9UjN`<65N0GXFU@kOjv_F zlSPTTu%VS@WgB%HSJfakuY&kpwVgs~(!2pys6rqzN6!we4(M(X0~mD=ipNH=u?Isa z;0olh!+kuoCsz}UyHbCr#)Bkj_Nq90qA++-PZzM~sdWNb{F@d>CU7Pd^{WDdõ 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%0AcA)MI(m;Ijw6)S4XA^D6zgx$j2N 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 Evgi)+M*si- diff --git a/__tests__/folder-hsm/FolderHSM.test.ts b/__tests__/folder-hsm/FolderHSM.test.ts index d1dead85aa954ee7ffab5dbcdea79dcdf48c0ab7..8360d42bf57c2765d5f748131406dfb60dad28f2 100644 GIT binary patch literal 23675 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

wE^X5 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 UljeTjL0B?j2jr9AUL+Czum+pG+yDRo diff --git a/__tests__/folder-hsm/delete-collector.test.ts b/__tests__/folder-hsm/delete-collector.test.ts index a5d991c9b41a6766a5a7cd615d404cc623694adf..593acb9123fb1f9775435871dd7a602e087753d0 100644 GIT binary patch literal 13780 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 >fW6!rcDs!SyFsx5eiZk1Gfb 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 literal 0 HcmV?d00001 diff --git a/__tests__/folder-hsm/engine-properties.test.ts b/__tests__/folder-hsm/engine-properties.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6664a073ea2437dc5c0fe0bb2ae1087459fc518e GIT binary patch literal 6895 zcmVI&Ru}dBM8%PfRMznEbDJuY-&WhZ?ZDbq(3*N@dOgs#8^kwrTjPccqy}6a z;qib)VsHUJN+0ipM$(Y_bsbyNEvrs~qoz61EVd`WF>W}hfru9H+@0A1#Mm(@8$tN` z86S?ba%HV7=YIJv8=A$;nJ+>JJ6Usw>}8wsLs3p@i#_bZ_~oiH4#Tp~c1YaU!-Y(7 z@C5k@>1&_}?Zt|Dnq0QPs094+wuM(Kb!a6cV)?E5AVxX9ceb`jeMFlYtdI_kD&^-m z6i7k!dck@RxJ|?&op+eObZtnRmum%2>5{&>;y3q}C_$RH4MDr}5QxtjoBDASo~?PU znC=Gx2cZr6{5G{sk105G#+qkm6$ljiFud>;t0t$ej`JSj>b`3}q4Pcgmq!>2-gMid zjhYB11jNsL@Uz)vEIez8)Xi*E)(0EFZ zlMHp(xx`#1t*7#J(9{GMU>OY1tVnn%6l!*@tU-jeT&7|hQ`dQpu9&>6BVs_CW9iRi z(`M$YPWo?+l4kVAVysjms=J;Z2E+}`@gll!MH6t6CuHsjh}33ks(y=LD;4^uam$Ly zlInS}qaP|yM_C7fu9MY;wYB4J9T-UPGj)Bo* z=uckqFP=s85%%UX2p6vqx!M}Ky_8>?3-G7Y`?t)^QWraAK@)S@IA6R;xH`&f-c4$7 z>sGzrWGt$$YVx6se?Rl4)7vt9iBIs#HZZO`Lqp+1AB!mxA)*_y6Q)r2E@RPMp&QvL zGC=~VR4Ku&e4C3xm1K+bun>Z298-MjYlQ(r&%i{AeqmWYc0${Y2aMZHT%w1*T+@I& zhCz+V=^9>27P45Dmb$56UDECrBvQLI7^HcZ*FQ|hwMf3?)`#B5sS5OI_ASlqzx9Mi z7SmYdbk=dhL7d1nuFt0g*Tkt8W^Th&kl8#`VQ1ugwQP62vAO;q=>ahvNW-5ug)f%Y znKHy>Lh(1=Rez>Mgv|s2g##Y}oU|ne;*x$rmW`qS4e1=0r^HHe1lHH*&al`N&nnJH zK+G}R7pd`s$uq+)a&RMCsG^NGDUy0E0M%fJ9DGYC9Nyo;9weQBgnng)3hepG} z2BX5ANe>QWn%-Lzy4@5_KwsFBa&Wgh9>3tKVzYKvA0x|goLSrMvrzev!||jJp7Ocs zs|G<7p8#dK&hr77<}Qx&SEi62mU&|7lfadj(6b8c`5_jNrZnj8jZx)%Gh$vR!d}3O z6bx?+}bA7JX_KwRBV-j*LzR0fqWb2}!nFeB_ouR`@)KAG1Eq5r6>~%9uKb zNaxI6hmQjwvZWTS8RS!?Ifpt$6i*`9I#CK7*)4vcA9Z*uQygsZESVOte%$u2q%CIVh--B3wV8=ZrUhIxA%ZiC(-JKeZlw-^t8k z_@bS#_5KYFbCp(F*WagLL#QZf2)Z`Vfk{tGgkloBT%?}nYMjp0YpAK)tLqUpD)Rj# zYNpL-(ERQNxZ2R<^V>I{ja16>9^}`l49a}*H-KWs`x#4Kim2s|SMIIWkM)@>Jg1PiBEGlw1PL8Z1Z?2DO9ST~g**0^A1 zpYKXPaW-6)83|JIVE?wiy5Fm3^b2N|B*P1N1B2S;{t!(61pV0(rZ3MKd`B-Ps_{S2q57T`B_GDmdz` zv_dwT1iRRi65Jmmc?kY4iJi@@iYvR^jFBaMF37O!CYtzn>D24R<=;1>WRRLGx+ftD zs-9<;UL!B*wR51Ga_ATPD!b)^WVApC4eL+P-syYB295pbZ%n(i^;t?O#@QqWbIs(G z3xw6HVgPy)n}eTPErBKhz==YgGIY%J4JW2w=l!-+ z_=+y4vtofDqY5X~0S$dB4o z&$LO8)6Rrp@!N%`>us5NZSd3m1Eh(T8?(Kad2xlI#wxeEs?%7@1DHAqkS@Ajl} zGKIYRYVaLxKj$GV*UNW*_q||KM#Ti&_mrinLA6i)vlY+eRF3)Bfk1CaT-X35@JGs} zZWKtS(q65E%*Ad&9TlY|esaOi=mFubM#9h=>|CnOMo?e;Kp^zMp-Qz@zPu3aH*EV! z-bfDt!5|UOsCg7+>vr%qF3Z5P3#kHkV4#Rq4_Y1}Xf3J8^f8*6Q>cFLvWCd%Lx*)>%r zx64Du5P6O^jG60)>Fnt;*O`8hW7U##oY(I7Eq>O1gFZ5k#%Pyr$o{{!zJj2O>_?8ISQt{QUB8W^FkfM;H-tHGde6C!tg%8 z;Rw4L1FbW{C={Y@WH3|H!Vu}Lz1XyGc3I*@9PBofh(7Y+VXc;m+=IXAFXbp{kg;E) zo#1{p79QJ$B|L8rZ<4)Erk{A9Z9=r6!0K+m?gs&8dKwj7+>d~b6iuV&%v?qUJa2YaQTDUEMt2obG#J1X4kmRt$hvPM05A6zm4S2q{& z3m_9(ZR!1m;1;VBg8ZahqiDL+cq7$FFBO+RB5H%h90=t`fm{?9R^B|J0MmG)8xTwm zO_&EL8s=b&;>L|noMUC3M2RN883pb=4dcZmQS7kD+FtTCf%vLj0Aq2i#~q9pX|6^L zuZKW4{m-L`%PW&}XTfXr^Jh3)XR|AGU(K!nq$e2rb)th}sxtIxCseP?P5#IZO;Crq zerrX#=e?;yfSJR@kLh`}tiCbAZPS?Bp@!vUWQgJ>(=bvNRI0|aP& z5XIosg__154FJe$8FGRGImueM>29+aQ>P6LB+5l2iyjcHc^}jYK&oXi5npR{gPI}E zwOE@*d?!Nc3#LO4HLzld6IcWN!ltFU++-ec7#!Rg0!m7n_bKEgQx>UTE0?OpmO^uE@EknB~L)^I#)@31-2_%DtkL8;$O%X$fNA5Vj&HWpkK-Rc*0_RKSpft z*!7OfJBZDe_Gu}B8}#HF6=C5YkXP(7yTHo8{7OeTqB8*-Q0v%iH?OJ5$!}!-l^kos z)RqxxRl;I>g7iARUr3=xg5tCBlyp-c^x2jGAv=d< z`;%8v-_A)&_n+J)gD2mwY|NlT&=r08GK{}87jNr4Pk(%gchUy@jqCFS0@&Vqvmn=M znK@+^b+IMxoL2Q&?3FB7utXTdtXOvsg_b-fS|g~O7)<%baKQHD>)9U`#@n6#sKze8 zXg^gx>zMJ6GBLa$u=3q}i2EjJ=7;wMp4ZA(TTz9LF)}$8bpko68(8rs7pv8AFc-W0 ziUj#DWg|nk&knioZQzx84&n8+!0V;ttUAB4hN1oG7H$fZ)SEkw~h%WU(yMllugg zY9jE4d0h7(cKs3Ug!4nA4m<;UIEqgfUZxF1Bu^ zm6GW#6vAINh+!u~W9Dcd3wt2th+O&0k)Jhg_I@Du#lKTqWAIM>jQagAIep&$HNw@Q zcegNen9gJXn_NhWqC8m`HjiS~V7~*Spb!^eZHcEpLi2{R6z8xa9zhN(MQMw~1soMN zGd{_8g4a(&Q2sp?L3ysvTWx|e@s{%=0FzJ9WLnnexq{Jx=C>lxgk_;ee3Rl9Z4Z)+ z`By+K*lgKEhDIHtfgE|f>Xk$Y&w9F%!)lQ7gGOoaiL;`r>X#pM|pVgwMAQMYT5r!q8m zqxF7lV5Q)Tk@Vl(02h|CCSn$`-Zl5GYWSif3I(6efOwM_cEQ-?6P&e9+-(U)?A3ef zB7CrR=pI>)M8@1T3bALAFgBHWvf6In;wg%{%4rU*ODeu0pw**M6$3 zoe}9&^m0s(v09(9**OvHeBDW((F7}RMBIg7bYAW99;G|jMjZfn$`XvMLWUDLY>u+3 zfGrNl4`EV^7$Xu~{lJDy&A6nLVtwLAWySLiKQ~mtowITSlC5FEo1B=ZmaMA4L@q~y z;;?(#dJ=3}DXTz!1<~`Ju#Ttk7zn(nOv69T%M%Kv?|~n~&vYI7%HyO)3((#iU}}$M ztA^S3#c?0x%)U@IP^`A4u=$yuW0Ls!s!0*Kt1aIw2~L@tam1v!(Gzd(x0kH1BXu5? zI)NwJ^*FA7Tt_Des%Zf`(3(T_HN&tj)sxb%bhC9JI=ThI>bm~-|UxC71 zt>;P2Hb#Z)_@_I$CyXN1?x)ukxn0eM{XfviCvJssdb)1xrysSW{U)EWwRUe`4X%r# zVN+*6=ZL!C{Qt5#7Xw-P40!Hyx1(uAK++rAY7i`Ibb?Y=0l--8Q|AQlVPKZHh+ERN zzTvZvZ}%-I8&P(-n0-{LJORSryf>$0WE`+XU_}R##;18PqbCw|o6i_b2)oA;->=R% z{=l|paJ)qvnF7i{Wk?YMp8;HgXHq7JS_6;i-F1>b;7Ac{?K8@vCvI?#`c zdl0+^`&y>3Skee|kHam^Gki9gySKz1bEeOgQXjRR89`uYqab63e>E~(2lmH%khS_( zXXrYGzpVVt?O@p3lC+(LR`4$MT%T?59X8qQKlHEfQo+qzb1JVOy~%>k^!BMX;Pa)k zE)+0$!7IdSq?DD^ktsB#DpRH;t`k@^%I4exAVOC@nTJI*`d8v`6ftGk+aDW3s5%)i zGD?V|$vNVShEkWnGQB|yS@Q+wYpUAC^+RX$Mj|x$pw712`a{x-KcU`n;MN@Nb1&0L_Mx63k|~!16Q1(&&0lDo+8fN`b-?Mf zRayhZT()qQONnyVNJ(v0E=1mP!v)fJyytu3`8s4z5j{w+OBh;~NUR>+*S{Nl>7_}= zk&AqO#@?CtMor5z+Js$mltM7iS>`|U0HSVhcxczAL%m|Nd(#qPQiN1(k4#=PqHIE! zopH9V!czKcupBE*i5X{ZvXJg~)lzi+PVnz7w%5oBM$^%pZ;pw{hSto5g*D&rtLLEIXokO;nhk_#re1H|eJNK_~x zzM(W@-om6BFz$E^0cg%)$eDona3xsr!?`g!KMg+@Fw;URMeG2uKd`Fz96UzU_=au) zsE0w>lbg~O?Z}#zNDv6{hiqUNW<$~;pV{0kHp`5mb13W%z#*7%Qxet)k&i&H+}HK# zJhknZzAFeEj=u09n)n#W{W9tvosFkr;bV-=szH6GRYCLt3#{EZXd@oU0UV_OIxTEt z9wRf|1~s;sP5ynabOdcU<1`>pNUG=+fQeU_$|jeD{gp66PV*md?~^m?yi!|!FXCBY zY4i|?d^$BBRURIo71eUIn7!Ncx|F9vxPJJmd3q^_y|>Hqi_g2~i-8XOn3zCPJJP^O zphzu44;f#qb&IF!Op~sqPVl18MB7=><$+o$@yNmpYKle577zSn&He+>nKe6mn|I_{ z5cU5ZwG8yYvVcU~`vsBAW@6X(&+#&{=RNqCE|*AjEZ-I(k3&@Utic%R0?D9kqre2M z*w-+@h~S~GBt3bv%8vaS1VmkLA`8M!m}KFmwGb`P1nkGz)(-)as=KqIs8znu_Jycm z$o-(K52OUSwwgtKYZU?1K}L+!)BH?z1Kiisla-o`K0GeMY$PSvV}e!Xcb3X)q=EEU zVZ}mKYZD!CKE+)f=4MX263Gl=hgg``yXWbXaL_uipXzR%SJVEIogn zW;X=6M_kzUIw)Zv#h2oXRm|mOyO;uVYb$s7TQwD|HJAd^CfakbNDgIx?IToncEBvB z{ha*8_-+di`dN=@X%bhLXwRkv76?ke@*}Z literal 0 HcmV?d00001 diff --git a/__tests__/folder-hsm/engine-scenarios.test.ts b/__tests__/folder-hsm/engine-scenarios.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bedf846b57a464860a069d472ff67ab6260ef394 GIT binary patch literal 15919 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 RLgoy+c4fF0_xUf&9yH~sxo-df literal 0 HcmV?d00001 diff --git a/__tests__/folder-hsm/entry-machine.test.ts b/__tests__/folder-hsm/entry-machine.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f55e28433df36e4808f0f81eeb7ee10e95b1564f GIT binary patch literal 39573 zcmV(tKIZWEax({l^@Q@hWBD8md?zf1Iq5TGc(5Obd z;TI`l`*AiL&(Jk^jr%T}7YIW+igclN_PCKBRW;Q-0kvcKUN7#kNVgHEbJ1PJf?Xj6 zW{)`L!W}kuQe*(c;iUN%h7fD1hN^l7SdCJ_6Tp;lFX=zn_|0>= z$2u6uiEx%x6ZarW(@pT3J%gikvFH9^!PPLnHvd;27g7mE=T9U*GJpStnWDzJ00KboqYw4Eu8o_x=;Z9G# z_p=<}6sK63&v2u`Xaul*umUPwS@Bm+duBxn3W_F2E7mAZ_FQ@=U@f_#WM$s9^CZ?v z=n%NK019{!ndA!mOAkeYsb(2CcGw@{&o>bgmsF+iTT^2LIb=D}qTwrdP<1<)Oh!Jq zsy|c(xw%UOehA8Dyo)35?B+D^l{{6MvE@7(AQt~MbJA1p(xv7+)1wqzb*}8-yoU8w zV~G5BOg9AKoF}A9{xOmsw7K0z#vKwKzAFmlnvyF&Pw>LJxpd6xspsGrcFFd?yLqsL z@=|9ym{2X=jz)Hpc+>`xRGq*$*3Gx0Mel$R@Ry>*sslpK`BtUsqCVn0D(o;7CoV3K zJR4$9M)kH$DPMXB`M4`12orK|V0HEeVQDaaIXEniq`rh<1Ty5pa!}lm=sIM8Tu_O|k~6 zKB_T2C`AC5)Qx41#1xjC_RFWd1R4eDESoGy=AK5tF{w@0`?NDe6z9df-(L_&Onmo?7u=ya%CWz25H2ltLL^ZlT2eYpZR{zUL-?%tBabAXAgA?tT8_l+ zgVDKjIKm3sxFFBev9~9S=kJD+LA`g*1T&v=7gfT^Xfoi;`1D>zJ0hQLS)_L&{4+Oz zUY29Zs2;~-A6+UdH}n&43%u>I;4Ktn>dzhjgD+33+d7+NB{F$x#Jk_btY>RcV4L~0 z+X^(j$MT}@cvO)ZAD0jGQe}QX>|=BhAO8s8;c{caVXcKJUAQG1R|+;ioLN->yuC36 zYWC~egPi{OecqA9m$kw<4`8vwC069DY{~14fuXp$R>=b2qyI{*25pB8Cs$qFZU)Lq z6iNv=SqRXLS~0?R$w{Ln_LChQ+p2CXsG;p=O(XY6a2#Mqk#Cs`T4r0{JjS|&0iB-p zpiMBZk4#0aO`pC`;1C@=M^gaaprXS*MNChZuQ0{z3=ryzK-kXoH!pDJbj6q$nuZWp zYt+5)LH3iD_c#*F-SD6@GVLS|uMIjko2=k3HqdCTo!#A-?bzu7=LU80&2zq)M#e1U zs^h!h8GXFdl^Q~Es9WGu$^iwCPgnE(zGmB8#IcTtv5)H;@Jg&*(m5j)D5nW&Sqp=; z#26^1|2&8!VB)na%li%%Dio3o6%yNnAo7_RTtGjKCemDk%p7E+g*c;KpHIv;L`@<3 zvkedyIxlo-p_usRN5D4mtdI!|_U7GMx7@oSPbt+gCBdHK*RW8Eg5GU1Y-;r&w zMrOQtg+=r<*GJVUde9FHTob?jp|khBNV~Pe7Y4v~zp$<#a>o|+SKh@Ss^DP+j(GC# zdDf0WR#Rj+<7eiYL$AHJhc)b~!BIagxE`zJ-mHWBbzDYnc9kTwM2ca

7J0l%eFpjd=*cgE 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 VOcv5P$jCV!(Vnm2S=W<1;VfGWdw2i< literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..df1121da08177cb95223aecd9624af8141325c61 GIT binary patch literal 2440 zcmV;333v7YM@dveQdv+`0CR3hCq2fxuoKEoQdgIRTc<+w+p_|bAbed&?|_ddS5)kI zz#eukuLKC}m?aP(8}wCU)i7rnks!Hkg^ER$Q~-rLxEaPRsRaQz~q2SW6!>M{0FSk*x7w*wxXv7d5{Wd*TG=%PjuG{UX)|9{r5 zaFbh^-DrBffY@BXz%P?QWK#TO-50K~FpR2XxY{DcR1hBYN$_*#x7z+IkNef-{}JV# zDiUT)U3fHj^`(`h2M9LpEGXdt6%x5)NKMidpMLLlH63>;tgI6mJ{oOGP)Hp@x5q0s+K^z%X1>0P`nO1ZRi>M{+1jf%C zpg^h%RrIs5hhOI#bChphD>YRcm7_!okx8dFN0>vH!WkvbhJy6ZY=49x5*pW$Y8Y>h zq>HfXH=tzV`&c}BR9TS6*anh;palUyEOVnM;hgc)*cnOKe%#dFS$BAwB22+lKFZ7* zCL4<=G}&ApPIZ_7ydffx5BkO}avQekFReT(QQ>4Q{eN5c6d%6W*$Ki|K^a*kb!&Uc zrR?ags=xpkZ}fu@VzlxpZo5h|qtb{XIu@k_0Czpg@k*AZxVtm@lPfG(JZk9{5n)Zq zvu6|toamd5Nnt?Y?9l8t6}9O(=i{$Z0PZHUSYfZUDSn5N^J;7Wf{XgN2kB9`&hK zrCxXwuVXc{W3{4ZV*R@fKUto^3wku``SHOk%d8?8<_T(U<`pG-?T{tis)`gg34MEX z^wO!P=jBw;(qr!P7jCD4Q>USze8x+gFDz%F$M{Tc=liUu?I`ti1AudDe4uucjM>&9 z4hCSURQ-?Z{WJR`7wn5b)^)HLs1?)anfwV{(~yZDb};SW?(o6))S<#F&>J0Cy{e@> z5QgV#HA-s_0>RUwl+45UM&-<9$Ruu6<+T?T@Um!<=)pr-V%pL<(EA>;NI5`}TlwAA zCjvH3On=`+u^4UJc4QT>!Od3?yL-hm{ftJA92E z0I@QA`~3^b(zg)cn%kHbpBh<8uZEqy`MD_m=5AL6-0jR)9Z+LBc;WqCrdhW2xH>SG z2BeRDM~kOM)UpV(|9h!qO#{TvVxipB^&@e;sLN@r6DX`q(1@D}tVwB?#EWK<_VQHB zF$}Gh)87-KlzG#&&^@`NL?hxcqMN+J=kiv#b_I)VldvR((tzT+`>yUyCHo~8?i-17 zMaOZL>}8%KUt#nLHCiE&GMTLKg4_(d?#wroS{Hu$U0jj7rl|o}a}&8u1-;`McOuoN z|K=(q_KkK82NQFDDaf!ghmP}U<5RK=BVqaaIh0Jo2@onrQt5EHj)q-3GKsvvxq?LT z?F`MW_BMJA$~SE&iP%RBRdsJsS|@RP&0OOtqXPO4+9JrS0Z(s!gWKjf{u^(9`fS#D zQG?OLtfa;zj_B*`z?6DKBM?z~HOy_a5RNv4nf)d-Ch`gooG93)bJCQ})?=|R&Nx{0;$D)*!s7{(lpz7eR+ZD!FoEsTJMtE)to|}83YNZq zALM|XXO+^}M`&I?+R$XfDgKv~0mY;|G&eNpwNpNXD)km!H5PmeBZPT5H%0A`Ct$^7KF`!fKXPSF4umcrz2TMsk9 z-cJq2_dctOg)4sE|TZX&mOH{!^CfuX^Mv~QM+>GA;4Di$v;H;=XY7lMhKvtw5nmj+i zCZmr3N%|gFR}^!n-k)eM#z{c3CPd~Zg-(_rWaID?#iY$o`rqqOCn-&$A$3z^IrTPB z)$;=~wKg`%wrP}ndW-D@?#SdDym=#LxOYZi)^zvFSoG+`d(;N@ z%Hz}E$^yK~U|$6l2s8rGCO$1!N~|KDZ!0GEwNf)2_r8|EpGy%A!%a4V1#tltu$cq< zy$KcA2={%X{=2M>Ue3p?_~ED@GQs}{Iy*k?7P$}*Kr6Fs(dX+^u;L9+|d|N0)EXD39c=N zHsI0D4^F?YO#;gQcwBfxVy^=2Lkq+b}@2`R_<(Lp3`ce;= zjFIo56)PZ=Y=2$J=-Mk{0jVP+;R>&RK)6HQgnCQtR%cXle-zF9?mkfoJ%tL=AN4t6 z0>7XDva9VCrvP0fw3m|)mV-K*eqKwiDL2wbr*hb|8qltI3F$qb!`zqqHtuUFpEkjr zSimRd4g&SjQ?@YkH|h%>Jao&XCRo6Z|H3H*O;(qBA|t4PhKcS?pYyIeAV?W-0b|w3 GHTe=?sJWj2 literal 0 HcmV?d00001 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..5fa6869ae 100644 --- a/src/LiveViews.ts +++ b/src/LiveViews.ts @@ -1293,7 +1293,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 +1419,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 6aa2632f7..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 @@ -433,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( @@ -501,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); @@ -531,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. @@ -548,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; @@ -577,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), @@ -775,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(); @@ -1790,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 @@ -1823,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); } /** @@ -1856,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() ?? []; @@ -2412,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; @@ -2645,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) { @@ -2657,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); @@ -2694,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; @@ -2735,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, @@ -2746,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; @@ -2818,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 }); })(); @@ -2869,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); } @@ -2919,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. @@ -3161,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); @@ -3175,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) { @@ -3195,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); @@ -3224,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); @@ -3267,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, @@ -3352,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); @@ -3445,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 { @@ -3484,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); } @@ -3640,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); @@ -3669,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); } @@ -3947,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); @@ -3961,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); @@ -3971,7 +4468,7 @@ export class SharedFolder extends HasProvider { this.files.delete(docGuid); doc.cleanup(); doc.destroy(); - cleanupGuids.add(docGuid); + cleanupGuids.set(docGuid, vpath); } } } @@ -3983,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); } } @@ -3997,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(); } } @@ -4025,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) { @@ -4185,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; @@ -4285,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(); @@ -4388,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/folder-hsm/FolderHSM.ts b/src/folder-hsm/FolderHSM.ts index bece54543..4243df45b 100644 --- a/src/folder-hsm/FolderHSM.ts +++ b/src/folder-hsm/FolderHSM.ts @@ -1,66 +1,151 @@ /** * FolderHSM * - * One machine per shared folder, driving event-based membership - * reconciliation. Membership entries live in - * the machine's context as a guid-keyed table of dispositions — not as - * machines. The declarative FOLDER_MACHINE definition is interpreted by - * the merge-hsm interpreter; guards, actions, and effect emission are - * bound per instance here. + * One engine instance per shared folder: one folder machine, one event + * queue, one entry table. The folder machine (FOLDER_MACHINE) is + * interpreted by the shared merge-hsm interpreter; per-file decisions + * live in the ENTRY_MACHINE's keyed row table, ticked synchronously from + * the folder machine's actions inside the single event queue. * * Effects are executed by the host (SharedFolder): trash via Obsidian's * trash, uploads/downloads via the existing BackgroundSync paths, map - * mutations via SyncStore. + * mutations via SyncStore. The host reports durable acceptance with + * WORK_STARTED and completions only for work actually done. + * + * Safety structure: + * - the emit chokepoint refuses (throws) any effect whose capability the + * current folder posture does not grant, any destructive dispatch at + * blind confidence, and any publishing/index write under read-only + * authorization; + * - entry candidates declare the capabilities their effects require; the + * cross-product of entry candidate and folder posture is checked at + * emit (the two-level check); + * - every undeclared (state x event) cell resolves through the node's + * `otherwise` policy — absorb, refuse, or reclassify — never silence. */ import { processEvent } from "../merge-hsm/machine-interpreter"; import type { ActiveInvoke } from "../merge-hsm/types"; import { curryLog } from "../debug"; import { FOLDER_MACHINE } from "./machine-definition"; +import { ENTRY_MACHINE } from "./entry-machine"; import type { + ConfidenceTier, Disposition, + EntryCandidate, + EntryEvent, + EntryEventHandler, + EntryRefusal, + EntryRow, + EntryStatePath, + EntryTarget, FileOrigin, - LocalFileKind, + FolderCapabilityName, FolderContext, FolderEffect, FolderEvent, FolderHSMConfig, + FolderInvariantViolation, + FolderSerializableSnapshot, FolderStatePath, FolderSyncSnapshot, - MapDeltaAdd, + LocalFileKind, MapEntrySummary, MembershipEntry, } from "./types"; -/** Capability each effect type requires from the current state's node. */ +/** Capability each effect type requires from the current posture. */ const EFFECT_CAPABILITY: Record< - Exclude, - "canDownload" | "canTrash" | "canRenameLocal" | "canMutateMap" | "canPark" | "canEmitEffects" + Exclude, + FolderCapabilityName > = { ENQUEUE_DOWNLOAD: "canDownload", TRASH_LOCAL: "canTrash", RENAME_LOCAL: "canRenameLocal", MAP_SET: "canMutateMap", MAP_DELETE: "canMutateMap", + RETRACT_UPLOAD: "canEmitEffects", PARK: "canPark", SURFACE_STATUS: "canEmitEffects", }; -const PARK_REASON = "bootstrap-discovered file at a tombstoned path"; +/** Effects that destroy local content or publish to the group. */ +const CONFIRMED_ONLY_EFFECTS = new Set([ + "TRASH_LOCAL", + "RENAME_LOCAL", + "ENQUEUE_UPLOAD", +]); + +/** Effects that originate writes (refused under read-only authorization). */ +const WRITE_EFFECTS = new Set([ + "ENQUEUE_UPLOAD", + "MAP_SET", + "MAP_DELETE", +]); + +/** Entry states whose CLASSIFY cell a classification pass visits. */ +const CLASSIFY_STATES: ReadonlySet = new Set([ + "unclassified", + "upload.held", + "upload.inFlight", + "download.pending", + "download.inFlight", +]); + +const PARK_REASON_TOMBSTONE = + "file found at a path the group previously deleted"; +const PARK_REASON_READ_ONLY = "publication requires write access"; +const DROP_REASON = "outbound deletion dropped: its target changed"; +const CONFLICT_REASON = + "remote content asserted at a refused path holding local content"; + +const DISPOSITION_BY_STATE: Record = { + unclassified: "pendingUpload", // provisional; see snapshotDisposition + synced: "synced", + "upload.held": "pendingUpload", + "upload.inFlight": "pendingUpload", + "download.pending": "pendingDownload", + "download.inFlight": "pendingDownload", + trashing: "pendingTrash", + renaming: "pendingRename", + "delete.pending": "pendingMapDelete", + "delete.held": "pendingMapDelete", + parked: "parked", + conflicted: "conflicted", +}; function freshContext(): FolderContext { return { persistenceLoaded: false, + tier: "none", providerSynced: false, isOnline: false, - entries: new Map(), - entryKeyByPath: new Map(), + authorization: "write", + rows: new Map(), + rowKeyByPath: new Map(), localFiles: new Map(), - locallyDeleted: new Set(), - parked: new Map(), + recordedDeleteIntents: new Set(), + classificationDeferred: false, + revision: 0, }; } +function isRefusal(handler: EntryEventHandler): handler is EntryRefusal { + return ( + typeof handler === "object" && + !Array.isArray(handler) && + "refuse" in handler + ); +} + +function normalizeEntryCandidates( + handler: Exclude, +): EntryCandidate[] { + if (typeof handler === "string") return [{ target: handler }]; + if (Array.isArray(handler)) return handler; + return [handler]; +} + export class FolderHSM { readonly context: FolderContext; private _statePath: FolderStatePath = "loading"; @@ -68,11 +153,22 @@ export class FolderHSM { private _processing = false; private _queue: FolderEvent[] = []; private _currentEventType = ""; + private _classifyQueue = new Set(); + private _surfaceDirty = false; + private _lastPersistedRevision = 0; private interpreterConfig: { guards: Record boolean>; actions: Record void>; invokeSources: Record; }; + private entryGuards: Record< + string, + (row: EntryRow, event: EntryEvent) => boolean + >; + private entryActions: Record< + string, + (row: EntryRow, event: EntryEvent) => void + >; private warn = curryLog("[FolderHSM]", "warn"); constructor(private config: FolderHSMConfig) { @@ -81,27 +177,48 @@ export class FolderHSM { guards: { persistenceLoaded: () => this.context.persistenceLoaded, hydrated: () => - this.context.persistenceLoaded && this.context.providerSynced, + this.context.persistenceLoaded && this.context.tier !== "none", reconnectPending: () => !this.context.providerSynced, + tierWasBlind: (_hsm, event) => + this.context.tier === "blind" && + event.type === "PROVIDER_SYNCED" && + (event.tier ?? "confirmed") === "confirmed", + classificationDeferred: () => this.context.classificationDeferred, + authorizationExpanded: (_hsm, event) => + event.type === "AUTHORIZATION_CHANGED" && + event.scope === "write" && + this.context.authorization !== "write", }, actions: { resetContext: () => this.resetContext(), markPersistenceLoaded: () => { this.context.persistenceLoaded = true; + this.bump(); + }, + recordTier: (_hsm, event) => { + if (event.type !== "PROVIDER_SYNCED") return; + this.recordTier(event.tier ?? "confirmed"); }, - markProviderSynced: () => { - this.context.providerSynced = true; + recordAuthorization: (_hsm, event) => { + if (event.type !== "AUTHORIZATION_CHANGED") return; + if (this.context.authorization !== event.scope) { + this.context.authorization = event.scope; + this.bump(); + } }, + revisitGatedRows: () => this.revisitGatedRows(), setOnline: () => { this.context.isOnline = true; }, setOffline: () => { this.context.isOnline = false; + // The session's sync claim dies with the transport; the + // next completed exchange re-enters classification. this.context.providerSynced = false; }, absorbMapDelta: () => { - // The map itself is the durable record; reconciling re-reads - // it via listMapEntries. Nothing to classify before hydration. + // The map itself is the durable record; classification + // re-reads it. Nothing to decide before hydration. }, absorbDiscoveredFile: (_hsm, event) => { if (event.type !== "FILE_DISCOVERED") return; @@ -110,60 +227,94 @@ export class FolderHSM { absorbInteractiveCreate: (_hsm, event) => { if (event.type !== "FILE_CREATED") return; this.rememberLocalFile(event.path, "interactive", event.kind); - this.context.locallyDeleted.delete(event.path); + this.context.recordedDeleteIntents.delete(event.path); }, absorbLocalDelete: (_hsm, event) => { if (event.type !== "FILE_DELETED") return; this.context.localFiles.delete(event.path); - this.context.locallyDeleted.add(event.path); + this.context.recordedDeleteIntents.add(event.path); + this.bump(); }, absorbLocalRename: (_hsm, event) => { if (event.type !== "FILE_RENAMED") return; this.rekeyLocalFile(event.from, event.to); }, - runProvenanceLadder: () => this.runProvenanceLadder(), - applyMapDelta: (_hsm, event) => { + classifyUnclassifiedRows: () => this.runClassification(), + routeDeltaToRows: (_hsm, event) => { if (event.type !== "MAP_DELTA") return; - this.applyMapDelta(event); + this.routeDelta(event); }, - trackDiscoveredFile: (_hsm, event) => { + routeFileDiscovered: (_hsm, event) => { if (event.type !== "FILE_DISCOVERED") return; - this.trackDiscoveredFile(event.path, event.origin, event.kind); + this.routeFileDiscovered(event.path, event.origin, event.kind); }, - handleInteractiveCreate: (_hsm, event) => { + routeFileCreated: (_hsm, event) => { if (event.type !== "FILE_CREATED") return; - this.handleInteractiveCreate(event.path, event.kind); + this.routeFileCreated(event.path, event.kind); + }, + routeFileModified: (_hsm, event) => { + if (event.type !== "FILE_MODIFIED") return; + const row = this.rowAtPath(event.path); + if (row) this.tickRow(row, event); }, - handleLocalDelete: (_hsm, event) => { + routeFileDeleted: (_hsm, event) => { if (event.type !== "FILE_DELETED") return; - this.handleLocalDelete(event.path); + this.routeFileDeleted(event.path); }, - handleLocalRename: (_hsm, event) => { + routeFileRenamed: (_hsm, event) => { if (event.type !== "FILE_RENAMED") return; - this.handleLocalRename(event.from, event.to); + this.routeFileRenamed(event.from, event.to); }, - settleUpload: (_hsm, event) => { - if (event.type !== "UPLOAD_COMPLETE") return; - this.upsertEntry(event.guid, event.path, "synced"); - this.rememberLocalFile(event.path, "bootstrap"); + routeAckToRow: (_hsm, event) => { + if (event.type !== "WORK_STARTED") return; + const row = this.rowAtPath(event.path); + if (row) this.tickRow(row, event); }, - settleDownload: (_hsm, event) => { - if (event.type !== "DOWNLOAD_COMPLETE") return; - this.upsertEntry(event.guid, event.path, "synced"); - this.rememberLocalFile(event.path, "bootstrap"); + routeCompletionToRow: (_hsm, event) => { + if ( + event.type !== "UPLOAD_COMPLETE" && + event.type !== "UPLOAD_FAILED" && + event.type !== "DOWNLOAD_COMPLETE" && + event.type !== "DOWNLOAD_FAILED" && + event.type !== "TRASH_COMPLETE" + ) + return; + if (event.type === "TRASH_COMPLETE") { + // Trash completion is the report that the file left + // the disk; cascade echoes are suppressed host-side, + // so the local-tree evidence updates here. + this.context.localFiles.delete(event.path); + this.bump(); + } + const row = this.rowAtPath(event.path); + if (row) this.tickRow(row, event); }, - settleDownloadFailure: (_hsm, event) => { - if (event.type !== "DOWNLOAD_FAILED") return; - this.settleDownloadFailure(event.path, event.guid); + routePolicyOutcomeToRows: (_hsm, event) => { + if ( + event.type !== "DELETE_HELD" && + event.type !== "DELETE_REPLICATED" && + event.type !== "DELETE_RESTORED" + ) + return; + for (const path of event.paths) { + const row = this.rowAtPath(path); + if (row) this.tickRow(row, event); + } }, - settleTrash: (_hsm, event) => { - if (event.type !== "TRASH_COMPLETE") return; - this.removeEntryAtPath(event.path); - this.context.localFiles.delete(event.path); + routeUserActionToRow: (_hsm, event) => { + if ( + event.type !== "UNPARK_REQUESTED" && + event.type !== "RESOLVE_CONFLICT" + ) + return; + const row = this.rowAtPath(event.path); + if (row) this.tickRow(row, event); }, }, invokeSources: {}, }; + this.entryGuards = this.buildEntryGuards(); + this.entryActions = this.buildEntryActions(); } // ========================================================================= @@ -204,6 +355,7 @@ export class FolderHSM { } finally { this._processing = false; } + this.flushPersist(); } private dispatch(event: FolderEvent): void { @@ -216,6 +368,8 @@ export class FolderHSM { FOLDER_MACHINE as never, this.interpreterConfig as never, ); + this.drainScheduledClassifies(); + this.flushSurfaceStatus(); } // ========================================================================= @@ -223,86 +377,183 @@ export class FolderHSM { // ========================================================================= /** - * Whether the engine currently knows `path` as a local file. The host's - * origin discriminator uses this to keep re-observations of already - * scanned paths from laundering into interactive intent. + * Whether the engine currently knows `path` as a local file. The + * host's origin discriminator uses this to keep re-observations of + * already scanned paths from laundering into interactive intent. */ hasLocalFile(path: string): boolean { return this.context.localFiles.has(path); } + /** The entry-machine state of the row at `path`, if any. */ + getRowState(path: string): EntryStatePath | undefined { + return this.rowAtPath(path)?.state; + } + + /** + * Whether the host's upload plumbing may publish `path` on its own + * retry paths. Only rows the machine holds in an upload state (or no + * row at all — legacy work predating the machine) are publishable; a + * parked, conflicted, or condemned row's hold must never flush. + */ + holdIsPublishable(path: string): boolean { + const row = this.rowAtPath(path); + if (!row) return true; + return row.state === "upload.held" || row.state === "upload.inFlight"; + } + getSnapshot(): FolderSyncSnapshot { + const entries: MembershipEntry[] = []; + const parked: Array<{ path: string; reason: string }> = []; + const conflicted: Array<{ path: string; reason: string }> = []; + for (const row of this.context.rows.values()) { + entries.push({ + guid: row.guid, + path: row.path, + disposition: this.snapshotDisposition(row), + }); + if (row.state === "parked") { + parked.push({ path: row.path, reason: row.reason ?? "" }); + } else if (row.state === "conflicted") { + conflicted.push({ path: row.path, reason: row.reason ?? "" }); + } + } return { statePath: this._statePath, hydrated: - this.context.persistenceLoaded && this.context.providerSynced, + this.context.persistenceLoaded && this.context.tier !== "none", isOnline: this.context.isOnline, - entries: Array.from(this.context.entries.values()).map((entry) => ({ - ...entry, + tier: this.context.tier, + entries, + parked, + conflicted, + }; + } + + getSerializableSnapshot(): FolderSerializableSnapshot { + return { + statePath: this._statePath, + revision: this.context.revision, + context: { + persistenceLoaded: this.context.persistenceLoaded, + tier: this.context.tier, + providerSynced: this.context.providerSynced, + isOnline: this.context.isOnline, + authorization: this.context.authorization, + classificationDeferred: this.context.classificationDeferred, + localFiles: Array.from(this.context.localFiles.entries()).map( + ([path, info]) => ({ path, ...info }), + ), + recordedDeleteIntents: Array.from( + this.context.recordedDeleteIntents, + ), + }, + rows: Array.from(this.context.rows.values()).map((row) => ({ + ...row, + observedIdentity: row.observedIdentity + ? { ...row.observedIdentity } + : undefined, })), - parked: Array.from(this.context.parked.entries()).map( - ([path, reason]) => ({ path, reason }), - ), }; } + /** + * Ask for a PERSIST_STATE emission (the host's durable write path for + * the approved fork-class subset). Coalesced with the revision + * stream; a request from a posture that grants no effects flushes on + * the next effect-granting posture. + */ + requestPersist(): void { + this.bump(); + if (!this._processing) this.flushPersist(); + } + + private snapshotDisposition(row: EntryRow): Disposition { + if (row.state === "unclassified") { + // Undecided rows project by their dominant evidence so status + // surfaces stay readable: mapped paths read as downloads, + // unmapped local files as uploads. + return this.getMapEntry(row.path) + ? this.context.localFiles.has(row.path) + ? "synced" + : "pendingDownload" + : "pendingUpload"; + } + return DISPOSITION_BY_STATE[row.state]; + } + // ========================================================================= - // Effect emission — capability-checked against the machine definition + // Effect emission — the capability-checked chokepoint // ========================================================================= private stateCapabilities() { return FOLDER_MACHINE[this._statePath]?.capabilities ?? {}; } - private emit(effect: FolderEffect): void { + /** Whether the current posture grants `capability` (the dispatch gate). */ + private may(capability: FolderCapabilityName): boolean { const capabilities = this.stateCapabilities(); - if (!capabilities.canEmitEffects) { - throw new Error( - `FolderHSM invariant violation: ${effect.type} emitted from ${this._statePath}, which grants no effects`, - ); - } - const required = - effect.type === "ENQUEUE_UPLOAD" - ? effect.origin === "interactive" - ? "canUploadInteractive" - : "canUploadBootstrap" - : EFFECT_CAPABILITY[effect.type]; - if (!capabilities[required]) { - throw new Error( - `FolderHSM invariant violation: ${effect.type} requires ${required} which ${this._statePath} does not grant`, - ); - } - this.config.onEffect(effect); + return Boolean(capabilities.canEmitEffects && capabilities[capability]); } - // ========================================================================= - // Per-item predicates (guards applied per membership entry) - // ========================================================================= - - private hasLocalRecord(path: string): boolean { - return this.config.getLocalRecordGuid(path) !== undefined; + private emit(effect: FolderEffect): void { + if (effect.type !== "PERSIST_STATE") { + const capabilities = this.stateCapabilities(); + if (!capabilities.canEmitEffects) { + throw new Error( + `FolderHSM invariant violation: ${effect.type} emitted from ${this._statePath}, which grants no effects`, + ); + } + const required = + effect.type === "ENQUEUE_UPLOAD" + ? effect.origin === "interactive" + ? "canUploadInteractive" + : "canUploadBootstrap" + : EFFECT_CAPABILITY[effect.type]; + if (!capabilities[required]) { + throw new Error( + `FolderHSM invariant violation: ${effect.type} requires ${required} which ${this._statePath} does not grant`, + ); + } + if ( + CONFIRMED_ONLY_EFFECTS.has(effect.type) && + this.context.tier !== "confirmed" + ) { + throw new Error( + `FolderHSM invariant violation: ${effect.type} dispatched at ${this.context.tier} confidence`, + ); + } + if ( + WRITE_EFFECTS.has(effect.type) && + this.context.authorization !== "write" + ) { + throw new Error( + `FolderHSM invariant violation: ${effect.type} dispatched under read-only authorization`, + ); + } + } + this.config.onEffect(effect); } - private guidMatchesLocalRecord(path: string, guid: string): boolean { - const entryKey = this.context.entryKeyByPath.get(path); - const entry = entryKey ? this.context.entries.get(entryKey) : undefined; - if ( - entry && - entry.guid === guid && - entry.path === path && - entry.disposition === "synced" - ) { - return true; - } - return this.config.getLocalRecordGuid(path) === guid; + private flushPersist(): void { + if (this.context.revision === this._lastPersistedRevision) return; + if (!this.stateCapabilities().canEmitEffects) return; + this._lastPersistedRevision = this.context.revision; + this.emit({ + type: "PERSIST_STATE", + snapshot: this.getSerializableSnapshot(), + }); } - private pathTombstoned(path: string): boolean { - return this.config.pathTombstoned(path); + private flushSurfaceStatus(): void { + if (!this._surfaceDirty) return; + if (!this.stateCapabilities().canEmitEffects) return; + this._surfaceDirty = false; + this.emit({ type: "SURFACE_STATUS" }); } - private originInteractive(path: string): boolean { - return this.context.localFiles.get(path)?.origin === "interactive"; + private bump(): void { + this.context.revision++; } // ========================================================================= @@ -312,6 +563,8 @@ export class FolderHSM { private resetContext(): void { const fresh = freshContext(); Object.assign(this.context, fresh); + this._classifyQueue.clear(); + this._surfaceDirty = false; } private rememberLocalFile( @@ -333,6 +586,18 @@ export class FolderHSM { return; // no change — keep sweeps quiet } this.context.localFiles.set(path, { origin: nextOrigin, kind: nextKind }); + this.bump(); + } + + private rekeyLocalFile(from: string, to: string): void { + const info = this.context.localFiles.get(from); + this.context.localFiles.delete(from); + this.context.localFiles.set( + to, + info ?? { origin: "interactive", kind: "file" }, + ); + this.context.recordedDeleteIntents.delete(to); + this.bump(); } private hasLocalChildren(path: string): boolean { @@ -343,393 +608,1060 @@ export class FolderHSM { return false; } - private rekeyLocalFile(from: string, to: string): void { - const info = this.context.localFiles.get(from); - this.context.localFiles.delete(from); - this.context.localFiles.set( - to, - info ?? { origin: "interactive", kind: "file" }, - ); - this.context.locallyDeleted.delete(to); + private getMapEntry(path: string): MapEntrySummary | undefined { + if (this.config.getMapEntry) return this.config.getMapEntry(path); + return this.config.listMapEntries().find((entry) => entry.path === path); + } + + private recordTier(claim: "blind" | "confirmed"): void { + if (claim === "blind") { + // A persisted marker can declare sync but never confirm it: it + // upgrades nothing once a live exchange has completed. + if (this.context.tier === "none") { + this.context.tier = "blind"; + this.bump(); + } + } else if (this.context.tier !== "confirmed") { + this.context.tier = "confirmed"; + this.bump(); + } + this.context.providerSynced = true; } - private entryKeyFor(guid: string | null, path: string): string { + // ========================================================================= + // The entry table + // ========================================================================= + + private rowKeyFor(guid: string | null, path: string): string { return guid ?? `path:${path}`; } - private upsertEntry( - guid: string | null, + private rowAtPath(path: string): EntryRow | undefined { + const key = this.context.rowKeyByPath.get(path); + return key !== undefined ? this.context.rows.get(key) : undefined; + } + + private rowByGuid(guid: string): EntryRow | undefined { + return this.context.rows.get(guid); + } + + private createRow( path: string, - disposition: Disposition, - ): MembershipEntry { - // Displace any existing entry for this path (a provisional guid-less - // entry being re-keyed after a guid was minted, or a stale mapping). - const previousKey = this.context.entryKeyByPath.get(path); + origin: FileOrigin, + kind: LocalFileKind, + guid: string | null = null, + ): EntryRow { + // Displace any existing row for this path (a provisional guid-less + // row being re-keyed after a mint, or a stale mapping). + const previousKey = this.context.rowKeyByPath.get(path); if (previousKey !== undefined) { - this.context.entries.delete(previousKey); - this.context.entryKeyByPath.delete(path); + this.context.rows.delete(previousKey); + this.context.rowKeyByPath.delete(path); } - const entry: MembershipEntry = { guid, path, disposition }; - const key = this.entryKeyFor(guid, path); - this.context.entries.set(key, entry); - this.context.entryKeyByPath.set(path, key); - return entry; + const row: EntryRow = { + key: this.rowKeyFor(guid, path), + path, + state: "unclassified", + guid, + origin, + kind, + decidedTier: this.context.tier, + dispatched: false, + contentAgreement: "unknown", + }; + this.context.rows.set(row.key, row); + this.context.rowKeyByPath.set(path, row.key); + this.bump(); + return row; } - private entryByGuid(guid: string): MembershipEntry | undefined { - return this.context.entries.get(guid); + /** Create an unclassified row for a path from the context's evidence. */ + private seedRow(path: string, guid: string | null = null): EntryRow { + const info = this.context.localFiles.get(path); + return this.createRow( + path, + info?.origin ?? "bootstrap", + info?.kind ?? "file", + guid, + ); } - private entryAtPath(path: string): MembershipEntry | undefined { - const key = this.context.entryKeyByPath.get(path); - return key !== undefined ? this.context.entries.get(key) : undefined; + private rekeyRowPath(row: EntryRow, to: string): void { + if (this.context.rowKeyByPath.get(row.path) === row.key) { + this.context.rowKeyByPath.delete(row.path); + } + row.path = to; + this.context.rowKeyByPath.set(to, row.key); + this.bump(); } - private removeEntryAtPath(path: string): void { - const key = this.context.entryKeyByPath.get(path); - if (key === undefined) return; - this.context.entries.delete(key); - this.context.entryKeyByPath.delete(path); + private rekeyRowGuid(row: EntryRow, guid: string): void { + this.context.rows.delete(row.key); + row.guid = guid; + row.key = this.rowKeyFor(guid, row.path); + this.context.rows.set(row.key, row); + this.context.rowKeyByPath.set(row.path, row.key); + this.bump(); } - private removeEntryByGuid(guid: string): void { - const entry = this.context.entries.get(guid); - if (!entry) return; - this.context.entries.delete(guid); - if (this.context.entryKeyByPath.get(entry.path) === guid) { - this.context.entryKeyByPath.delete(entry.path); + private retireRow(row: EntryRow): void { + this.context.rows.delete(row.key); + if (this.context.rowKeyByPath.get(row.path) === row.key) { + this.context.rowKeyByPath.delete(row.path); + } + this._classifyQueue.delete(row.key); + // A record must never outlive the file it described. + this.config.records.retireRecord(row.path); + if (this.config.records.getRecordGuid(row.path) !== undefined) { + const violation: FolderInvariantViolation = { + id: "record-dies-with-row", + severity: "error", + message: `a local record survived the retirement of ${row.path}`, + statePath: this._statePath, + path: row.path, + }; + if (this.config.onInvariantViolation) { + this.config.onInvariantViolation(violation); + } else { + this.warn(violation.message); + } + } + this.bump(); + // Declared convergence window: if the map re-asserted the path + // while this row was condemned, the re-add classifies afresh. + if ( + !this.context.localFiles.has(row.path) && + this.getMapEntry(row.path) !== undefined + ) { + const seeded = this.seedRow( + row.path, + this.getMapEntry(row.path)?.guid ?? null, + ); + this.scheduleClassifyRow(seeded); } } - private movePath(entry: MembershipEntry, to: string, disposition: Disposition): void { - const key = this.entryKeyFor(entry.guid, entry.path); - this.context.entryKeyByPath.delete(entry.path); - entry.path = to; - entry.disposition = disposition; - const newKey = this.entryKeyFor(entry.guid, to); - if (newKey !== key) { - this.context.entries.delete(key); - this.context.entries.set(newKey, entry); + // ========================================================================= + // The row tick (the entry-machine executor) + // ========================================================================= + + private tickRow(row: EntryRow, event: EntryEvent): void { + const node = ENTRY_MACHINE[row.state]; + const handler = node.on[event.type]; + if (handler === undefined) { + this.applyOtherwise(row, event); + return; + } + if (isRefusal(handler)) { + this.refuse(row, event); + return; + } + const candidates = normalizeEntryCandidates(handler); + for (const candidate of candidates) { + if (candidate.guard) { + const guard = this.entryGuards[candidate.guard]; + if (!guard) { + this.warn(`unknown entry guard: ${candidate.guard}`); + continue; + } + if (!guard(row, event)) continue; + } + this.executeEntryTransition(row, candidate, event); + return; + } + // Declared cell, no passing guard: consumed without transition — + // but never silently for an explicit user action. A resolution or + // unpark whose evidence guards refuse (blind tier above all) is + // reported like a refusal so the drop is visible. + if ( + event.type === "RESOLVE_CONFLICT" || + event.type === "UNPARK_REQUESTED" + ) { + this.refuse(row, event); } - this.context.entryKeyByPath.set(to, newKey); } - private getMapEntry(path: string): MapEntrySummary | undefined { - if (this.config.getMapEntry) return this.config.getMapEntry(path); - return this.config.listMapEntries().find((entry) => entry.path === path); + private executeEntryTransition( + row: EntryRow, + candidate: EntryCandidate, + event: EntryEvent, + ): void { + for (const name of candidate.actions ?? []) { + const action = this.entryActions[name]; + if (!action) { + this.warn(`unknown entry action: ${name}`); + continue; + } + action(row, event); + } + const target: EntryTarget = candidate.target; + if (target === "retired") { + this.retireRow(row); + return; + } + if (target === row.state) { + return; // internal transition: no re-entry + } + row.state = target; + row.decidedTier = this.context.tier; + row.dispatched = false; + this.bump(); + for (const name of ENTRY_MACHINE[target].entry ?? []) { + const action = this.entryActions[name]; + if (!action) { + this.warn(`unknown entry action: ${name}`); + continue; + } + action(row, event); + } } - // ========================================================================= - // The bootstrap provenance ladder (reconciling's transition logic) - // ========================================================================= + private applyOtherwise(row: EntryRow, event: EntryEvent): void { + const policy = ENTRY_MACHINE[row.state].otherwise; + if (policy === "refuse") { + this.refuse(row, event); + return; + } + if (policy === "reclassify") { + row.state = "unclassified"; + row.decidedTier = this.context.tier; + row.dispatched = false; + this.bump(); + this.scheduleClassifyRow(row); + return; + } + // absorb: record the event as evidence — no state change, no effect. + this.absorbIntoRow(row, event); + } - private runProvenanceLadder(): void { - const ctx = this.context; - const mapEntries = this.config.listMapEntries(); - const mapByPath = new Map(mapEntries.map((entry) => [entry.path, entry])); - const mapByGuid = new Map(mapEntries.map((entry) => [entry.guid, entry])); + private absorbIntoRow(row: EntryRow, event: EntryEvent): void { + switch (event.type) { + case "FILE_MODIFIED": + if (row.contentAgreement !== "stale") { + row.contentAgreement = "stale"; + this.bump(); + } + return; + case "FILE_DISCOVERED": + if (event.kind && event.kind !== row.kind) { + row.kind = event.kind; + this.bump(); + } + return; + default: + return; + } + } - ctx.entries.clear(); - ctx.entryKeyByPath.clear(); - ctx.parked.clear(); - let parkedCount = 0; + private refuse(row: EntryRow, event: EntryEvent): void { + const violation: FolderInvariantViolation = { + id: "entry-event-refused", + severity: "warning", + message: `${event.type} refused in ${row.state} for ${row.path}: no outstanding work matches it`, + statePath: this._statePath, + entryState: row.state, + path: row.path, + }; + if (this.config.onInvariantViolation) { + this.config.onInvariantViolation(violation); + } else { + this.warn(violation.message); + } + } - // Local files with no current map entry walk the ladder in order. - for (const [path, info] of Array.from(ctx.localFiles)) { - const mapEntry = mapByPath.get(path); - if (mapEntry) { - this.upsertEntry(mapEntry.guid, path, "synced"); - continue; - } + private scheduleClassifyRow(row: EntryRow): void { + this._classifyQueue.add(row.key); + } - // Rung 1: ours, awaiting → upload. - const pendingGuid = this.config.getPendingUploadGuid(path); - if (pendingGuid !== undefined) { - this.upsertEntry(pendingGuid, path, "pendingUpload"); - this.emit({ type: "ENQUEUE_UPLOAD", path, origin: info.origin }); - continue; + private drainScheduledClassifies(): void { + if (this._classifyQueue.size === 0) return; + if (this._statePath !== "tracking" && this._statePath !== "reconciling") { + return; // reconciling's entry pass visits them anyway + } + // The trust gate binds scheduled visits too: no verdict reads an + // untrustworthy map. The queue drops — a deferred pass re-visits + // every undecided row when the drain re-arms it. + if (this.config.hasPendingSyncState?.()) { + if (!this.context.classificationDeferred) { + this.context.classificationDeferred = true; + this.bump(); } + this._classifyQueue.clear(); + return; + } + // A tick may schedule further visits; loop until quiet, bounded. + for (let i = 0; i < 10 && this._classifyQueue.size > 0; i++) { + const keys = Array.from(this._classifyQueue); + this._classifyQueue.clear(); + for (const key of keys) { + const row = this.context.rows.get(key); + if (!row) continue; + this.classifyVisit(row); + } + } + if (this._classifyQueue.size > 0) { + this.warn("scheduled classification did not settle; deferring"); + } + } - // Interactive creation expresses user intent to (re)share the - // path — it always uploads, tombstone or stale record be damned - // (parking applies only to bootstrap origin; a stale record belongs - // to the previous file, not to content the user just created). - if (info.origin === "interactive") { - this.emit({ type: "ENQUEUE_UPLOAD", path, origin: "interactive" }); - this.upsertEntry( - this.config.getPendingUploadGuid(path) ?? null, - path, - "pendingUpload", - ); - continue; + /** One classification visit for one row, with the row-hygiene pre-checks. */ + private classifyVisit(row: EntryRow): void { + if (row.state === "unclassified") { + const hasFile = this.context.localFiles.has(row.path); + const hasEntry = this.getMapEntry(row.path) !== undefined; + const hasIntent = this.context.recordedDeleteIntents.has(row.path); + if (!hasFile && !hasEntry && !hasIntent) { + // Nothing on either side and no recorded intent: the row has + // no subject left to decide about. + this.retireRow(row); + return; } + } + this.tickRow(row, { type: "CLASSIFY" }); + } - // Rung 2: stale materialization of a previously synced file. - const recordGuid = this.config.getLocalRecordGuid(path); - if (recordGuid !== undefined) { - const guidElsewhere = mapByGuid.get(recordGuid); - if (guidElsewhere && guidElsewhere.path !== path) { - // Pure path move: rename, do not trash. - this.upsertEntry(recordGuid, guidElsewhere.path, "pendingRename"); - this.emit({ - type: "RENAME_LOCAL", - from: path, - to: guidElsewhere.path, - guid: recordGuid, - }); - } else { - this.upsertEntry(recordGuid, path, "pendingTrash"); - this.emit({ type: "TRASH_LOCAL", path, guid: recordGuid }); - } - continue; + // ========================================================================= + // Classification (the reconciling pass) + // ========================================================================= + + private runClassification(): void { + // Local-tree evidence rows exist regardless of the trust gate: a + // deferred file waits visibly as an unclassified row, not silently. + for (const path of this.context.localFiles.keys()) { + if (!this.rowAtPath(path)) this.seedRow(path); + } + + // The trust gate: never classify against a map with pending sync + // state — an undelivered deletion reads as a never-present key and + // would send a deleted path down the publication rung. The host + // reports the drain with SYNC_DRAINED and the pass re-runs. + if (this.config.hasPendingSyncState?.()) { + if (!this.context.classificationDeferred) { + this.context.classificationDeferred = true; + this.bump(); } + this.warn( + "classification deferred: folder doc holds pending sync state", + ); + return; + } + if (this.context.classificationDeferred) { + this.context.classificationDeferred = false; + this.bump(); + } - // Rung 3: previously deleted path, no proof of new content → park. - if (this.pathTombstoned(path)) { - // Folder-entry rule: an empty local directory at a tombstoned - // path holds no user content to protect — trash it so remote - // directory deletions converge instead of leaving parked husks - // (folders have no HSM/hash local record, so rung 2 can never - // re-trash them). A directory with unclassified children keeps - // its parking; the children walk the ladder on their own. - if (info.kind === "folder" && !this.hasLocalChildren(path)) { - this.upsertEntry(null, path, "pendingTrash"); - this.emit({ type: "TRASH_LOCAL", path, guid: null }); - continue; + // Decisions made at blind confidence are provisional: the session's + // first confirmed pass returns them to unclassified and revisits. + // Acknowledged in-flight work is adopted, never demoted. + if (this.context.tier === "confirmed") { + for (const row of Array.from(this.context.rows.values())) { + if ( + row.decidedTier === "blind" && + row.state !== "unclassified" && + row.state !== "upload.inFlight" && + row.state !== "download.inFlight" + ) { + row.state = "unclassified"; + row.decidedTier = this.context.tier; + row.dispatched = false; + this.bump(); } - ctx.parked.set(path, PARK_REASON); - this.upsertEntry(null, path, "parked"); - this.emit({ type: "PARK", path, reason: PARK_REASON }); - parkedCount += 1; - continue; } + } - // Rung 4: genuinely new content → upload. - this.emit({ type: "ENQUEUE_UPLOAD", path, origin: "bootstrap" }); - this.upsertEntry( - this.config.getPendingUploadGuid(path) ?? null, - path, - "pendingUpload", - ); + // Seed rows for map entries this replica has never held rows for. + // An identity already carried by some row is skipped: one authority + // per file — its row's own transitions (a rename decision above + // all) settle where the identity lives. + const mapEntries = this.config.listMapEntries(); + const mapByGuid = new Map(mapEntries.map((entry) => [entry.guid, entry])); + for (const entry of mapEntries) { + if (this.rowAtPath(entry.path)) continue; + // Rows carrying a non-null guid are always guid-keyed (every + // guid assignment goes through rekeyRowGuid), so this O(1) + // lookup is the whole identity check. + if (this.rowByGuid(entry.guid)) continue; + this.seedRow(entry.path, entry.guid); } - // Map entries with no local file: download — unless a pre-hydration - // interactive delete recorded the opposite intent. - for (const mapEntry of mapEntries) { - if (ctx.localFiles.has(mapEntry.path)) continue; - if (ctx.entryKeyByPath.has(mapEntry.path)) continue; // rename target - if (ctx.locallyDeleted.has(mapEntry.path)) { - this.upsertEntry(mapEntry.guid, mapEntry.path, "pendingMapDelete"); - this.emit({ - type: "MAP_DELETE", - path: mapEntry.path, - guid: mapEntry.guid, + // Re-derive verdicts for settled rows whose evidence moved while + // the machine could not act: a synced row whose identity the map + // no longer holds anywhere was remotely deleted (a real decision — + // absence alone never deletes; the identity is the association); + // one whose identity lives at another path moved. + for (const row of Array.from(this.context.rows.values())) { + if (row.state !== "synced" || row.guid === null) continue; + const inMap = mapByGuid.get(row.guid); + if (!inMap) { + this.tickRow(row, { + type: "MAP_REMOVED", + path: row.path, + guid: row.guid, + }); + } else if (inMap.path !== row.path) { + this.tickRow(row, { + type: "MAP_MOVED", + guid: row.guid, + from: row.path, + to: inMap.path, }); - continue; } - this.upsertEntry(mapEntry.guid, mapEntry.path, "pendingDownload"); - this.emit({ - type: "ENQUEUE_DOWNLOAD", - path: mapEntry.path, - guid: mapEntry.guid, - }); } - ctx.locallyDeleted.clear(); - if (parkedCount > 0) { - this.emit({ type: "SURFACE_STATUS" }); + // The visit: every row whose node declares a CLASSIFY cell. + for (const row of Array.from(this.context.rows.values())) { + // Retired or displaced mid-pass (a rename decision can adopt a + // seeded row's key): only live row objects are visited. + if (this.context.rows.get(row.key) !== row) continue; + if (!CLASSIFY_STATES.has(row.state)) continue; + this.classifyVisit(row); + } + this.drainScheduledClassifies(); + } + + /** Re-run gated dispatches after an authorization (or tier) edge. */ + private revisitGatedRows(): void { + for (const row of Array.from(this.context.rows.values())) { + if (row.dispatched) continue; + if (row.state === "upload.held") { + this.dispatchUpload(row); + } else if (row.state === "delete.pending") { + this.dispatchIndexDelete(row); + } } } // ========================================================================= - // Steady state (tracking) + // Folder-event routing into the table // ========================================================================= - private applyMapDelta(event: Extract): void { - const ctx = this.context; - - // Moves first: a same-transaction delete+add with one guid is a path - // update on an existing entry — structurally incapable of being + private routeDelta( + event: Extract, + ): void { + // Moves first: a same-transaction delete+add sharing one guid is a + // path update on one identity — structurally incapable of being // misread as delete-then-create. for (const move of event.moves ?? []) { - const entry = this.entryByGuid(move.guid); - if (ctx.localFiles.has(move.from)) { - if (entry) { - this.movePath(entry, move.to, "pendingRename"); - } else { - this.upsertEntry(move.guid, move.to, "pendingRename"); - } - this.emit({ - type: "RENAME_LOCAL", - from: move.from, - to: move.to, - guid: move.guid, - }); - } else if (ctx.localFiles.has(move.to)) { - if (entry) this.movePath(entry, move.to, "synced"); - else this.upsertEntry(move.guid, move.to, "synced"); + const row = this.rowByGuid(move.guid); + if (row) { + this.tickRow(row, { type: "MAP_MOVED", ...move }); } else { - if (entry) this.movePath(entry, move.to, "pendingDownload"); - else this.upsertEntry(move.guid, move.to, "pendingDownload"); - this.emit({ type: "ENQUEUE_DOWNLOAD", path: move.to, guid: move.guid }); + const seeded = this.seedRow(move.from, move.guid); + this.scheduleClassifyRow(seeded); } } - - // Unpaired deletes trash the local file iff the removal is positively - // associated with it: the removed guid matches the locally - // recorded guid for that path. for (const del of event.deletes ?? []) { const guid = del.oldValue?.id; - const hasLocalFile = ctx.localFiles.has(del.path); - if (!hasLocalFile) { - if (guid !== undefined) this.removeEntryByGuid(guid); - continue; + const row = + this.rowAtPath(del.path) ?? + (guid !== undefined ? this.rowByGuid(guid) : undefined); + if (row) { + this.tickRow(row, { type: "MAP_REMOVED", path: del.path, guid }); + } else if (this.context.localFiles.has(del.path)) { + // A removal for a path the table has never decided about: + // the evidence ladder decides, it does not trash on arrival. + const seeded = this.seedRow(del.path, guid ?? null); + this.scheduleClassifyRow(seeded); } - if (guid !== undefined && this.guidMatchesLocalRecord(del.path, guid)) { - this.upsertEntry(guid, del.path, "pendingTrash"); - this.emit({ type: "TRASH_LOCAL", path: del.path, guid }); + } + const routeAssertion = ( + add: (typeof event.adds)[number], + type: "MAP_ADDED" | "MAP_UPDATED", + ) => { + const existing = this.rowAtPath(add.path); + if (existing) { + this.tickRow(existing, { + type, + path: add.path, + guid: add.guid, + fileType: add.type, + }); } else { - // Path now hosts different content (recreation) — never trash. - if (guid !== undefined) this.removeEntryByGuid(guid); + const seeded = this.seedRow(add.path, add.guid); + if (add.type === "folder") seeded.kind = "folder"; + this.scheduleClassifyRow(seeded); } - } - - for (const add of [...(event.adds ?? []), ...(event.updates ?? [])]) { - this.applyMapAdd(add); - } - } - - private applyMapAdd(add: MapDeltaAdd): void { - if (this.context.localFiles.has(add.path)) { - this.upsertEntry(add.guid, add.path, "synced"); - return; - } - // Re-emission for an entry already pendingDownload is deliberate: - // emission does not prove execution — the host's enqueue can drop - // against a mid-sync store — and a split-transaction join delivers - // a later delta for exactly these keys. The downstream path dedups: - // re-created files return early above, in-flight downloads are keyed - // by path, and the queue dedups by guid. - this.upsertEntry(add.guid, add.path, "pendingDownload"); - this.emit({ type: "ENQUEUE_DOWNLOAD", path: add.path, guid: add.guid }); - } - - /** - * A download attempt failed, or the host dropped the enqueue before it - * reached the queue. The entry stays pendingDownload: retries are - * delta-driven (applyMapAdd re-emits on the next add or update of the - * key) and hydration-driven (a fresh hydrate re-emits for map entries - * without local files). No re-emission here — the host's enqueue guard - * reports failure synchronously, so an immediate retry would loop. - */ - private settleDownloadFailure(path: string, guid: string): void { - const entry = this.entryByGuid(guid); - if (!entry || entry.path !== path) return; - if (entry.disposition !== "pendingDownload") return; + }; + for (const add of event.adds ?? []) routeAssertion(add, "MAP_ADDED"); + for (const update of event.updates ?? []) + routeAssertion(update, "MAP_UPDATED"); } - private trackDiscoveredFile( + private routeFileDiscovered( path: string, origin: FileOrigin, kind?: LocalFileKind, ): void { this.rememberLocalFile(path, origin, kind); - const mapEntry = this.getMapEntry(path); - if (mapEntry) { - const entry = this.entryAtPath(path); - if ( - !entry || - entry.guid !== mapEntry.guid || - entry.disposition === "pendingDownload" - ) { - // Discovery of a mapped path settles missing entries and - // downloads whose completion event was missed. - this.upsertEntry(mapEntry.guid, path, "synced"); - } + const row = this.rowAtPath(path); + if (row) { + this.tickRow(row, { type: "FILE_DISCOVERED", path, origin, kind }); + // An undecided row is a standing question, and a discovery is + // the host asking for the answer (the shared-handle fallback + // depends on it). The visit still honors the trust and tier + // gates; a row the gates hold stays visibly unclassified. + if (row.state === "unclassified") this.scheduleClassifyRow(row); + return; } - // Bootstrap-origin classification (uploads, rung-2 trash) is - // reconciling's job: it reruns on the next connect. Recording - // the observation here is the whole job of the sweep event source. + const seeded = this.seedRow(path); + this.scheduleClassifyRow(seeded); } - private handleInteractiveCreate(path: string, kind?: LocalFileKind): void { + private routeFileCreated(path: string, kind?: LocalFileKind): void { this.rememberLocalFile(path, "interactive", kind); - // A live create supersedes any recorded delete intent and unparks. - this.context.locallyDeleted.delete(path); - if (this.context.parked.has(path)) { - this.context.parked.delete(path); - this.removeEntryAtPath(path); - } - const mapEntry = this.getMapEntry(path); - if (mapEntry) { - // Materialization of an already-shared path (e.g. a download). - this.upsertEntry(mapEntry.guid, path, "synced"); - return; - } - const existing = this.entryAtPath(path); - if (existing && existing.disposition === "pendingUpload") { - return; // already queued - } - this.emit({ type: "ENQUEUE_UPLOAD", path, origin: "interactive" }); - this.upsertEntry( - this.config.getPendingUploadGuid(path) ?? null, - path, - "pendingUpload", - ); + this.context.recordedDeleteIntents.delete(path); + let row = this.rowAtPath(path); + if (!row) row = this.seedRow(path); + this.tickRow(row, { type: "FILE_CREATED", path, kind }); } - private handleLocalDelete(path: string): void { + private routeFileDeleted(path: string): void { this.context.localFiles.delete(path); - const entry = this.entryAtPath(path); - if (entry?.disposition === "pendingTrash") { - // Echo of our own TRASH_LOCAL; completion arrives as TRASH_COMPLETE. + this.bump(); + let row = this.rowAtPath(path); + if (!row) row = this.seedRow(path); + this.tickRow(row, { type: "FILE_DELETED", path }); + } + + private routeFileRenamed(from: string, to: string): void { + this.rekeyLocalFile(from, to); + const destination = this.rowAtPath(to); + if (destination && destination.state === "renaming") { + // The platform echo of our own rename: the row already sits at + // the destination awaiting exactly this event. + this.tickRow(destination, { type: "FILE_RENAMED_IN", from, to }); return; } - if (entry?.disposition === "parked" || this.context.parked.has(path)) { - // Parked paths have no map entry and no pending upload: deleting - // the local file simply retires the parked state. Emitting a map - // delete here would be an op that internally no-ops. - this.context.parked.delete(path); - this.removeEntryAtPath(path); + const source = this.rowAtPath(from); + if (source) { + this.tickRow(source, { type: "FILE_RENAMED_AWAY", from, to }); return; } - const mapEntry = this.getMapEntry(path); - const guid = entry?.guid ?? mapEntry?.guid; - if (entry || mapEntry) { - this.emit({ type: "MAP_DELETE", path, guid: guid ?? undefined }); - this.removeEntryAtPath(path); - } else { - // Unknown to the map and to the table: remember the intent so a - // later reconcile does not resurrect the path. - this.context.locallyDeleted.add(path); - } + // Renamed into existence from the machine's perspective. + this.routeFileCreated(to); } - private handleLocalRename(from: string, to: string): void { - this.rekeyLocalFile(from, to); + // ========================================================================= + // Entry guards — the evidence-requirements contract + // ========================================================================= - // Echo of a RENAME_LOCAL we asked for: the entry already sits at the - // destination path awaiting exactly this event. - const destination = this.entryAtPath(to); - if (destination && destination.disposition === "pendingRename") { - destination.disposition = "synced"; - return; - } + private buildEntryGuards(): Record< + string, + (row: EntryRow, event: EntryEvent) => boolean + > { + const confirmed = () => this.context.tier === "confirmed"; + const heldAt = (path: string) => + this.config.holds.getHold(path) !== undefined; + const eventGuid = (event: EntryEvent): string | undefined => { + if ("guid" in event && typeof event.guid === "string") + return event.guid; + return undefined; + }; + return { + indexEntryAtPathWithLocalFile: (row) => + this.getMapEntry(row.path) !== undefined && + this.context.localFiles.has(row.path), + holdAdoptable: (row) => + this.context.localFiles.has(row.path) && + heldAt(row.path) && + (row.origin === "interactive" || + !this.config.pathTombstoned(row.path)), + originInteractive: (row) => + this.context.localFiles.has(row.path) && + row.origin === "interactive", + recordAliveElsewhere: (row) => { + if (!confirmed()) return false; + if (!this.context.localFiles.has(row.path)) return false; + const recordGuid = this.config.records.getRecordGuid(row.path); + if (recordGuid === undefined) return false; + const alive = this.config + .listMapEntries() + .find((entry) => entry.guid === recordGuid); + return alive !== undefined && alive.path !== row.path; + }, + staleCopyCondemned: (row) => { + if (!confirmed()) return false; + if (!this.context.localFiles.has(row.path)) return false; + // Held-but-unpublished content is never condemned. + if (heldAt(row.path)) return false; + const recordGuid = this.config.records.getRecordGuid(row.path); + if (recordGuid === undefined) return false; + const anywhere = this.config + .listMapEntries() + .some((entry) => entry.guid === recordGuid); + if (anywhere) return false; + // Content agreement is part of the guard, never optional: a + // record can outlive the file it described, and a new file + // at an old path is a different file. + return this.config.records.recordMatchesDisk(row.path); + }, + tombstonedEmptyDirectory: (row) => + confirmed() && + row.kind === "folder" && + this.context.localFiles.has(row.path) && + !heldAt(row.path) && + this.config.pathTombstoned(row.path) && + !this.hasLocalChildren(row.path), + tombstoned: (row) => + this.context.localFiles.has(row.path) && + this.config.pathTombstoned(row.path), + isLocalFile: (row) => this.context.localFiles.has(row.path), + recordedDeleteIntent: (row) => + this.context.recordedDeleteIntents.has(row.path), + indexEntryKnown: (row) => this.getMapEntry(row.path) !== undefined, + identityMatchesAndContentAgrees: (row, event) => { + if (!confirmed()) return false; + if (heldAt(row.path)) return false; + const guid = eventGuid(event); + if (guid === undefined) return false; + const matches = + guid === row.guid || + guid === this.config.records.getRecordGuid(row.path); + if (!matches) return false; + return this.config.records.recordMatchesDisk(row.path); + }, + identityMatches: (row, event) => { + if (!confirmed()) return false; + if (heldAt(row.path)) return false; + const guid = eventGuid(event); + if (guid === undefined) return false; + return ( + guid === row.guid || + guid === this.config.records.getRecordGuid(row.path) + ); + }, + committedIdentityAtPath: (row) => + this.getMapEntry(row.path) !== undefined, + tombstonedBootstrapHold: (row) => + confirmed() && + this.config.pathTombstoned(row.path) && + row.origin === "bootstrap", + observedIdentityStillCommitted: (row) => { + if (!row.observedIdentity) return false; + // The deletion took effect exactly when the committed map no + // longer holds the path this intent removed. + return this.getMapEntry(row.observedIdentity.path) === undefined; + }, + mergeableKind: (row, event) => { + const fileType = + event.type === "MAP_ADDED" || event.type === "MAP_UPDATED" + ? event.fileType + : undefined; + return this.config.mergeableKind?.(fileType) ?? false; + }, + sourceFilePresent: (row, event) => + this.context.tier === "confirmed" && + event.type === "MAP_MOVED" && + this.context.localFiles.has(event.from), + destinationPresent: (row, event) => + event.type === "MAP_MOVED" && + this.context.localFiles.has(event.to), + verdictKeepLocal: (row, event) => + event.type === "RESOLVE_CONFLICT" && + event.verdict === "keep-local", + verdictKeepRemote: (row, event) => + this.context.tier === "confirmed" && + event.type === "RESOLVE_CONFLICT" && + event.verdict === "keep-remote" && + !this.context.localFiles.has(row.path), + verdictKeepRemoteWithLocalFile: (row, event) => + this.context.tier === "confirmed" && + event.type === "RESOLVE_CONFLICT" && + event.verdict === "keep-remote" && + this.context.localFiles.has(row.path), + }; + } + + // ========================================================================= + // Entry actions + // ========================================================================= - const entry = this.entryAtPath(from); - if (entry) { - const guid = entry.guid; - this.movePath(entry, to, entry.disposition); - this.emit({ - type: "MAP_SET", - path: to, - oldPath: from, - guid: guid ?? undefined, + private buildEntryActions(): Record< + string, + (row: EntryRow, event: EntryEvent) => void + > { + return { + adoptHold: (row) => { + const guid = this.config.holds.getHold(row.path); + if (guid !== undefined && row.guid !== guid) { + this.rekeyRowGuid(row, guid); + } + }, + mintHold: (row) => { + // Reuse a persisted identity when one exists — retries after + // restart must not mint fresh guids; the actual mint happens + // in the host's execution of the upload effect. + const guid = this.config.holds.getHold(row.path); + if (guid !== undefined && row.guid !== guid) { + this.rekeyRowGuid(row, guid); + } + }, + upgradeOriginInteractive: (row) => { + if (row.origin !== "interactive") { + row.origin = "interactive"; + this.bump(); + } + }, + setOriginInteractive: (row) => { + if (row.origin !== "interactive") { + row.origin = "interactive"; + this.bump(); + } + row.reason = undefined; + }, + scheduleClassify: (row) => this.scheduleClassifyRow(row), + recordDeleteIntent: (row) => { + this.context.recordedDeleteIntents.add(row.path); + this.bump(); + }, + recordObservedIdentity: (row) => { + const guid = row.guid ?? this.getMapEntry(row.path)?.guid; + if (guid !== undefined) { + row.observedIdentity = { guid, path: row.path }; + this.bump(); + } + }, + dropIntent: (row) => { + row.observedIdentity = undefined; + this.context.recordedDeleteIntents.delete(row.path); + this.bump(); + }, + surfaceDrop: (row) => { + row.reason = DROP_REASON; + this._surfaceDirty = true; + this.bump(); + }, + recordReason: (row) => { + row.reason = + this.context.authorization === "read-only" + ? PARK_REASON_READ_ONLY + : PARK_REASON_TOMBSTONE; + this.bump(); + }, + recordEvidencePair: (row, event) => { + row.reason = CONFLICT_REASON; + if (event.type === "MAP_ADDED" || event.type === "MAP_UPDATED") { + row.observedIdentity = { guid: event.guid, path: row.path }; + } + this.bump(); + }, + recordContentEvidence: (row, event) => { + row.contentAgreement = "agrees"; + const guid = + "guid" in event && typeof event.guid === "string" + ? event.guid + : null; + if (guid && row.guid !== guid) this.rekeyRowGuid(row, guid); + this.bump(); + }, + adoptCommittedIdentity: (row) => { + const committed = this.getMapEntry(row.path); + if (committed && row.guid !== committed.guid) { + this.rekeyRowGuid(row, committed.guid); + } + }, + adoptAcknowledgedIdentity: (row, event) => { + if (event.type !== "WORK_STARTED") return; + if (row.guid !== event.guid) { + this.rekeyRowGuid(row, event.guid); + } + }, + rekeyRow: (row, event) => { + if (event.type === "MAP_MOVED") { + this.rekeyRowPath(row, event.to); + if (row.guid !== event.guid) this.rekeyRowGuid(row, event.guid); + } else if (event.type === "FILE_RENAMED_AWAY") { + this.config.records.moveRecord(event.from, event.to); + this.rekeyRowPath(row, event.to); + } + }, + rekeyRowAndHold: (row, event) => { + if (event.type !== "FILE_RENAMED_AWAY") return; + this.config.holds.moveHold(event.from, event.to); + this.config.records.moveRecord(event.from, event.to); + this.rekeyRowPath(row, event.to); + }, + dispatchUploadIfPermitted: (row) => this.dispatchUpload(row), + redispatchIfUnacknowledged: (row) => { + // Emission is not execution: decided work re-emits + // at-least-once until the host acknowledges it. + if (row.state === "upload.held") { + this.dispatchUpload(row); + } else if (row.state === "download.pending") { + this.dispatchDownload(row); + } + }, + emitEnqueueDownload: (row, event) => { + // A download materializes the identity the group committed at + // the path. The committed map entry wins, then the event's + // asserted identity; the row's own guid is only a fallback — + // a row that reached parked or conflicted with an adopted, + // never-committed mint must not dispatch that mint, or the + // work can never execute and the row dead-ends re-emitting it. + const eventGuid = + "guid" in event && typeof event.guid === "string" + ? event.guid + : undefined; + const guid = + this.getMapEntry(row.path)?.guid ?? + eventGuid ?? + row.guid ?? + undefined; + if (guid === undefined) return; + if (row.guid !== guid) this.rekeyRowGuid(row, guid); + this.dispatchDownload(row); + }, + emitTrashLocal: (row, event) => { + const guid = + ("guid" in event && typeof event.guid === "string" + ? event.guid + : undefined) ?? + row.guid ?? + this.config.records.getRecordGuid(row.path) ?? + null; + this.emit({ type: "TRASH_LOCAL", path: row.path, guid }); + row.dispatched = true; + }, + emitRenameLocal: (row, event) => { + let from = row.path; + let to: string | undefined; + let guid = row.guid ?? undefined; + if (event.type === "MAP_MOVED") { + from = event.from; + to = event.to; + guid = event.guid; + } else { + // Classification: the recorded identity lives elsewhere + // in the map; the local file follows it. + const recordGuid = this.config.records.getRecordGuid(row.path); + const alive = recordGuid + ? this.config + .listMapEntries() + .find((entry) => entry.guid === recordGuid) + : undefined; + if (!alive) return; + to = alive.path; + guid = recordGuid; + } + if (to === undefined || guid === undefined) return; + this.emit({ type: "RENAME_LOCAL", from, to, guid }); + row.dispatched = true; + // The row awaits the platform echo at the destination. + if (row.guid !== guid) this.rekeyRowGuid(row, guid); + this.rekeyRowPath(row, to); + }, + emitIndexSet: (row, event) => { + if (event.type !== "FILE_RENAMED_AWAY") return; + if (this.context.authorization !== "write") { + row.dispatched = false; + return; + } + this.emit({ + type: "MAP_SET", + path: event.to, + oldPath: event.from, + guid: row.guid ?? undefined, + }); + row.dispatched = true; + }, + emitIndexDelete: (row) => { + if (!row.observedIdentity) { + const guid = row.guid ?? this.getMapEntry(row.path)?.guid; + if (guid !== undefined) { + row.observedIdentity = { guid, path: row.path }; + } + } + this.dispatchIndexDelete(row); + }, + emitPark: (row) => { + this.emit({ + type: "PARK", + path: row.path, + reason: row.reason ?? PARK_REASON_TOMBSTONE, + }); + this._surfaceDirty = true; + }, + emitSurfaceStatus: () => { + this._surfaceDirty = true; + }, + emitRetractUpload: (row) => { + // The hold releases with the row: the local file is gone, or + // a committed identity superseded the unpublished mint. A row + // that never carried an identity and holds nothing has + // nothing to retract. + const guid = + row.guid ?? this.config.holds.getHold(row.path) ?? null; + if (guid === null) return; + this.emit({ + type: "RETRACT_UPLOAD", + path: row.path, + guid, + releaseHold: true, + }); + }, + emitCancelUploadWork: (row) => { + // Cancel the queued work but PRESERVE the persisted hold: a + // hold marks content the server does not have, and its + // identity is never discarded without a completed + // publication or an explicit user action. + this.emit({ + type: "RETRACT_UPLOAD", + path: row.path, + guid: row.guid ?? this.config.holds.getHold(row.path) ?? null, + releaseHold: false, + }); + }, + retractSupersededMint: (row) => { + // The row is converging to a committed identity at its path + // (a remote assertion, or an explicit keep-remote verdict). + // A never-committed mint the row still carries — a preserved + // hold adopted while parked or conflicted — is superseded: + // retract its queued work and release the hold, or the stale + // identity keeps shadowing the committed one in every + // path-keyed lookup. Emits nothing when the row carries no + // superseded identity. + const committed = this.getMapEntry(row.path)?.guid; + const minted = + this.config.holds.getHold(row.path) ?? row.guid ?? null; + if (minted === null || minted === committed) return; + this.emit({ + type: "RETRACT_UPLOAD", + path: row.path, + guid: minted, + releaseHold: true, + }); + }, + cancelWork: (row) => { + // The identity this download served was removed; the host + // cancels via the retraction contract for downloads too. + row.dispatched = false; + }, + }; + } + + // ========================================================================= + // Gated dispatch helpers + // ========================================================================= + + /** + * The dispatch gate for publication: emits only under confirmed + * confidence, write authorization, and a posture granting the + * origin's upload capability. Otherwise the intent queues silently in + * the row and dispatch fires on the tier/authorization edge. + */ + private dispatchUpload(row: EntryRow): void { + if (this.context.tier !== "confirmed") return; + if (this.context.authorization !== "write") return; + const capability: FolderCapabilityName = + row.origin === "interactive" + ? "canUploadInteractive" + : "canUploadBootstrap"; + if (!this.may(capability)) return; + this.emit({ type: "ENQUEUE_UPLOAD", path: row.path, origin: row.origin }); + row.dispatched = true; + this.bump(); + } + + private dispatchDownload(row: EntryRow): void { + if (row.guid === null) return; + if (!this.may("canDownload")) return; + this.emit({ type: "ENQUEUE_DOWNLOAD", path: row.path, guid: row.guid }); + row.dispatched = true; + this.bump(); + } + + private dispatchIndexDelete(row: EntryRow): void { + if (this.context.authorization !== "write") return; + if (!this.may("canMutateMap")) return; + this.emit({ + type: "MAP_DELETE", + path: row.path, + guid: row.observedIdentity?.guid ?? row.guid ?? undefined, + }); + row.dispatched = true; + this.bump(); + } + + // ========================================================================= + // Invariants + // ========================================================================= + + /** + * Run the state-shaped invariant checks over the current table. The + * emit-time invariants (capability grants, blind and read-only gates) + * are enforced by throwing in `emit`; these are the observational + * ones, run by tests and periodic checkers. + */ + checkInvariants(): FolderInvariantViolation[] { + const violations: FolderInvariantViolation[] = []; + const report = ( + id: string, + severity: FolderInvariantViolation["severity"], + message: string, + row?: EntryRow, + ) => { + violations.push({ + id, + severity, + message, + statePath: this._statePath, + entryState: row?.state, + path: row?.path, }); - return; + }; + for (const row of this.context.rows.values()) { + if (row.state === "synced") { + const entry = this.getMapEntry(row.path); + if (!entry || (row.guid !== null && entry.guid !== row.guid)) { + report( + "synced-agrees", + "error", + `synced row ${row.path} does not match the committed map`, + row, + ); + } + } + if (row.state === "parked") { + if (this.getMapEntry(row.path) !== undefined) { + report( + "parked-outside-index", + "error", + `parked row ${row.path} has a committed map entry`, + row, + ); + } + } + if ( + row.state === "upload.inFlight" || + row.state === "download.inFlight" + ) { + if (row.guid === null) { + report( + "inflight-implies-ack", + "warning", + `in-flight row ${row.path} carries no acknowledged identity`, + row, + ); + } + } + if (row.state === "conflicted") { + const hasLocal = this.context.localFiles.has(row.path); + const hasRemote = + row.observedIdentity !== undefined || + this.getMapEntry(row.path) !== undefined; + if (!hasLocal || !hasRemote) { + report( + "conflict-has-two-evidences", + "warning", + `conflicted row ${row.path} lacks positive evidence on both sides`, + row, + ); + } + } } - - // Renamed into existence from the machine's perspective. - this.handleInteractiveCreate(to); + for (const violation of violations) { + this.config.onInvariantViolation?.(violation); + } + return violations; } } diff --git a/src/folder-hsm/delete-collector.ts b/src/folder-hsm/delete-collector.ts index 49441cf4f..d74faa7f3 100644 --- a/src/folder-hsm/delete-collector.ts +++ b/src/folder-hsm/delete-collector.ts @@ -39,6 +39,15 @@ export type GateResolution = "resolved" | "not-gated" | "stale"; export interface HeldDelete { mapName: FolderMapName; key: string; + /** + * The identity the deletion observed at decision time (captured from + * the removed value before compaction). Resolution replays a deletion + * only while the committed value still carries this identity; a + * mismatch drops the stale intent instead of destroying. Absent on + * rows rehydrated from formats that recorded no identity — those + * replay on the user's explicit send, which is itself authorization. + */ + guid?: string; } /** Stable, display-ready view of one version of a gated burst. */ @@ -77,6 +86,18 @@ export interface DeleteCollectorCallbacks { onRestored(deletes: HeldDelete[]): void; /** Collector state changed in a way the host should persist. */ persist(state: SerializedCollectorState | null): void; + /** + * The identity the committed remote replica currently holds for a + * key, for the expired-intent check at send() resolution. Absent, no + * intent is ever classified stale. + */ + currentRemoteIdentity?(mapName: FolderMapName, key: string): string | undefined; + /** + * Deletions dropped at send() resolution because their target changed + * since the intent was recorded. The keys stay on the remote replica; + * the host re-asserts them locally so the replicas converge. + */ + onDroppedStale?(deletes: HeldDelete[]): void; } export interface DeleteCollectorOptions { @@ -91,6 +112,23 @@ function refKey(mapName: FolderMapName, key: string): string { return `${mapName}\u0000${key}`; } +/** + * The identity carried by a removed map value: filemeta rows carry it as + * `id`; the legacy docs map stores the identity string itself. + */ +function observedIdentity(oldValue: unknown): string | undefined { + if (typeof oldValue === "string") return oldValue; + if ( + oldValue !== null && + typeof oldValue === "object" && + "id" in oldValue && + typeof (oldValue as { id: unknown }).id === "string" + ) { + return (oldValue as { id: string }).id; + } + return undefined; +} + function logicalPaths(deletes: Iterable): string[] { return [...new Set([...deletes].map((deleted) => deleted.key))].sort(); } @@ -127,9 +165,10 @@ export class DeleteCollector { } heldDeletes(): HeldDelete[] { - return [...this.held.values()].map(({ mapName, key }) => ({ + return [...this.held.values()].map(({ mapName, key, guid }) => ({ mapName, key, + ...(guid !== undefined ? { guid } : {}), })); } @@ -160,9 +199,18 @@ export class DeleteCollector { if (deletes.length === 0) return; const before = this.held.size; for (const d of deletes) { + const observed = observedIdentity(d.oldValue); + const existing = this.held.get(refKey(d.mapName, d.key)); this.held.set(refKey(d.mapName, d.key), { mapName: d.mapName, key: d.key, + // Keep the first observed identity: it is what the user's + // decision was actually about. + ...(existing?.guid !== undefined + ? { guid: existing.guid } + : observed !== undefined + ? { guid: observed } + : {}), }); } if (this.phase === "idle") this.phase = "collecting"; @@ -218,10 +266,32 @@ export class DeleteCollector { if (this.phase !== "gated") return "not-gated"; if (token !== this.gateSnapshot()?.token) return "stale"; const deletes = this.heldDeletes(); - this.bridge.replicateDeletes(deletes); + // Expired-intent check at resolution: a deletion replays only + // while its target still carries the identity it observed at + // decision time. A changed target's deletion is dropped — the + // intent expired — and the key converges back from remote truth. + const replayable: HeldDelete[] = []; + const stale: HeldDelete[] = []; + const identityOf = this.callbacks.currentRemoteIdentity; + for (const deleted of deletes) { + if ( + identityOf === undefined || + deleted.guid === undefined || + identityOf(deleted.mapName, deleted.key) === undefined || + identityOf(deleted.mapName, deleted.key) === deleted.guid + ) { + replayable.push(deleted); + } else { + stale.push(deleted); + } + } + this.bridge.replicateDeletes(replayable); this.reset(); this.callbacks.persist(null); - this.callbacks.onReplicated(deletes); + if (stale.length > 0) { + this.callbacks.onDroppedStale?.(stale); + } + this.callbacks.onReplicated(replayable); return "resolved"; } diff --git a/src/folder-hsm/entry-machine.ts b/src/folder-hsm/entry-machine.ts new file mode 100644 index 000000000..eb5541e96 --- /dev/null +++ b/src/folder-hsm/entry-machine.ts @@ -0,0 +1,529 @@ +/** + * The entry machine — the per-file membership transition table. + * + * A second declarative constant in the family's state-node grammar + * (dotted paths, guard-ordered candidates, named guards/actions), with + * two deliberate restrictions: no `invoke` and no `always` — entry + * transitions are synchronous, executed as row ticks from inside the + * folder machine's actions, on its single event queue. + * + * Every node declares an `otherwise` policy so no (state x event) cell + * can be silently consumed: an unconsidered event is exactly where files + * get lost. The exhaustive test walk fails on any cell covered by + * neither a handler nor a policy. + * + * Candidates that emit effects declare the folder-posture capabilities + * those effects require (`requires`); the emit chokepoint checks the + * cross-product and refuses on violation. + * + * Evidence rules the guards encode (see FolderHSM for the bindings): + * - destruction requires positive identity association AND content + * agreement AND confirmed confidence — absence never deletes; + * - a path carrying a persisted upload hold is never trashed and its + * minted identity is never silently discarded: the hold marks content + * the server does not have; + * - publication requires live user intent or a confirmed-confidence + * verdict, and dispatch additionally requires write authorization; + * - acknowledged work is adopted, never re-emitted; unacknowledged + * intent re-emits at-least-once. + */ + +import type { EntryMachineDefinition, EntryRefusal } from "./types"; + +const REFUSE: EntryRefusal = { refuse: true }; + +export const ENTRY_MACHINE: EntryMachineDefinition = { + unclassified: { + otherwise: "absorb", + on: { + // The evidence ladder: first passing guard wins. + CLASSIFY: [ + // Adoption of the committed identity is row bookkeeping: a + // synced row always carries the identity the map holds. + { + target: "synced", + guard: "indexEntryAtPathWithLocalFile", + actions: ["adoptCommittedIdentity"], + }, + { + target: "upload.held", + guard: "holdAdoptable", + actions: ["adoptHold"], + }, + { + target: "upload.held", + guard: "originInteractive", + actions: ["mintHold"], + }, + { + target: "renaming", + guard: "recordAliveElsewhere", + actions: ["emitRenameLocal"], + requires: ["canRenameLocal"], + }, + { + target: "trashing", + guard: "staleCopyCondemned", + actions: ["emitTrashLocal"], + requires: ["canTrash"], + }, + { + target: "trashing", + guard: "tombstonedEmptyDirectory", + actions: ["emitTrashLocal"], + requires: ["canTrash"], + }, + { + target: "parked", + guard: "tombstoned", + actions: ["recordReason", "emitPark"], + requires: ["canPark"], + }, + { + target: "upload.held", + guard: "isLocalFile", + actions: ["mintHold"], + }, + { + target: "delete.pending", + guard: "recordedDeleteIntent", + actions: ["emitIndexDelete"], + requires: ["canMutateMap"], + }, + { + target: "download.pending", + actions: ["emitEnqueueDownload"], + requires: ["canDownload"], + }, + ], + FILE_CREATED: { + target: "unclassified", + actions: ["upgradeOriginInteractive", "scheduleClassify"], + }, + FILE_DELETED: [ + { + target: "delete.pending", + guard: "indexEntryKnown", + actions: ["emitIndexDelete"], + requires: ["canMutateMap"], + }, + { target: "retired", actions: ["recordDeleteIntent"] }, + ], + }, + }, + + synced: { + // MAP_UPDATED / FILE_DISCOVERED / FILE_MODIFIED absorb (content + // convergence is out of scope; content evidence is marked stale on + // FILE_MODIFIED). Completions and acks with no outstanding work + // refuse explicitly below. + otherwise: "absorb", + on: { + MAP_REMOVED: [ + { + target: "trashing", + guard: "identityMatchesAndContentAgrees", + actions: ["emitTrashLocal"], + requires: ["canTrash"], + }, + // The content-disagreement case. OPEN DECISION: the default + // here is that the removal prevails into recoverable trash; + // the alternative candidate is { target: "conflicted" }. + { + target: "trashing", + guard: "identityMatches", + actions: ["emitTrashLocal"], + requires: ["canTrash"], + }, + // Identity mismatch: a recreated path — never trash. + { target: "unclassified", actions: ["scheduleClassify"] }, + ], + MAP_MOVED: [ + { + target: "renaming", + guard: "sourceFilePresent", + actions: ["emitRenameLocal"], + requires: ["canRenameLocal"], + }, + { + target: "synced", + guard: "destinationPresent", + actions: ["rekeyRow"], + }, + { + target: "download.pending", + actions: ["rekeyRow", "emitEnqueueDownload"], + requires: ["canDownload"], + }, + ], + FILE_DELETED: { + target: "delete.pending", + actions: ["recordObservedIdentity", "emitIndexDelete"], + requires: ["canMutateMap"], + }, + // Carries the observed identity outbound. + FILE_RENAMED_AWAY: { + target: "synced", + actions: ["rekeyRow", "emitIndexSet"], + requires: ["canMutateMap"], + }, + WORK_STARTED: REFUSE, + UPLOAD_COMPLETE: REFUSE, + DOWNLOAD_COMPLETE: REFUSE, + }, + }, + + "upload.held": { + // Dispatch is gated: ENQUEUE_UPLOAD emits only under confirmed + // tier and write authorization; otherwise the intent queues + // silently and dispatch fires on the tier/authorization edge. + entry: ["dispatchUploadIfPermitted"], + otherwise: "absorb", + on: { + // The acknowledgment carries the accepted work item's identity; + // the row adopts it (bookkeeping for the in-flight contract). + WORK_STARTED: { + target: "upload.inFlight", + actions: ["adoptAcknowledgedIdentity"], + }, + UPLOAD_COMPLETE: { + target: "synced", + actions: ["recordContentEvidence"], + }, + // Retried on the next occasion. + UPLOAD_FAILED: { target: "upload.held" }, + CLASSIFY: [ + // A re-run saw the deletion the minting pass could not: the + // queued work is cancelled, but the hold's minted identity is + // PRESERVED with the parked file — held-but-unpublished + // content is never condemned. + { + target: "parked", + guard: "tombstonedBootstrapHold", + actions: [ + "emitCancelUploadWork", + "recordReason", + "emitPark", + ], + requires: ["canPark"], + }, + // At-least-once until acknowledged. + { + target: "upload.held", + actions: ["redispatchIfUnacknowledged"], + }, + ], + MAP_ADDED: [ + // A peer published this path first: adopt the committed + // identity; the unpublished mint is superseded. + { + target: "synced", + guard: "committedIdentityAtPath", + actions: ["adoptCommittedIdentity", "emitRetractUpload"], + }, + { target: "upload.held" }, + ], + // The user deleted the local file: explicit action releases the + // hold with the row. + FILE_DELETED: { target: "retired", actions: ["emitRetractUpload"] }, + FILE_RENAMED_AWAY: { + target: "upload.held", + actions: ["rekeyRowAndHold"], + }, + // Content is read at execution time. + FILE_MODIFIED: { target: "upload.held" }, + }, + }, + + "upload.inFlight": { + otherwise: "absorb", + on: { + UPLOAD_COMPLETE: { + target: "synced", + actions: ["recordContentEvidence"], + }, + UPLOAD_FAILED: "upload.held", + CLASSIFY: [ + { + target: "parked", + guard: "tombstonedBootstrapHold", + actions: [ + "emitCancelUploadWork", + "recordReason", + "emitPark", + ], + requires: ["canPark"], + }, + // Adopt: acknowledged work is never re-emitted. + { target: "upload.inFlight" }, + ], + MAP_ADDED: [ + { + target: "synced", + guard: "committedIdentityAtPath", + actions: ["adoptCommittedIdentity", "emitRetractUpload"], + }, + { target: "upload.inFlight" }, + ], + // A late completion afterwards hits refuse and writes no + // membership. + FILE_DELETED: { target: "retired", actions: ["emitRetractUpload"] }, + }, + }, + + "download.pending": { + otherwise: "absorb", + on: { + WORK_STARTED: { + target: "download.inFlight", + actions: ["adoptAcknowledgedIdentity"], + }, + DOWNLOAD_COMPLETE: { + target: "synced", + actions: ["recordContentEvidence"], + }, + // Delta- and classification-driven retries. + DOWNLOAD_FAILED: { target: "download.pending" }, + CLASSIFY: { + target: "download.pending", + actions: ["redispatchIfUnacknowledged"], + }, + MAP_ADDED: { + target: "download.pending", + actions: ["redispatchIfUnacknowledged"], + }, + MAP_REMOVED: [ + { target: "retired", guard: "identityMatches" }, + { target: "unclassified", actions: ["scheduleClassify"] }, + ], + MAP_MOVED: { target: "download.pending", actions: ["rekeyRow"] }, + // Materialized; content convergence is out of scope. + FILE_CREATED: { target: "synced" }, + FILE_DISCOVERED: { target: "synced" }, + }, + }, + + "download.inFlight": { + otherwise: "absorb", + on: { + DOWNLOAD_COMPLETE: { + target: "synced", + actions: ["recordContentEvidence"], + }, + DOWNLOAD_FAILED: "download.pending", + // Adopt; never re-emit. + CLASSIFY: { target: "download.inFlight" }, + MAP_REMOVED: [ + { + target: "retired", + guard: "identityMatches", + actions: ["cancelWork"], + }, + { target: "unclassified", actions: ["scheduleClassify"] }, + ], + // Work follows identity. + MAP_MOVED: { target: "download.inFlight", actions: ["rekeyRow"] }, + FILE_CREATED: { target: "synced" }, + }, + }, + + trashing: { + // Including MAP_ADDED: after TRASH_COMPLETE the re-added entry + // classifies to download.pending; the trash is recoverable and the + // window is declared. No completion with the file still present + // leaves the row trashing, retried by the next matching delta or + // classification pass. + otherwise: "absorb", + on: { + // Retirement retires the local record with the row. + TRASH_COMPLETE: { target: "retired" }, + // The platform echo; completion arrives as TRASH_COMPLETE. + FILE_DELETED: { target: "trashing" }, + }, + }, + + renaming: { + otherwise: "absorb", + on: { + // The echo cell. + FILE_RENAMED_IN: { target: "synced" }, + // A colliding user rename. + FILE_RENAMED_AWAY: { + target: "unclassified", + actions: ["scheduleClassify"], + }, + MAP_MOVED: { target: "renaming", actions: ["rekeyRow"] }, + }, + }, + + "delete.pending": { + otherwise: "absorb", + on: { + DELETE_REPLICATED: [ + { target: "retired", guard: "observedIdentityStillCommitted" }, + { + target: "unclassified", + actions: ["dropIntent", "surfaceDrop", "scheduleClassify"], + }, + ], + DELETE_HELD: "delete.held", + DELETE_RESTORED: { + target: "download.pending", + actions: ["emitEnqueueDownload"], + requires: ["canDownload"], + }, + // Re-creation leaves the burst. + FILE_CREATED: { + target: "unclassified", + actions: ["scheduleClassify"], + }, + // A peer changed what this device decided to delete: the + // evidence no longer matches. + MAP_UPDATED: { + target: "unclassified", + actions: ["dropIntent", "surfaceDrop", "scheduleClassify"], + }, + MAP_MOVED: { + target: "unclassified", + actions: ["dropIntent", "surfaceDrop", "scheduleClassify"], + }, + }, + }, + + "delete.held": { + // The burst resolves as a unit; only explicit resolution or + // re-creation moves a held row. + otherwise: "absorb", + on: { + DELETE_REPLICATED: [ + { target: "retired", guard: "observedIdentityStillCommitted" }, + { + target: "unclassified", + actions: ["dropIntent", "surfaceDrop", "scheduleClassify"], + }, + ], + DELETE_RESTORED: { + target: "download.pending", + actions: ["emitEnqueueDownload"], + requires: ["canDownload"], + }, + FILE_CREATED: { + target: "unclassified", + actions: ["scheduleClassify"], + }, + }, + }, + + parked: { + otherwise: "absorb", + on: { + UNPARK_REQUESTED: { + target: "upload.held", + actions: ["setOriginInteractive", "mintHold"], + }, + FILE_CREATED: { + target: "upload.held", + actions: ["setOriginInteractive", "mintHold"], + }, + // NEVER publishes: editing a refused file only re-surfaces it. + FILE_MODIFIED: { target: "parked", actions: ["emitSurfaceStatus"] }, + // Explicit local deletion is one of parked's declared exits, and + // it releases a preserved hold with the row: a durable identity + // never outlives the file it was minted for. + FILE_DELETED: { target: "retired", actions: ["emitRetractUpload"] }, + MAP_ADDED: [ + // The group asserted mergeable content at the refused path: + // converge to the committed identity. A preserved mint the + // row adopted is superseded and retracts with its hold. + { + target: "download.pending", + guard: "mergeableKind", + actions: ["retractSupersededMint", "emitEnqueueDownload"], + requires: ["canDownload"], + }, + // Unmergeable content on both sides. + { + target: "conflicted", + actions: ["recordEvidencePair", "emitSurfaceStatus"], + }, + ], + }, + }, + + conflicted: { + otherwise: "absorb", + on: { + RESOLVE_CONFLICT: [ + { + target: "upload.held", + guard: "verdictKeepLocal", + actions: ["setOriginInteractive", "mintHold"], + }, + // Keep-remote is the explicit user action that sanctions + // discarding an unpublished mint the row carries. + { + target: "download.pending", + guard: "verdictKeepRemote", + actions: ["retractSupersededMint", "emitEnqueueDownload"], + requires: ["canDownload"], + }, + { + target: "trashing", + guard: "verdictKeepRemoteWithLocalFile", + actions: ["emitTrashLocal"], + requires: ["canTrash"], + }, + ], + FILE_DELETED: [ + { + target: "delete.pending", + guard: "indexEntryKnown", + actions: ["recordObservedIdentity", "emitIndexDelete"], + requires: ["canMutateMap"], + }, + { target: "retired" }, + ], + // New evidence may dissolve the disagreement; auto-resolution + // only ever lands in non-destructive verdicts via CLASSIFY. + MAP_REMOVED: { + target: "unclassified", + actions: ["scheduleClassify"], + }, + MAP_UPDATED: { + target: "unclassified", + actions: ["scheduleClassify"], + }, + }, + }, +}; + +/** All entry state paths, for structural walks. */ +export const ENTRY_STATE_PATHS = Object.keys( + ENTRY_MACHINE, +) as Array; + +/** Every event type the entry machine can be addressed with. */ +export const ENTRY_EVENT_TYPES = [ + "CLASSIFY", + "MAP_ADDED", + "MAP_UPDATED", + "MAP_REMOVED", + "MAP_MOVED", + "FILE_DISCOVERED", + "FILE_CREATED", + "FILE_MODIFIED", + "FILE_DELETED", + "FILE_RENAMED_AWAY", + "FILE_RENAMED_IN", + "WORK_STARTED", + "UPLOAD_COMPLETE", + "UPLOAD_FAILED", + "DOWNLOAD_COMPLETE", + "DOWNLOAD_FAILED", + "TRASH_COMPLETE", + "DELETE_HELD", + "DELETE_REPLICATED", + "DELETE_RESTORED", + "UNPARK_REQUESTED", + "RESOLVE_CONFLICT", +] as const; diff --git a/src/folder-hsm/index.ts b/src/folder-hsm/index.ts index 0b8660711..00459f34e 100644 --- a/src/folder-hsm/index.ts +++ b/src/folder-hsm/index.ts @@ -1,6 +1,13 @@ export { FolderHSM } from "./FolderHSM"; export { FOLDER_MACHINE } from "./machine-definition"; +export { + ENTRY_MACHINE, + ENTRY_EVENT_TYPES, + ENTRY_STATE_PATHS, +} from "./entry-machine"; +export { FOLDER_INVARIANTS } from "./invariants"; export { pathWasDeleted } from "./tombstones"; +export { docsHavePendingSyncState, observeSyncDrain } from "./pending-sync"; export { FolderDocBridge, BRIDGE_IN_ORIGIN, @@ -18,23 +25,42 @@ export { type HeldDelete, type SerializedCollectorState, } from "./delete-collector"; +export { + FolderHSMStore, + deleteFolderHSMDatabase, + sanitizeFolderState, + type FolderFork, + type FolderForkDelete, + type PersistedFolderState, + type RemoteIndexCache, + type RetainedDoc, +} from "./persistence/FolderHSMStore"; export { deriveRecoveryDelta, isEmptyRecoveryDelta, type RecoveryDelta, } from "./recovery"; export type { + AuthorizationScope, + ConfidenceTier, Disposition, + EntryEvent, + EntryRow, + EntryStatePath, FileOrigin, LocalFileKind, FolderEffect, FolderEvent, FolderHSMConfig, + FolderInvariantViolation, + FolderSerializableSnapshot, FolderStatePath, FolderSyncSnapshot, + LocalRecordSource, MapDeltaAdd, MapDeltaDelete, MapDeltaMove, MapEntrySummary, MembershipEntry, + UploadHoldSource, } from "./types"; diff --git a/src/folder-hsm/invariants.ts b/src/folder-hsm/invariants.ts new file mode 100644 index 000000000..4915b627f --- /dev/null +++ b/src/folder-hsm/invariants.ts @@ -0,0 +1,120 @@ +/** + * Runtime invariant definitions for the folder membership engine, in the + * family's declarative form. Two enforcement layers realize them: + * + * - emit-time invariants are enforced by the FolderHSM emit chokepoint, + * which THROWS on violation (capability grants, the blind-confidence + * gate, the read-only gate) — these can never be observed as state; + * - state-shaped invariants are evaluated by FolderHSM.checkInvariants() + * on demand and by periodic checkers, reporting violations through the + * configured sink. + * + * This table is the single description of both layers; tests assert that + * every id here is exercised by deliberately violating it. + */ + +import type { FolderInvariantSeverity, FolderInvariantTrigger } from "./types"; + +export interface FolderInvariantDefinition { + id: string; + name: string; + description: string; + severity: FolderInvariantSeverity; + trigger: FolderInvariantTrigger; + /** Entry-state paths (exact or dot prefix) the check applies to; empty = all. */ + applicableStates: string[]; + /** Where the check runs. */ + enforcement: "emit-throw" | "check"; +} + +export const FOLDER_INVARIANTS: FolderInvariantDefinition[] = [ + { + id: "effect-capability-granted", + name: "Effects require granted capabilities", + description: + "Every emitted effect's required capability is granted by the current folder posture; refusal throws.", + severity: "critical", + trigger: "on-emit", + applicableStates: [], + enforcement: "emit-throw", + }, + { + id: "blind-never-dispatches", + name: "Nothing destructive or publishing at blind confidence", + description: + "No TRASH_LOCAL, RENAME_LOCAL, or ENQUEUE_UPLOAD emits while the session tier is not confirmed.", + severity: "critical", + trigger: "on-emit", + applicableStates: [], + enforcement: "emit-throw", + }, + { + id: "readonly-never-writes", + name: "No writes under read-only authorization", + description: + "No publishing or map-mutating effect emits under read-only authorization.", + severity: "critical", + trigger: "on-emit", + applicableStates: [], + enforcement: "emit-throw", + }, + { + id: "synced-agrees", + name: "Synced rows agree with the committed map", + description: + "A synced row's identity is committed in the map at the row's path.", + severity: "error", + trigger: "on-state", + applicableStates: ["synced"], + enforcement: "check", + }, + { + id: "inflight-implies-ack", + name: "In-flight rows carry acknowledged work", + description: + "An acknowledged work item (its identity) exists for every in-flight row.", + severity: "warning", + trigger: "periodic", + applicableStates: ["upload.inFlight", "download.inFlight"], + enforcement: "check", + }, + { + id: "parked-outside-index", + name: "Parked rows have no committed entry", + description: "No committed map entry exists at a parked row's path.", + severity: "error", + trigger: "on-state", + applicableStates: ["parked"], + enforcement: "check", + }, + { + id: "record-dies-with-row", + name: "Records die with their rows", + description: + "No local record survives its row's retirement — a record never outlives the file it described.", + severity: "error", + trigger: "on-transition", + applicableStates: [], + enforcement: "check", + }, + { + id: "conflict-has-two-evidences", + name: "Conflicts hold two positive evidences", + description: + "A conflicted row records positive evidence on both sides that genuinely disagrees.", + severity: "warning", + trigger: "on-state", + applicableStates: ["conflicted"], + enforcement: "check", + }, + { + id: "entry-event-refused", + name: "Refusal writes nothing", + description: + "A refused event mutates no row and emits no effect; the refusal itself is reported.", + severity: "warning", + trigger: "on-refuse", + applicableStates: [], + enforcement: "check", + }, +]; diff --git a/src/folder-hsm/machine-definition.ts b/src/folder-hsm/machine-definition.ts index 2cdceed98..79c4d567b 100644 --- a/src/folder-hsm/machine-definition.ts +++ b/src/folder-hsm/machine-definition.ts @@ -1,26 +1,34 @@ /** - * FolderHSM Machine Definition + * FolderHSM Machine Definition — the folder postures. * - * The declarative state machine for shared-folder membership - * reconciliation. This constant is the single - * source of truth for all FolderHSM state transitions; it is interpreted - * by the merge-hsm machine interpreter. + * The declarative folder-level machine for shared-folder membership. + * This constant is the single source of truth for posture transitions; + * it is interpreted by the merge-hsm machine interpreter. Per-file + * decisions live in the entry machine (entry-machine.ts), whose rows the + * routing actions here address. * * Structural invariants encoded here (verified by __tests__/folder-hsm): - * - No effects are emitted from `loading` or `syncing`: those nodes - * grant no effect capabilities, and FolderHSM refuses to emit without one. - * - TRASH_LOCAL only from `tracking` (guid-matched MAP_DELTA delete) or - * `reconciling` (ladder rung 2): only those nodes grant canTrash. - * - Bootstrap-origin uploads only from `reconciling` (rungs 1 and 4); + * - No effects are emitted from `loading`, `syncing`, or `rebuilding`: + * those nodes grant no effect capabilities, and the emit chokepoint + * refuses without one. + * - Bootstrap-origin uploads dispatch only from `reconciling`; * interactive uploads also from `tracking`. - * - `rebuilding` exits into `reconciling`, never directly into `tracking`. + * - `rebuilding` exits into `reconciling`, never directly into + * `tracking`. + * + * Classification runs exactly once per (re)connect, plus once per trust + * or tier edge: a disconnect clears the session's sync claim + * (`reconnectPending`), the session's first confirmed exchange after a + * blind boot re-enters classification (`tierWasBlind`), and a + * classification pass deferred on pending sync state re-arms on the + * host-observed drain (`classificationDeferred`). */ import type { FolderEventHandler, FolderMachineDefinition } from "./types"; /** * Observations absorbed identically wherever classification is not yet - * allowed: context is updated, no effects are emitted. + * allowed: context evidence is updated, no effects are emitted. */ const ABSORB_OBSERVATIONS = ( target: "loading" | "syncing" | "rebuilding", @@ -33,6 +41,8 @@ const ABSORB_OBSERVATIONS = ( FILE_RENAMED: { target, actions: ["absorbLocalRename"] }, CONNECTED: { target, actions: ["setOnline"] }, DISCONNECTED: { target, actions: ["setOffline"] }, + AUTHORIZATION_CHANGED: { target, actions: ["recordAuthorization"] }, + SYNC_DRAINED: { target, actions: [] }, }); export const FOLDER_MACHINE: FolderMachineDefinition = { @@ -46,7 +56,7 @@ export const FOLDER_MACHINE: FolderMachineDefinition = { }, PROVIDER_SYNCED: { target: "loading", - actions: ["markProviderSynced"], + actions: ["recordTier"], reenter: true, }, ...ABSORB_OBSERVATIONS("loading"), @@ -58,7 +68,7 @@ export const FOLDER_MACHINE: FolderMachineDefinition = { on: { PROVIDER_SYNCED: { target: "syncing", - actions: ["markProviderSynced"], + actions: ["recordTier"], reenter: true, }, ...ABSORB_OBSERVATIONS("syncing"), @@ -66,9 +76,10 @@ export const FOLDER_MACHINE: FolderMachineDefinition = { always: [{ target: "reconciling", guard: "hydrated" }], }, - // Transient: the bootstrap provenance ladder runs over the evidence - // assembled during loading/syncing, then control always falls through - // to tracking. Exactly once per connect. + // Transient: classification visits every undecided row over the + // evidence assembled so far, then control always falls through to + // tracking. The entry action defers the whole pass when the live + // replica holds pending sync state (the trust gate). reconciling: { capabilities: { canEmitEffects: true, @@ -80,7 +91,7 @@ export const FOLDER_MACHINE: FolderMachineDefinition = { canMutateMap: true, canPark: true, }, - entry: ["runProvenanceLadder"], + entry: ["classifyUnclassifiedRows"], always: [{ target: "tracking" }], }, @@ -92,32 +103,84 @@ export const FOLDER_MACHINE: FolderMachineDefinition = { canDownload: true, canRenameLocal: true, canMutateMap: true, + // Parking must stay available in steady state: reclassification + // after a remote removal can land on the tombstone rung, and a + // refusal the user cannot reach is worse than one surfaced late. + canPark: true, }, on: { - MAP_DELTA: { target: "tracking", actions: ["applyMapDelta"] }, - FILE_DISCOVERED: { target: "tracking", actions: ["trackDiscoveredFile"] }, - FILE_CREATED: { target: "tracking", actions: ["handleInteractiveCreate"] }, - FILE_MODIFIED: { target: "tracking", actions: [] }, - FILE_DELETED: { target: "tracking", actions: ["handleLocalDelete"] }, - FILE_RENAMED: { target: "tracking", actions: ["handleLocalRename"] }, - UPLOAD_COMPLETE: { target: "tracking", actions: ["settleUpload"] }, - UPLOAD_FAILED: { target: "tracking", actions: [] }, - DOWNLOAD_COMPLETE: { target: "tracking", actions: ["settleDownload"] }, - DOWNLOAD_FAILED: { target: "tracking", actions: ["settleDownloadFailure"] }, - TRASH_COMPLETE: { target: "tracking", actions: ["settleTrash"] }, + MAP_DELTA: { target: "tracking", actions: ["routeDeltaToRows"] }, + FILE_DISCOVERED: { target: "tracking", actions: ["routeFileDiscovered"] }, + FILE_CREATED: { target: "tracking", actions: ["routeFileCreated"] }, + FILE_MODIFIED: { target: "tracking", actions: ["routeFileModified"] }, + FILE_DELETED: { target: "tracking", actions: ["routeFileDeleted"] }, + FILE_RENAMED: { target: "tracking", actions: ["routeFileRenamed"] }, + WORK_STARTED: { target: "tracking", actions: ["routeAckToRow"] }, + UPLOAD_COMPLETE: { target: "tracking", actions: ["routeCompletionToRow"] }, + UPLOAD_FAILED: { target: "tracking", actions: ["routeCompletionToRow"] }, + DOWNLOAD_COMPLETE: { + target: "tracking", + actions: ["routeCompletionToRow"], + }, + DOWNLOAD_FAILED: { + target: "tracking", + actions: ["routeCompletionToRow"], + }, + TRASH_COMPLETE: { target: "tracking", actions: ["routeCompletionToRow"] }, + DELETE_HELD: { target: "tracking", actions: ["routePolicyOutcomeToRows"] }, + DELETE_REPLICATED: { + target: "tracking", + actions: ["routePolicyOutcomeToRows"], + }, + DELETE_RESTORED: { + target: "tracking", + actions: ["routePolicyOutcomeToRows"], + }, + UNPARK_REQUESTED: { target: "tracking", actions: ["routeUserActionToRow"] }, + RESOLVE_CONFLICT: { target: "tracking", actions: ["routeUserActionToRow"] }, CONNECTED: { target: "tracking", actions: ["setOnline"] }, - // Connection is an input, not a state: offline tracking continues - // to record local intent. + // Connection is an input, not a posture: offline tracking + // continues to record local intent. DISCONNECTED: { target: "tracking", actions: ["setOffline"] }, - // The ladder reruns exactly once per connect: only a resync after - // a disconnect re-enters reconciling. + // Classification re-runs on a resync after a disconnect, and on + // the session's first confirmed exchange after a blind boot — + // the blind pass classified before the provider delivered + // anything, so the handshake's truth revisits it. A repeat sync + // on a live confirmed connection stays a no-op. PROVIDER_SYNCED: [ { target: "reconciling", guard: "reconnectPending", - actions: ["markProviderSynced"], + actions: ["recordTier"], + }, + { + target: "reconciling", + guard: "tierWasBlind", + actions: ["recordTier"], + }, + { target: "tracking", actions: ["recordTier"] }, + ], + // A classification pass deferred on pending sync state re-runs + // when the host observes the drain; otherwise a drain is a + // no-op. + SYNC_DRAINED: [ + { target: "reconciling", guard: "classificationDeferred" }, + { target: "tracking" }, + ], + // A widened authorization re-enters classification — the same + // shape as the tier edge — so publication verdicts gated by the + // old scope re-decide and dispatch from the posture that grants + // them. A narrowed scope only records; nothing is retracted. + AUTHORIZATION_CHANGED: [ + { + target: "reconciling", + guard: "authorizationExpanded", + actions: ["recordAuthorization"], + }, + { + target: "tracking", + actions: ["recordAuthorization", "revisitGatedRows"], }, - { target: "tracking", actions: ["markProviderSynced"] }, ], REBUILD_STARTED: { target: "rebuilding" }, }, @@ -127,8 +190,11 @@ export const FOLDER_MACHINE: FolderMachineDefinition = { // nothing; evidence is re-derived in reconciling on the way out. rebuilding: { on: { - PERSISTENCE_LOADED: { target: "rebuilding", actions: ["markPersistenceLoaded"] }, - PROVIDER_SYNCED: { target: "rebuilding", actions: ["markProviderSynced"] }, + PERSISTENCE_LOADED: { + target: "rebuilding", + actions: ["markPersistenceLoaded"], + }, + PROVIDER_SYNCED: { target: "rebuilding", actions: ["recordTier"] }, ...ABSORB_OBSERVATIONS("rebuilding"), REBUILD_COMPLETE: { target: "reconciling" }, }, diff --git a/src/folder-hsm/pending-sync.ts b/src/folder-hsm/pending-sync.ts new file mode 100644 index 000000000..4d3050849 --- /dev/null +++ b/src/folder-hsm/pending-sync.ts @@ -0,0 +1,70 @@ +/** + * Trust probes for the folder docs, and the drain observer that re-arms + * a deferred classification pass. + * + * A deletion can reach a replica as sync data that cannot yet apply (its + * dependencies never arrived, or a delete set references structs this + * replica never held). It then parks in the doc's pending state and the + * membership map understates remote deletions: a deleted path reads as + * never present. While any folder doc holds such state, classification + * must not read the map (see FolderHSM.runClassification); once it + * drains, the deletion stands as an ordinary tombstone and + * classification can run honestly. + * + * The probe reads the LIVE docs only — a persisted readiness marker can + * declare a folder synced while the current session's exchange is + * incomplete or faulty. + */ + +import type * as Y from "yjs"; +import type { FolderEvent } from "./types"; + +/** The slice of the machine the drain observer needs. */ +interface DeferredClassificationHost { + context: { classificationDeferred: boolean }; + send(event: FolderEvent): void; +} + +/** + * Whether any of the given docs holds pending sync state. Tolerates + * docs that are not fully constructed (test harnesses, teardown races): + * no readable store means nothing pending. + */ +export function docsHavePendingSyncState( + ...docs: Array +): boolean { + return docs.some((doc) => { + const store = doc?.store as + | { pendingStructs?: unknown; pendingDs?: unknown } + | undefined; + if (store == null) return false; + return store.pendingStructs != null || store.pendingDs != null; + }); +} + +/** + * Report the drain of pending sync state to a machine whose + * classification pass deferred on it. Every folder-doc transaction that + * integrates structs can complete the drain, so the probe runs after + * each update; the checks are two null reads and the machine consumes + * SYNC_DRAINED only while a deferred pass is armed. Returns the + * uninstaller. + */ +export function observeSyncDrain( + hsm: DeferredClassificationHost, + docs: Array, +): () => void { + const live = docs.filter((doc): doc is Y.Doc => doc != null); + const onUpdate = () => { + if ( + hsm.context.classificationDeferred && + !docsHavePendingSyncState(...live) + ) { + hsm.send({ type: "SYNC_DRAINED" }); + } + }; + for (const doc of live) doc.on("update", onUpdate); + return () => { + for (const doc of live) doc.off("update", onUpdate); + }; +} diff --git a/src/folder-hsm/persistence/FolderHSMStore.ts b/src/folder-hsm/persistence/FolderHSMStore.ts new file mode 100644 index 000000000..ad9b4482b --- /dev/null +++ b/src/folder-hsm/persistence/FolderHSMStore.ts @@ -0,0 +1,350 @@ +/** + * FolderHSM IndexedDB Persistence + * + * Database: {appId}-folder-hsm + * Stores: + * - states: one row per shared folder (PersistedFolderState), keyed by + * the folder guid. + * + * The row persists exactly the fork-class subset of the engine's state: + * + * - `fork` — the withheld local deletion divergence pending explicit + * resolution (the intent half): the held burst's deletions with the + * identity each observed at decision time, so resolution replays a + * deletion only while its target is still the thing it decided about; + * - `retained` — the deferred-teardown ledger (the content half): the + * entries that keep deleted documents' local data restorable until + * they expire. It sits at row level, not inside the fork, because it + * serves every deletion's undo (single deletes included), not only + * gated bursts. + * + * Nothing else persists: the entry table is in-memory authority, rebuilt + * at every boot by classification. Writes flow only through the folder + * machine's PERSIST_STATE effect (plus the one-time migration of the + * legacy custom-store keys); retirement is bound to the folder's + * lifecycle — unsharing the folder removes the row. + */ + +import * as idb from "lib0/indexeddb"; +import { DestroyedError, isDestroyedError } from "../../DestroyedError"; + +// ============================================================================= +// Database Configuration +// ============================================================================= + +const getDbName = (appId: string) => `${appId}-folder-hsm`; + +const STORES = { + states: "states", +} as const; + +const DESTROY_DRAIN_TIMEOUT_MS = 2000; + +export const PERSISTED_FOLDER_STATE_VERSION = 1; + +// ============================================================================= +// Row schema +// ============================================================================= + +export interface FolderForkDelete { + mapName: string; + key: string; + /** + * The identity observed at decision time. Resolution replays the + * deletion only while the committed value still carries it; a + * mismatch surfaces instead of destroying. Absent only on rows + * migrated from the legacy format, which recorded no identity. + */ + guid?: string; +} + +/** The withheld deletion divergence pending explicit resolution. */ +export interface FolderFork { + deletes: FolderForkDelete[]; + origin: "bulk-delete" | "root-detach"; + created: number; + captureMark?: number; +} + +/** One deleted document whose local data is retained for undo. */ +export interface RetainedDoc { + guid: string; + /** Path at deletion time, for restore placement. */ + path: string; + expiresAt: number; +} + +/** + * A cache of the provider-side folder doc, written at natural sync + * moments (handshake complete, drain) — never streamed per-update. + * Bounded staleness is acceptable: it is server-owned state, and loss + * only costs a fuller resync. The provider doc boots from it when + * present, from empty when absent. + */ +export interface RemoteIndexCache { + snapshot: Uint8Array; + stateVector: Uint8Array; + /** When the picture was taken. */ + updated: number; +} + +export interface PersistedFolderState { + /** The folder guid — the row key. */ + guid: string; + version: number; + fork: FolderFork | null; + retained: RetainedDoc[]; + remoteIndex: RemoteIndexCache | null; +} + +/** + * Allow-list every field so nothing beyond the approved subset (and no + * non-serializable value) reaches IDB's structured clone algorithm. + */ +export function sanitizeFolderState( + state: PersistedFolderState, +): PersistedFolderState { + return { + guid: state.guid, + version: PERSISTED_FOLDER_STATE_VERSION, + fork: state.fork + ? { + deletes: state.fork.deletes.map((deleted) => ({ + mapName: deleted.mapName, + key: deleted.key, + ...(deleted.guid !== undefined + ? { guid: deleted.guid } + : {}), + })), + origin: state.fork.origin, + created: state.fork.created, + ...(state.fork.captureMark !== undefined + ? { captureMark: state.fork.captureMark } + : {}), + } + : null, + retained: (state.retained ?? []).map((doc) => ({ + guid: doc.guid, + path: doc.path, + expiresAt: doc.expiresAt, + })), + remoteIndex: state.remoteIndex + ? { + snapshot: state.remoteIndex.snapshot, + stateVector: state.remoteIndex.stateVector, + updated: state.remoteIndex.updated, + } + : null, + }; +} + +// ============================================================================= +// FolderHSMStore +// ============================================================================= + +/** + * Persistence layer for the folder membership engine's fork-class state. + * Holds a single IDB connection and tracks pending writes so callers can + * flush before teardown. + */ +export class FolderHSMStore { + private _openDb: Promise; + private _db: Promise; + private _dbInstance: IDBDatabase | null = null; + private _pendingWrites = new Set>(); + private _destroyed = false; + private _dbClosed = false; + private _destroyPromise: Promise | null = null; + private _rejectDbForDestroy?: (reason?: unknown) => void; + + constructor(appId: string) { + this._openDb = idb + .openDB(getDbName(appId), (db) => { + idb.createStores(db, [[STORES.states, { keyPath: "guid" }]]); + }) + .then((db) => { + this._dbInstance = db; + if (this._destroyed) { + this._closeDb(db); + this._dbInstance = null; + } + return db; + }); + + this._db = new Promise((resolve, reject) => { + this._rejectDbForDestroy = reject; + this._openDb.then(resolve, reject); + }); + void this._db.catch(() => {}); + } + + async saveState(guid: string, state: PersistedFolderState): Promise { + if (this._destroyed) return; + const sanitized = sanitizeFolderState({ ...state, guid }); + const p = this._db + .then((db) => { + if (this._dbClosed) return; + const [store] = idb.transact(db, [STORES.states], "readwrite"); + return idb.put(store, sanitized as unknown as string); + }) + .catch((err) => { + if (this._shouldIgnoreClosingError(err)) return; + throw err; + }); + this._trackWrite(p); + await p; + } + + async loadState(guid: string): Promise { + return this._read(null, async (db) => { + const [store] = idb.transact(db, [STORES.states], "readonly"); + const result = await idb.get(store, guid); + return (result as unknown as PersistedFolderState) ?? null; + }); + } + + async deleteState(guid: string): Promise { + if (this._destroyed) return; + const p = this._db + .then((db) => { + if (this._dbClosed) return; + const [store] = idb.transact(db, [STORES.states], "readwrite"); + return idb.del(store, guid); + }) + .catch((err) => { + if (this._shouldIgnoreClosingError(err)) return; + throw err; + }); + this._trackWrite(p); + await p; + } + + // =========================================================================== + // Lifecycle + // =========================================================================== + + /** Wait for all in-flight writes to complete. */ + async flush(): Promise { + if (this._pendingWrites.size > 0) { + await Promise.allSettled([...this._pendingWrites]); + } + } + + /** Flush pending writes and close the database connection. */ + async destroy(): Promise { + if (this._destroyPromise) return this._destroyPromise; + this._destroyPromise = (async () => { + this._destroyed = true; + this._rejectDbForDestroy?.( + new DestroyedError( + "FolderHSMStore", + "destroyed before IndexedDB settled", + ), + ); + this._rejectDbForDestroy = undefined; + + const db = this._dbInstance; + if (!db) { + this._pendingWrites.clear(); + return; + } + + try { + await this._drainPendingWritesForDestroy(); + } finally { + this._dbClosed = true; + this._closeDb(db); + this._dbInstance = null; + this._pendingWrites.clear(); + } + })(); + return this._destroyPromise; + } + + // =========================================================================== + // Internals + // =========================================================================== + + private async _read( + fallback: T, + fn: (db: IDBDatabase) => Promise, + ): Promise { + try { + const db = await this._db; + return await fn(db); + } catch (err) { + if (this._shouldIgnoreClosingError(err)) return fallback; + throw err; + } + } + + private _trackWrite(p: Promise): void { + this._pendingWrites.add(p); + void p.then( + () => this._pendingWrites.delete(p), + () => this._pendingWrites.delete(p), + ); + } + + private async _drainPendingWritesForDestroy(): Promise { + while (this._pendingWrites.size > 0) { + const pendingWrites = Promise.allSettled([ + ...this._pendingWrites, + ]).then(() => undefined); + const drained = await this._settleOrTimeout(pendingWrites); + if (!drained) return; + } + } + + private _settleOrTimeout(promise: Promise): Promise { + return new Promise((resolve) => { + let finished = false; + const finish = (settled: boolean) => { + if (finished) return; + finished = true; + globalThis.clearTimeout(timer); + resolve(settled); + }; + const timer = globalThis.setTimeout( + () => finish(false), + DESTROY_DRAIN_TIMEOUT_MS, + ); + promise.then( + () => finish(true), + () => finish(true), + ); + }); + } + + private _closeDb(db: IDBDatabase): void { + // lib0/indexeddb.openDB installs `db.onversionchange = () => + // db.close()`; leaving the handler attached after close() pins the + // module's lexical scope until the browser fully releases the + // connection, which races plugin reload. Clear the IDL handlers so + // the wrapper can be collected. + (db as IDBDatabase & { onversionchange: unknown }).onversionchange = + null; + (db as IDBDatabase & { onerror: unknown }).onerror = null; + (db as IDBDatabase & { onabort: unknown }).onabort = null; + (db as IDBDatabase & { onclose: unknown }).onclose = null; + db.close(); + } + + private _shouldIgnoreClosingError(err: unknown): boolean { + if (isDestroyedError(err)) return true; + if (!this._destroyed) return false; + if (!(err instanceof DOMException)) return false; + return err.name === "InvalidStateError"; + } +} + +/** + * Delete the entire database (vault-level cleanup). + */ +export async function deleteFolderHSMDatabase(appId: string): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(getDbName(appId)); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); +} diff --git a/src/folder-hsm/testing/createTestEngine.ts b/src/folder-hsm/testing/createTestEngine.ts new file mode 100644 index 000000000..a8b7f1540 --- /dev/null +++ b/src/folder-hsm/testing/createTestEngine.ts @@ -0,0 +1,269 @@ +/** + * Test factory for the folder membership engine: an engine instance + * bound to an in-memory substrate (map replica with tombstones, local + * tree, record store, hold store), with effect capture, transition + * capture, invariant-violation capture, and snapshot inspection. + * Deterministic scheduling; no real timers or I/O. + * + * The substrate models what survives a restart: the committed map and + * its tombstones, the local tree, the persisted records, and the + * persisted upload holds. `restart()` builds a fresh engine over the + * same substrate — the entry table itself is in-memory authority and is + * deliberately lost. + */ + +import { FolderHSM } from "../FolderHSM"; +import type { + FolderEffect, + FolderEvent, + FolderHSMConfig, + FolderInvariantViolation, + FolderStatePath, + MapEntrySummary, + MembershipEntry, +} from "../types"; +import * as events from "./events"; + +export interface TestSubstrate { + /** The committed membership map: path → identity. */ + index: Map; + /** Native tombstones: paths whose most recent map entry is a deletion. */ + tombstones: Set; + /** The local tree: what is actually on disk. */ + localTree: Set; + /** Persisted device-local records: path → identity + agreement flag. */ + records: Map; + /** Persisted upload holds: path → minted identity. */ + holds: Map; + /** The live replica's trust probe result. */ + pendingSyncState: boolean; +} + +export interface Transition { + from: FolderStatePath; + to: FolderStatePath; + eventType: string; +} + +export interface TestEngineOptions { + substrate?: Partial; + /** Automatically acknowledge dispatched work with WORK_STARTED. */ + autoAck?: boolean; + /** Mint fresh identities for upload executions (host behavior). */ + mintOnUpload?: boolean; +} + +export interface TestEngine { + engine: FolderHSM; + substrate: TestSubstrate; + effects: FolderEffect[]; + transitions: Transition[]; + violations: FolderInvariantViolation[]; + /** Every event sent, in order — the recording for replay. */ + recorded: FolderEvent[]; + send(event: FolderEvent): void; + /** PERSISTENCE_LOADED + confirmed PROVIDER_SYNCED. */ + hydrate(): void; + /** PERSISTENCE_LOADED + blind PROVIDER_SYNCED (the persisted marker). */ + hydrateBlind(): void; + /** Discover local files (adds to the tree and reports to the engine). */ + discover(...paths: string[]): void; + /** Effects of one type. */ + effectsOf( + type: T, + ): Array>; + entryFor(path: string): MembershipEntry | undefined; + rowState(path: string): string | undefined; + /** Substrate mutations that also deliver the matching delta. */ + remoteAdd(path: string, guid: string, type?: string): void; + remoteRemove(path: string): void; + remoteMove(guid: string, from: string, to: string): void; + /** A fresh engine over the same substrate (the restart boundary). */ + restart(options?: Omit): TestEngine; +} + +function buildSubstrate(overrides: Partial): TestSubstrate { + return { + index: overrides.index ?? new Map(), + tombstones: overrides.tombstones ?? new Set(), + localTree: overrides.localTree ?? new Set(), + records: overrides.records ?? new Map(), + holds: overrides.holds ?? new Map(), + pendingSyncState: overrides.pendingSyncState ?? false, + }; +} + +export function createTestEngine( + options: TestEngineOptions = {}, +): TestEngine { + const substrate = buildSubstrate(options.substrate ?? {}); + return attachEngine(substrate, options); +} + +function attachEngine( + substrate: TestSubstrate, + options: Omit, +): TestEngine { + const effects: FolderEffect[] = []; + const transitions: Transition[] = []; + const violations: FolderInvariantViolation[] = []; + const recorded: FolderEvent[] = []; + const autoAck = options.autoAck ?? false; + const mintOnUpload = options.mintOnUpload ?? true; + + const pendingSends: FolderEvent[] = []; + // Deterministic per-engine minting so recorded runs replay to + // identical identities. + let mintCounter = 0; + let engine: FolderHSM; + + const config: FolderHSMConfig = { + folderGuid: "test-folder", + listMapEntries: (): MapEntrySummary[] => + Array.from(substrate.index.entries()).map(([path, entry]) => ({ + path, + guid: entry.guid, + type: entry.type, + })), + getMapEntry: (path: string) => { + const entry = substrate.index.get(path); + return entry + ? { path, guid: entry.guid, type: entry.type } + : undefined; + }, + pathTombstoned: (path: string) => substrate.tombstones.has(path), + records: { + getRecordGuid: (path: string) => substrate.records.get(path)?.guid, + recordMatchesDisk: (path: string) => + substrate.records.get(path)?.matchesDisk ?? false, + retireRecord: (path: string) => { + substrate.records.delete(path); + }, + moveRecord: (from: string, to: string) => { + const record = substrate.records.get(from); + if (!record) return; + substrate.records.delete(from); + substrate.records.set(to, record); + }, + }, + holds: { + getHold: (path: string) => substrate.holds.get(path), + moveHold: (from: string, to: string) => { + const guid = substrate.holds.get(from); + if (guid === undefined) return; + substrate.holds.delete(from); + substrate.holds.set(to, guid); + }, + }, + hasPendingSyncState: () => substrate.pendingSyncState, + mergeableKind: (fileType?: string) => fileType === "markdown", + onEffect: (effect) => { + effects.push(effect); + // The minimal host: executing an upload mints (or reuses) the + // hold identity, exactly like the production enrollment path. + if (effect.type === "ENQUEUE_UPLOAD" && mintOnUpload) { + if (!substrate.holds.has(effect.path)) { + substrate.holds.set(effect.path, `minted-${++mintCounter}`); + } + if (autoAck) { + pendingSends.push( + events.workStarted( + "upload", + effect.path, + substrate.holds.get(effect.path)!, + ), + ); + } + } + if (effect.type === "ENQUEUE_DOWNLOAD" && autoAck) { + pendingSends.push( + events.workStarted("download", effect.path, effect.guid), + ); + } + if (effect.type === "RETRACT_UPLOAD" && effect.releaseHold) { + substrate.holds.delete(effect.path); + } + }, + onTransition: (from, to, eventType) => + transitions.push({ from, to, eventType }), + onInvariantViolation: (violation) => violations.push(violation), + }; + + engine = new FolderHSM(config); + + const send = (event: FolderEvent): void => { + recorded.push(event); + engine.send(event); + while (pendingSends.length > 0) { + const queued = pendingSends.shift()!; + recorded.push(queued); + engine.send(queued); + } + }; + + const discover = (...paths: string[]): void => { + for (const path of paths) { + substrate.localTree.add(path); + send(events.fileDiscovered(path)); + } + }; + + const test: TestEngine = { + engine, + substrate, + effects, + transitions, + violations, + recorded, + send, + hydrate: () => { + send(events.persistenceLoaded()); + send(events.providerSynced()); + }, + hydrateBlind: () => { + send(events.persistenceLoaded()); + send(events.providerSynced("blind")); + }, + discover, + effectsOf: (type) => + effects.filter( + (effect): effect is never => effect.type === type, + ) as never, + entryFor: (path: string) => + engine.getSnapshot().entries.find((entry) => entry.path === path), + rowState: (path: string) => engine.getRowState(path), + remoteAdd: (path, guid, type) => { + substrate.index.set(path, { guid, type }); + substrate.tombstones.delete(path); + send(events.mapDelta({ adds: [{ path, guid, type }] })); + }, + remoteRemove: (path) => { + const existing = substrate.index.get(path); + substrate.index.delete(path); + substrate.tombstones.add(path); + send( + events.mapDelta({ + deletes: [ + { + path, + oldValue: existing + ? { id: existing.guid, type: existing.type } + : undefined, + }, + ], + }), + ); + }, + remoteMove: (guid, from, to) => { + const existing = substrate.index.get(from); + substrate.index.delete(from); + substrate.tombstones.add(from); + substrate.index.set(to, { guid, type: existing?.type }); + substrate.tombstones.delete(to); + send(events.mapDelta({ moves: [{ guid, from, to }] })); + }, + restart: (restartOptions = {}) => + attachEngine(substrate, { ...options, ...restartOptions }), + }; + return test; +} diff --git a/src/folder-hsm/testing/events.ts b/src/folder-hsm/testing/events.ts new file mode 100644 index 000000000..604c6da79 --- /dev/null +++ b/src/folder-hsm/testing/events.ts @@ -0,0 +1,152 @@ +/** + * Event helpers for the folder membership engine: factory functions + * returning plain event objects, shared between tests and the recorder. + */ + +import type { + AuthorizationScope, + FileOrigin, + FolderEvent, + LocalFileKind, + MapDeltaAdd, + MapDeltaDelete, + MapDeltaMove, +} from "../types"; + +export const load = (): FolderEvent => ({ type: "LOAD" }); + +export const persistenceLoaded = (): FolderEvent => ({ + type: "PERSISTENCE_LOADED", +}); + +export const providerSynced = (tier?: "blind" | "confirmed"): FolderEvent => ({ + type: "PROVIDER_SYNCED", + ...(tier !== undefined ? { tier } : {}), +}); + +export const connected = (): FolderEvent => ({ type: "CONNECTED" }); + +export const disconnected = (): FolderEvent => ({ type: "DISCONNECTED" }); + +export const syncDrained = (): FolderEvent => ({ type: "SYNC_DRAINED" }); + +export const authorizationChanged = ( + scope: AuthorizationScope, +): FolderEvent => ({ type: "AUTHORIZATION_CHANGED", scope }); + +export const mapDelta = ( + delta: Partial<{ + adds: MapDeltaAdd[]; + updates: MapDeltaAdd[]; + deletes: MapDeltaDelete[]; + moves: MapDeltaMove[]; + }> = {}, +): FolderEvent => ({ + type: "MAP_DELTA", + adds: delta.adds ?? [], + updates: delta.updates ?? [], + deletes: delta.deletes ?? [], + moves: delta.moves ?? [], +}); + +export const fileDiscovered = ( + path: string, + origin: FileOrigin = "bootstrap", + kind?: LocalFileKind, +): FolderEvent => ({ + type: "FILE_DISCOVERED", + path, + origin, + ...(kind !== undefined ? { kind } : {}), +}); + +export const fileCreated = ( + path: string, + kind?: LocalFileKind, +): FolderEvent => ({ + type: "FILE_CREATED", + path, + ...(kind !== undefined ? { kind } : {}), +}); + +export const fileModified = (path: string): FolderEvent => ({ + type: "FILE_MODIFIED", + path, +}); + +export const fileDeleted = (path: string): FolderEvent => ({ + type: "FILE_DELETED", + path, +}); + +export const fileRenamed = (from: string, to: string): FolderEvent => ({ + type: "FILE_RENAMED", + from, + to, +}); + +export const workStarted = ( + kind: "upload" | "download", + path: string, + guid: string, +): FolderEvent => ({ type: "WORK_STARTED", kind, path, guid }); + +export const uploadComplete = (path: string, guid: string): FolderEvent => ({ + type: "UPLOAD_COMPLETE", + path, + guid, +}); + +export const uploadFailed = (path: string, guid?: string): FolderEvent => ({ + type: "UPLOAD_FAILED", + path, + ...(guid !== undefined ? { guid } : {}), +}); + +export const downloadComplete = (path: string, guid: string): FolderEvent => ({ + type: "DOWNLOAD_COMPLETE", + path, + guid, +}); + +export const downloadFailed = (path: string, guid: string): FolderEvent => ({ + type: "DOWNLOAD_FAILED", + path, + guid, +}); + +export const trashComplete = ( + path: string, + guid: string | null, +): FolderEvent => ({ type: "TRASH_COMPLETE", path, guid }); + +export const deleteHeld = (paths: string[]): FolderEvent => ({ + type: "DELETE_HELD", + paths, +}); + +export const deleteReplicated = (paths: string[]): FolderEvent => ({ + type: "DELETE_REPLICATED", + paths, +}); + +export const deleteRestored = (paths: string[]): FolderEvent => ({ + type: "DELETE_RESTORED", + paths, +}); + +export const unparkRequested = (path: string): FolderEvent => ({ + type: "UNPARK_REQUESTED", + path, +}); + +export const resolveConflict = ( + path: string, + verdict: "keep-local" | "keep-remote", +): FolderEvent => ({ type: "RESOLVE_CONFLICT", path, verdict }); + +export const rebuildStarted = (): FolderEvent => ({ type: "REBUILD_STARTED" }); + +export const rebuildComplete = (): FolderEvent => ({ + type: "REBUILD_COMPLETE", +}); diff --git a/src/folder-hsm/testing/index.ts b/src/folder-hsm/testing/index.ts new file mode 100644 index 000000000..225b5f59d --- /dev/null +++ b/src/folder-hsm/testing/index.ts @@ -0,0 +1,8 @@ +export { + createTestEngine, + type TestEngine, + type TestEngineOptions, + type TestSubstrate, + type Transition, +} from "./createTestEngine"; +export * as folderEvents from "./events"; diff --git a/src/folder-hsm/types.ts b/src/folder-hsm/types.ts index 0ef8e60dd..fbcab275b 100644 --- a/src/folder-hsm/types.ts +++ b/src/folder-hsm/types.ts @@ -1,32 +1,80 @@ /** * FolderHSM Types * - * Type definitions for the per-shared-folder membership state machine. - * Where MergeHSM reconciles three representations of one - * document's content, FolderHSM reconciles two representations of the - * folder's membership (the local file tree and the map) plus local - * records. + * Type definitions for the per-shared-folder membership engine. Where + * MergeHSM reconciles three representations of one document's content, + * FolderHSM reconciles two representations of the folder's membership + * (the local file tree and the map) plus device-local records. + * + * The engine is two declarative constants executed together: + * + * - the FOLDER machine (machine-definition.ts) — postures describing what + * the folder as a whole is doing, interpreted by the shared merge-hsm + * machine interpreter; + * - the ENTRY machine (entry-machine.ts) — per-file states held as a + * keyed row table inside the folder machine's context, ticked + * synchronously from the folder machine's actions. * * The declarative machine shape (StateNode / TransitionCandidate / * EventHandler) mirrors merge-hsm/types.ts so the same interpreter can - * drive both machines. + * drive the folder machine. */ // ============================================================================= -// States +// Folder states // ============================================================================= export type FolderStatePath = | "loading" // persistence loading; observations absorbed, no effects - | "syncing" // persistence loaded, awaiting first provider sync (hydration) - | "reconciling" // bootstrap: the provenance ladder, exactly once per connect - | "tracking" // steady state: map deltas and vault events applied incrementally + | "syncing" // persistence loaded, awaiting hydration + | "reconciling" // transient: classification visits every undecided row + | "tracking" // steady state: deltas and file events drive rows incrementally | "rebuilding"; // wholesale doc replacement; exits into reconciling only +// ============================================================================= +// Entry states (the per-file machine) +// ============================================================================= + +/** + * Per-file state paths. The intent/execution split is expressed in the + * dotted hierarchy so invariants and capability rules can scope to + * prefixes (`upload.` covers both children). + */ +export type EntryStatePath = + | "unclassified" // known but undecided; the explicit waiting state + | "synced" // local file, map entry, and content evidence agree + | "upload.held" // publication decided; hold minted; dispatch gated + | "upload.inFlight" // host acknowledged the upload; adopt, never re-emit + | "download.pending" // materialization decided; not yet acknowledged + | "download.inFlight" // host acknowledged the download + | "trashing" // recoverable destruction instructed + | "renaming" // local rename instructed; awaiting the platform echo + | "delete.pending" // local deletion decided outbound; carries observed identity + | "delete.held" // the outbound deletion policy holds the burst (a fork) + | "parked" // publication refused, reason recorded; surfaced + | "conflicted"; // positive evidence disagrees on both sides; surfaced + +/** + * Transition targets: a state path, or the reserved row-lifecycle + * instruction `retired` — not a state but "remove this row and retire + * its local record". + */ +export type EntryTarget = EntryStatePath | "retired"; + // ============================================================================= // Membership context // ============================================================================= +/** Session confidence: the quality of the folder picture decisions run under. */ +export type ConfidenceTier = "none" | "blind" | "confirmed"; + +export type AuthorizationScope = "write" | "read-only"; + +/** + * Compatibility projection of an entry row for status surfaces and the + * recovery sweep. `conflicted` is additive; all other members predate the + * entry machine. + */ export type Disposition = | "synced" | "pendingUpload" @@ -34,7 +82,8 @@ export type Disposition = | "pendingDownload" | "pendingTrash" | "pendingMapDelete" - | "pendingRename"; + | "pendingRename" + | "conflicted"; export interface MembershipEntry { /** Durable identity; null until a guid is minted for a pending upload. */ @@ -47,27 +96,61 @@ export type FileOrigin = "bootstrap" | "interactive"; export type LocalFileKind = "file" | "folder"; +/** How the row's recorded content evidence relates to the file on disk. */ +export type ContentAgreement = "unknown" | "agrees" | "stale"; + +/** One row of the keyed entry table: the machine's authority for one file. */ +export interface EntryRow { + /** guid when known, or a path sentinel pre-mint. */ + key: string; + path: string; + state: EntryStatePath; + guid: string | null; + origin: FileOrigin; + kind: LocalFileKind; + /** Confidence tier the current state was decided under. */ + decidedTier: ConfidenceTier; + /** Whether the current intent's effect actually emitted (dispatch gate). */ + dispatched: boolean; + /** Content evidence freshness; FILE_MODIFIED marks it stale. */ + contentAgreement: ContentAgreement; + /** What outbound destructive intent targeted (deletes and renames). */ + observedIdentity?: { guid: string; path: string }; + /** parked / conflicted surfacing. */ + reason?: string; +} + export interface FolderContext { persistenceLoaded: boolean; + /** Session confidence tier. */ + tier: ConfidenceTier; + /** A sync claim is live this session (cleared by DISCONNECTED). */ providerSynced: boolean; isOnline: boolean; - /** Membership table keyed by guid (or a path sentinel pre-mint). */ - entries: Map; - /** Reverse index: path → entries key. */ - entryKeyByPath: Map; + authorization: AuthorizationScope; + /** The keyed entry table: one row per file. */ + rows: Map; + /** Reverse index: path → rows key. */ + rowKeyByPath: Map; /** Discovered local file tree with how each file became known. */ localFiles: Map; /** - * Interactive deletes observed before hydration. The ladder honors them - * as recorded local intent (MAP_DELETE) instead of re-downloading. + * Local deletes observed before hydration. Classification honors them + * as recorded local intent instead of re-downloading. */ - locallyDeleted: Set; - /** Parked paths with a human-readable reason. */ - parked: Map; + recordedDeleteIntents: Set; + /** + * Classification was skipped because the folder doc held pending sync + * state (an undelivered deletion reads as a never-present path). + * Re-armed by SYNC_DRAINED. + */ + classificationDeferred: boolean; + /** Bumped by every row/context mutation; drives PERSIST_STATE. */ + revision: number; } // ============================================================================= -// Events +// Folder events (the host-facing union) // ============================================================================= export interface MapEntrySummary { @@ -97,9 +180,17 @@ export interface MapDeltaMove { export type FolderEvent = | { type: "LOAD" } | { type: "PERSISTENCE_LOADED" } - | { type: "PROVIDER_SYNCED" } + /** + * The provider claims sync. `tier: "blind"` marks a claim derived from + * the persisted has-synced marker before any live exchange this + * session; an absent tier is a completed live exchange (confirmed). + */ + | { type: "PROVIDER_SYNCED"; tier?: "blind" | "confirmed" } | { type: "CONNECTED" } | { type: "DISCONNECTED" } + /** The folder doc's pending sync state drained (host-observed). */ + | { type: "SYNC_DRAINED" } + | { type: "AUTHORIZATION_CHANGED"; scope: AuthorizationScope } | { type: "MAP_DELTA"; adds: MapDeltaAdd[]; @@ -118,14 +209,75 @@ export type FolderEvent = | { type: "FILE_MODIFIED"; path: string } | { type: "FILE_DELETED"; path: string } | { type: "FILE_RENAMED"; from: string; to: string } + /** The host durably accepted enqueued work (the acknowledgment). */ + | { + type: "WORK_STARTED"; + kind: "upload" | "download"; + path: string; + guid: string; + } | { type: "UPLOAD_COMPLETE"; path: string; guid: string } | { type: "UPLOAD_FAILED"; path: string; guid?: string } | { type: "DOWNLOAD_COMPLETE"; path: string; guid: string } | { type: "DOWNLOAD_FAILED"; path: string; guid: string } | { type: "TRASH_COMPLETE"; path: string; guid: string | null } + /** Outbound deletion policy outcomes, per burst. */ + | { type: "DELETE_HELD"; paths: string[] } + | { type: "DELETE_REPLICATED"; paths: string[] } + | { type: "DELETE_RESTORED"; paths: string[] } + | { type: "UNPARK_REQUESTED"; path: string } + | { + type: "RESOLVE_CONFLICT"; + path: string; + verdict: "keep-local" | "keep-remote"; + } | { type: "REBUILD_STARTED" } | { type: "REBUILD_COMPLETE" }; +// ============================================================================= +// Entry events (folder events routed to rows) +// ============================================================================= + +export type EntryEvent = + | { type: "CLASSIFY" } + | { type: "MAP_ADDED"; path: string; guid: string; fileType?: string } + | { type: "MAP_UPDATED"; path: string; guid: string; fileType?: string } + | { type: "MAP_REMOVED"; path: string; guid?: string } + | { type: "MAP_MOVED"; guid: string; from: string; to: string } + | { + type: "FILE_DISCOVERED"; + path: string; + origin: FileOrigin; + kind?: LocalFileKind; + } + | { type: "FILE_CREATED"; path: string; kind?: LocalFileKind } + | { type: "FILE_MODIFIED"; path: string } + | { type: "FILE_DELETED"; path: string } + | { type: "FILE_RENAMED_AWAY"; from: string; to: string } + | { type: "FILE_RENAMED_IN"; from: string; to: string } + | { + type: "WORK_STARTED"; + kind: "upload" | "download"; + path: string; + guid: string; + } + | { type: "UPLOAD_COMPLETE"; path: string; guid: string } + | { type: "UPLOAD_FAILED"; path: string; guid?: string } + | { type: "DOWNLOAD_COMPLETE"; path: string; guid: string } + | { type: "DOWNLOAD_FAILED"; path: string; guid: string } + | { type: "TRASH_COMPLETE"; path: string; guid: string | null } + | { type: "DELETE_HELD"; paths: string[] } + | { type: "DELETE_REPLICATED"; paths: string[] } + | { type: "DELETE_RESTORED"; paths: string[] } + | { type: "UNPARK_REQUESTED"; path: string } + | { + type: "RESOLVE_CONFLICT"; + path: string; + verdict: "keep-local" | "keep-remote"; + }; + +export type EntryEventType = EntryEvent["type"]; + // ============================================================================= // Effects (executed by the host) // ============================================================================= @@ -137,8 +289,29 @@ export type FolderEffect = | { type: "RENAME_LOCAL"; from: string; to: string; guid: string } | { type: "MAP_SET"; path: string; oldPath?: string; guid?: string } | { type: "MAP_DELETE"; path: string; guid?: string } + /** + * Withdraw queued upload work. `releaseHold` distinguishes the two + * sanctioned forms: true also releases the persisted hold (the local + * file is gone, or a committed identity superseded the mint); false + * cancels the queued work but PRESERVES the hold — a persisted hold + * marks content the server does not have, and that identity is never + * discarded without a completed publication or an explicit user + * action. + */ + | { + type: "RETRACT_UPLOAD"; + path: string; + guid: string | null; + releaseHold: boolean; + } | { type: "PARK"; path: string; reason: string } - | { type: "SURFACE_STATUS" }; + | { type: "SURFACE_STATUS" } + /** + * Revision-driven snapshot stream. The host's durable write is limited + * to the approved fork-class subset (held deletion fork + retained-doc + * ledger); the machine snapshot itself is observability only. + */ + | { type: "PERSIST_STATE"; snapshot: FolderSerializableSnapshot }; // ============================================================================= // Snapshot / projections @@ -148,41 +321,99 @@ export interface FolderSyncSnapshot { statePath: FolderStatePath; hydrated: boolean; isOnline: boolean; + tier: ConfidenceTier; entries: MembershipEntry[]; parked: Array<{ path: string; reason: string }>; + conflicted: Array<{ path: string; reason: string }>; +} + +/** Serializable whole-machine snapshot (recording/replay, PERSIST_STATE). */ +export interface FolderSerializableSnapshot { + statePath: FolderStatePath; + revision: number; + context: { + persistenceLoaded: boolean; + tier: ConfidenceTier; + providerSynced: boolean; + isOnline: boolean; + authorization: AuthorizationScope; + classificationDeferred: boolean; + localFiles: Array<{ + path: string; + origin: FileOrigin; + kind: LocalFileKind; + }>; + recordedDeleteIntents: string[]; + }; + rows: EntryRow[]; } // ============================================================================= -// Configuration (host-injected callbacks, mirroring merge-hsm) +// Configuration (host-injected callbacks) // ============================================================================= +/** + * Device-local records: durable proof that some file at a path once + * synced under a guid, plus the content evidence that ties the recorded + * identity to the bytes now on disk. All lookups must be synchronous — + * the host assembles its caches before hydration completes. A record's + * existence alone never authorizes destruction; only `recordMatchesDisk` + * lets a record condemn the current file. + */ +export interface LocalRecordSource { + getRecordGuid: (path: string) => string | undefined; + /** Stored content evidence agrees with the file currently on disk. */ + recordMatchesDisk: (path: string) => boolean; + /** Retire the record when its row retires — a record never outlives its file. */ + retireRecord: (path: string) => void; + /** Follow a rename so the record keeps describing the same file. */ + moveRecord: (from: string, to: string) => void; +} + +/** + * Upload holds: the persisted record that a path's publication was + * decided and a guid minted, so retries after restart reuse the same + * identity. Identity-only by design; reads and writes go through the + * host's existing hold persistence in its current format. + */ +export interface UploadHoldSource { + getHold: (path: string) => string | undefined; + moveHold: (from: string, to: string) => void; +} + export interface FolderHSMConfig { folderGuid: string; /** Current committed map entries (path → Meta projection). */ listMapEntries: () => MapEntrySummary[]; /** Single-path map lookup; defaults to scanning listMapEntries(). */ getMapEntry?: (path: string) => MapEntrySummary | undefined; - /** Persisted pendingUpload lookup (guid minted at placeHold time). */ - getPendingUploadGuid: (path: string) => string | undefined; - /** - * Durable local proof that a file synced before: HSM persisted state - * (documents), hash-store entries with guids (attachments), per-doc - * CRDT store metadata. Must be synchronous — the host assembles its - * cache before hydration completes. - */ - getLocalRecordGuid: (path: string) => string | undefined; /** Native tombstone query (wraps pathWasDeleted). */ pathTombstoned: (path: string) => boolean; + records: LocalRecordSource; + holds: UploadHoldSource; + /** + * Live trust probe: the folder docs hold received-but-unappliable sync + * state, so the map understates deletions and no classification may + * read it. Must probe the live replica, never a persisted marker. + */ + hasPendingSyncState?: () => boolean; + /** Whether a file type has content-merge machinery (documents). */ + mergeableKind?: (fileType?: string) => boolean; onEffect: (effect: FolderEffect) => void; onTransition?: ( from: FolderStatePath, to: FolderStatePath, eventType: string, ) => void; + /** + * Invariant-violation sink. Emit-time capability violations always + * throw; refusals and state-check violations report here. + */ + onInvariantViolation?: (violation: FolderInvariantViolation) => void; } // ============================================================================= -// Declarative machine shape (structurally mirrors merge-hsm/types.ts) +// Declarative machine shapes (folder machine) // ============================================================================= export type FolderTransitionCandidate = { @@ -204,9 +435,10 @@ export type FolderAlwaysCandidate = { }; /** - * Effect capabilities per state. These encode the engine's structural - * invariants in the machine definition itself; FolderHSM refuses (throws) - * to emit an effect from a state whose node does not grant the capability. + * Effect capabilities per folder posture. These encode the engine's + * structural invariants in the machine definition itself; the emit + * chokepoint refuses (throws) any effect whose required capability the + * current posture does not grant. */ export interface FolderCapabilities { /** Master switch — loading/syncing/rebuilding leave it unset. */ @@ -220,6 +452,8 @@ export interface FolderCapabilities { canPark?: boolean; } +export type FolderCapabilityName = keyof FolderCapabilities; + export type FolderStateNode = { entry?: string[]; exit?: string[]; @@ -232,5 +466,71 @@ export type FolderMachineDefinition = Partial< Record >; -export type FolderGuardFn = (event: FolderEvent) => boolean; -export type FolderActionFn = (event: FolderEvent) => void; +// ============================================================================= +// Entry-machine shape (the keyed entry table's grammar) +// ============================================================================= + +/** + * Default policy for (state × event) cells with no declared handler. The + * shared interpreter silently consumes unhandled events; for per-file + * rows that is exactly where files get lost, so every node declares what + * an unconsidered event does: + * + * - `absorb` — record the event as evidence, no state change, no effect; + * - `refuse` — no state change, no effect, logged as an invariant report; + * - `reclassify` — return the row to `unclassified` and schedule a + * classification visit. + */ +export type EntryOtherwise = "absorb" | "refuse" | "reclassify"; + +export type EntryCandidate = { + target: EntryTarget; + guard?: string; + actions?: string[]; + /** + * Capabilities this candidate's effects require from the current + * FOLDER posture (the two-level check): the transition's effects may + * emit only while the folder grants them. + */ + requires?: FolderCapabilityName[]; +}; + +/** The explicit refuse handler (distinct from an undeclared cell). */ +export type EntryRefusal = { refuse: true }; + +export type EntryEventHandler = + | EntryTarget + | EntryCandidate + | EntryCandidate[] + | EntryRefusal; + +export type EntryStateNode = { + /** Entry actions run when the row enters this state. */ + entry?: string[]; + otherwise: EntryOtherwise; + on: Partial>; +}; + +export type EntryMachineDefinition = Record; + +// ============================================================================= +// Invariants +// ============================================================================= + +export type FolderInvariantSeverity = "warning" | "error" | "critical"; + +export type FolderInvariantTrigger = + | "on-emit" + | "on-state" + | "on-refuse" + | "on-transition" + | "periodic"; + +export interface FolderInvariantViolation { + id: string; + severity: FolderInvariantSeverity; + message: string; + statePath: FolderStatePath; + entryState?: EntryStatePath; + path?: string; +} diff --git a/src/y-codemirror.next/RemoteSelections.ts b/src/y-codemirror.next/RemoteSelections.ts index 726d49b8d..12dc9b187 100644 --- a/src/y-codemirror.next/RemoteSelections.ts +++ b/src/y-codemirror.next/RemoteSelections.ts @@ -231,8 +231,13 @@ export class YRemoteSelectionsPluginValue implements PluginValue { } const folder = this.connectionManager?.sharedFolders.lookup(file.path); if (folder) { - this.document = folder.proxy.getDoc(file.path); - return this.document; + try { + this.document = folder.proxy.getDoc(file.path); + return this.document; + } catch { + // No shared handle (membership refused or undecided): + // fall through to the view fallback. + } } } diff --git a/src/y-codemirror.next/UserAttributionPlugin.ts b/src/y-codemirror.next/UserAttributionPlugin.ts index 4c9d43579..b6d4744cb 100644 --- a/src/y-codemirror.next/UserAttributionPlugin.ts +++ b/src/y-codemirror.next/UserAttributionPlugin.ts @@ -91,7 +91,13 @@ class UserAttributionPluginValue { const connectionManager = getLiveViews(view) as any; const folder = connectionManager?.sharedFolders.lookup(file.path); - const doc = folder?.proxy.getDoc(file.path); + let doc; + try { + doc = folder?.proxy.getDoc(file.path); + } catch { + // No shared handle (membership refused or undecided). + doc = undefined; + } const ydoc = doc?.localDoc as Y.Doc | undefined; if (!ydoc) { this.decorations = Decoration.none; From 8f72e786000a6bb07a86163034bf53f643ff33b4 Mon Sep 17 00:00:00 2001 From: Daniel Grossmann-Kavanagh Date: Wed, 22 Jul 2026 11:35:06 -0700 Subject: [PATCH 4/9] chore: declare relay state and control actions as data attributes The folder nav pill carries data-relay-state with the folder's live connection status, the NOT SYNCED pill carries data-reason (storage-required or file-type-disabled) alongside its human label, the join controls carry data-action (join-relay, add-to-vault, confirm-add-to-vault), and the share-key input carries a name. The UI declares its state and actions; styling classes and copy stay free to change. --- src/components/AddToVaultModalContent.svelte | 1 + src/components/ManageRelay.svelte | 1 + src/components/ManageRemoteFolder.svelte | 6 +++++- src/components/Relays.svelte | 2 ++ src/components/SecretText.svelte | 3 +++ src/components/SettingsControl.svelte | 2 ++ src/components/TextPill.svelte | 3 ++- src/ui/FolderNav.ts | 17 ++++++++++++----- 8 files changed, 28 insertions(+), 7 deletions(-) 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} diff --git a/src/components/Relays.svelte b/src/components/Relays.svelte index b993a4b5b..70533e0bf 100644 --- a/src/components/Relays.svelte +++ b/src/components/Relays.svelte @@ -202,6 +202,7 @@ bind:value={shareKey} disabled={invitePending} placeholder="Enter share key" + name="share-key" readonly={false} copyOnClick={false} on:input={handleShareKeyInput} @@ -210,6 +211,7 @@ />