mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-27 19:43:20 +00:00
Prepare 1.0.41 notes, dependencies, and recovery coverage
Align release metadata and audited development dependencies, and consolidate release notes with contributor acknowledgements. Cover upgrades, start-up integrity, text reflection, and interrupted saves in real Obsidian. Document the accepted provenance persistence interval: identical replayed content converges, safe merge retains the later remote content and ancestry, and overlapping edits remain available for review.
This commit is contained in:
1 parent
935511c69b
commit
b449dfd2bb
16 files changed
+1583
-292
No files matched your search
@@ -143,9 +143,30 @@ policy-approved skip before the manager can acknowledge either safely.
|
||||
|
||||
A crash after the database write but before snapshot removal can replay an
|
||||
operation. Replay must check current storage and revision provenance, retain
|
||||
the existing protection against stale restored events, and avoid duplicate
|
||||
revisions or deletion of a newer same-path file. It must be safe to interrupt
|
||||
replay again. Existing saved snapshots remain readable.
|
||||
the existing protection against stale restored events, and never delete a
|
||||
newer same-path file. Once both the database write and provenance write have
|
||||
completed, replay must not add another revision. If the database write
|
||||
succeeded before provenance was recorded, replay may first create an identical
|
||||
sibling even when no remote descendant has arrived. In the tested case
|
||||
without a remote edit, replay creates that sibling. Duplicate handling then
|
||||
tombstones one of the identical branches and converges to one live leaf with
|
||||
the exact saved content.
|
||||
Another restart must add no revision. Replay must remain safe to interrupt;
|
||||
existing saved snapshots remain readable.
|
||||
|
||||
The database write and the device-local provenance write are separate,
|
||||
sequential operations. A stop between them can leave the previous provenance
|
||||
record in place. This pre-provenance gap is the known exception to the
|
||||
no-extra-revision result after both writes complete: replay can first create a
|
||||
duplicate sibling; one of the identical branches is then tombstoned while
|
||||
one live leaf retains the saved bytes when no remote descendant has arrived. If continuous
|
||||
replication advances the local database during the gap, replay can instead
|
||||
expose the local content as a sibling of that descendant. The [conflict
|
||||
resolution specification](../specs_conflict_resolution.md#known-potentially-problematic-behaviour)
|
||||
records the required mergeable and manual-resolution outcomes. The per-file
|
||||
lock does not fence replication writes. The interruption window is expected
|
||||
to be very rare, but its field frequency is unmeasured; this decision adds no
|
||||
transaction or second journal to close the interval.
|
||||
|
||||
Boolean failure, an exception, and an unavailable comparison are not successful
|
||||
completion. Keep unresolved work recoverable and report the failure through the
|
||||
@@ -202,6 +223,14 @@ outcomes through the ordinary non-forced file handler and its provenance rules:
|
||||
| Vault content has no provable origin and matches no current live leaf | Preserve it according to the existing independent-branch rules. Do not attach it to an arbitrary database winner. |
|
||||
| Required content or provenance cannot be read, or state changes during comparison | Retain unresolved work and report or re-evaluate it. Do not mark it reconciled merely because the timestamp comparison is `EVEN`. |
|
||||
|
||||
The table describes stable-state reconciliation, not atomic database and
|
||||
provenance persistence. Replay in the pre-provenance gap may create an
|
||||
identical sibling; with no remote descendant, one of the identical branches
|
||||
is tombstoned, leaving one live leaf with the exact saved content. If replication has
|
||||
already added a descendant, preserve its exact content through an existing
|
||||
safe merge, or retain both branches for manual resolution. See [Known
|
||||
potentially problematic behaviour](../specs_conflict_resolution.md#known-potentially-problematic-behaviour).
|
||||
|
||||
Revalidate the relevant file and database state before acting on a comparison.
|
||||
Reuse the existing content and provenance checks; avoid a separate conflict
|
||||
algorithm in the scanner. Metadata continues to reference Chunks containing the
|
||||
@@ -333,7 +362,9 @@ matrix is:
|
||||
| Scenario | Required observation |
|
||||
| -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Stop after dequeue, timer release, or during a database write | Pending work remains recoverable; restart eventually represents the saved Vault edit. |
|
||||
| Database write succeeds before snapshot cleanup; restart twice | Replay creates no duplicate revision and preserves later edits. |
|
||||
| Database and provenance writes are complete before replay; restart twice | Replay adds no extra revision. |
|
||||
| Stop after the database write but before provenance; no remote descendant; replay twice | The tested replay creates an identical sibling and tombstones one of the two branches; one live leaf retains exact saved content, and the second restart adds no revision. |
|
||||
| A remote descendant arrives during the pre-provenance gap | The mergeable case preserves its exact content and ancestry; the overlapping case retains both bodies for manual resolution. |
|
||||
| Old and new events share a path; snapshot writes overlap | Completing old work cannot remove new work or regress the durable snapshot. |
|
||||
| Handler returns false, throws, or snapshot persistence fails | Flush reports failure; pending work is retained; unrelated recoverable work can continue. |
|
||||
| Buffered or in-progress work exists with no waiting timer | Flush still waits for actual completion. |
|
||||
|
||||
@@ -166,6 +166,49 @@ For ordinary saves and incoming reflection, a missing or unreadable recorded bas
|
||||
|
||||
Provenance keys follow the selected [Unicode path identity](design_docs/unicode_path_identity.md#state-which-follows-the-selected-identity). In NFC mode, equivalent path spellings address the same record; the content evidence continues to describe file bytes, independently of that path spelling.
|
||||
|
||||
## Known potentially problematic behaviour
|
||||
|
||||
Saving a local file writes its revision to the local database, then writes
|
||||
device-local provenance in a separate operation. The writes are not atomic.
|
||||
If the process stops between them, provenance can still identify the revision
|
||||
which preceded the local edit. Continuous replication which was already
|
||||
running can add a remote descendant to the local database during this
|
||||
interval. The per-file lock covers file handling and Vault reflection; it
|
||||
does not block raw replication writes to the database.
|
||||
|
||||
For example, local content `2-B` may have been saved as a child of
|
||||
displayed revision `1`, while provenance still identifies `1`.
|
||||
On restart, replay of the pending save can treat `2-B` as an edit of
|
||||
`1` and write sibling `2-D`. The tested case demonstrates that this
|
||||
pre-provenance gap alone can produce the duplicate. Without a remote
|
||||
descendant, replay creates this sibling; duplicate handling tombstones one
|
||||
of the identical branches and converges to one live leaf whose exact content
|
||||
matches the saved local file. A second restart must add no revision.
|
||||
|
||||
If replication has already added descendant `3-C` during the gap, replay
|
||||
can expose an additional branch. When the revisions have a complete shared
|
||||
base and their text changes can be combined unambiguously, sensible merge can retain the
|
||||
exact content of the incoming descendant. In the tested fixture, merge
|
||||
produced revision `4` as a child of `3-C`; its file bytes were exactly
|
||||
those of `3-C`, `3-C` remained in its ancestry, and sibling
|
||||
`2-D` was tombstoned. The acceptance condition is retaining
|
||||
`3-C`'s exact content; it need not remain the current leaf. Both Vaults
|
||||
converged to those bytes. This observed outcome is limited to that mergeable
|
||||
fixture and is not a universal no-data-loss guarantee.
|
||||
|
||||
When changes cannot be combined safely, as in the tested competing whole-line
|
||||
replacements, sensible merge does not combine the bodies. Both versions remain available for the existing
|
||||
manual conflict dialogue, even when sensible merge is enabled.
|
||||
|
||||
An interruption in the database/provenance interval is expected to be very
|
||||
rare, especially when combined with a remote descendant arriving in that
|
||||
interval; the field frequency is unmeasured. Start-up replays restored work,
|
||||
scans, and commits pending local file work before marking the plug-in ready;
|
||||
ordinary replication proceeds after that ready boundary. This does not claim
|
||||
remote synchronisation before start-up readiness. The accepted design adds no
|
||||
cross-store transaction, second journal, or new persistent state to close the
|
||||
interval.
|
||||
|
||||
## Operations while a conflict exists
|
||||
|
||||
- Editing a file writes a child of its recorded or uniquely reconstructed displayed revision.
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "1.0.40",
|
||||
"version": "1.0.41",
|
||||
"minAppVersion": "1.7.2",
|
||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"author": "vorotamoroz",
|
||||
|
||||
Generated
+185
-224
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.40",
|
||||
"version": "1.0.41",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.40",
|
||||
"version": "1.0.41",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"src/apps/cli",
|
||||
@@ -55,7 +55,7 @@
|
||||
"@types/pouchdb-replication": "^6.4.7",
|
||||
"@types/transform-pouch": "^1.0.6",
|
||||
"@typescript-eslint/parser": "8.69.0",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"@vitest/coverage-v8": "^4.1.12",
|
||||
"@vrtmrz/obsidian-test-session": "0.2.6",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"esbuild": "0.28.1",
|
||||
@@ -90,8 +90,8 @@
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.69.0",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.11",
|
||||
"vite": "^8.1.6",
|
||||
"vitest": "^4.1.12",
|
||||
"yaml": "^2.8.2"
|
||||
}
|
||||
},
|
||||
@@ -1945,22 +1945,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.6.tgz",
|
||||
"integrity": "sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.2"
|
||||
"@tybys/wasm-util": "^0.10.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=23.5.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
"@emnapi/core": "^1.7.1 || 2.0.0-beta.2",
|
||||
"@emnapi/runtime": "^1.7.1 || 2.0.0-beta.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
@@ -2012,9 +2015,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.133.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
|
||||
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
|
||||
"version": "0.139.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
||||
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -2222,9 +2225,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2239,9 +2242,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2256,9 +2259,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
|
||||
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2273,9 +2276,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
|
||||
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2290,9 +2293,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
|
||||
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
|
||||
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2307,13 +2310,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2324,13 +2330,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
|
||||
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2341,13 +2350,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2358,13 +2370,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2375,13 +2390,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2392,13 +2410,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
|
||||
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2409,9 +2430,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2426,9 +2447,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
|
||||
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
|
||||
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -2436,52 +2457,18 @@
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "1.10.0",
|
||||
"@emnapi/runtime": "1.10.0",
|
||||
"@napi-rs/wasm-runtime": "^1.1.4"
|
||||
"@emnapi/core": "1.11.1",
|
||||
"@emnapi/runtime": "1.11.1",
|
||||
"@napi-rs/wasm-runtime": "^1.1.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
|
||||
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2496,9 +2483,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
|
||||
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3347,9 +3334,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
||||
"version": "0.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
|
||||
"integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -3963,14 +3950,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
|
||||
"integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.12.tgz",
|
||||
"integrity": "sha512-6S7/iVknKglLAxMFxQGNppBI0iqJS2NgM6FBxavitj7KaDq/vywOWGf6dGu7F2L1Qy0G6S3uTp7aGshHrzK8JA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
@@ -3984,8 +3971,8 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.11",
|
||||
"vitest": "4.1.11"
|
||||
"@vitest/browser": "4.1.12",
|
||||
"vitest": "4.1.12"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
@@ -3994,16 +3981,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
|
||||
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz",
|
||||
"integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -4012,13 +3999,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
|
||||
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz",
|
||||
"integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -4039,9 +4026,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
|
||||
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz",
|
||||
"integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4052,13 +4039,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
|
||||
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz",
|
||||
"integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -4066,14 +4053,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
|
||||
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz",
|
||||
"integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -4082,9 +4069,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
|
||||
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz",
|
||||
"integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -4092,13 +4079,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
|
||||
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz",
|
||||
"integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -6377,9 +6364,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"version": "3.1.9",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
|
||||
"integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -8814,6 +8801,19 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pkg-dir": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
|
||||
@@ -9586,13 +9586,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
|
||||
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.133.0",
|
||||
"@oxc-project/types": "=0.139.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -9602,21 +9602,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.3",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.3",
|
||||
"@rolldown/binding-darwin-x64": "1.0.3",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.3",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.3",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.3",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.3",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.3",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.3",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.3"
|
||||
"@rolldown/binding-android-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-x64": "1.1.5",
|
||||
"@rolldown/binding-freebsd-x64": "1.1.5",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.1.5",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-musl": "1.1.5",
|
||||
"@rolldown/binding-openharmony-arm64": "1.1.5",
|
||||
"@rolldown/binding-wasm32-wasi": "1.1.5",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.1.5",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/sade": {
|
||||
@@ -10465,19 +10465,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||
@@ -10847,16 +10834,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.16",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||
"version": "8.1.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.6.tgz",
|
||||
"integrity": "sha512-aRAHIxx5SW5zc1sMqFwJU/t5qaFfXb5Hj0J7W0VXihu/X99+346tz6080tO1T1xVIJORX/b3zVTXGFqUXh35uw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.15",
|
||||
"rolldown": "1.0.3",
|
||||
"picomatch": "^4.0.5",
|
||||
"postcss": "^8.5.17",
|
||||
"rolldown": "~1.1.5",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
@@ -10873,7 +10860,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.1.18",
|
||||
"@vitejs/devtools": "^0.3.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
@@ -10939,19 +10926,6 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/vitefu": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz",
|
||||
@@ -10973,19 +10947,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
|
||||
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz",
|
||||
"integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.11",
|
||||
"@vitest/mocker": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/runner": "4.1.11",
|
||||
"@vitest/snapshot": "4.1.11",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/expect": "4.1.12",
|
||||
"@vitest/mocker": "4.1.12",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/runner": "4.1.12",
|
||||
"@vitest/snapshot": "4.1.12",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -11013,12 +10987,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.11",
|
||||
"@vitest/browser-preview": "4.1.11",
|
||||
"@vitest/browser-webdriverio": "4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.11",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"@vitest/ui": "4.1.11",
|
||||
"@vitest/browser-playwright": "4.1.12",
|
||||
"@vitest/browser-preview": "4.1.12",
|
||||
"@vitest/browser-webdriverio": "^4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.12",
|
||||
"@vitest/coverage-v8": "4.1.12",
|
||||
"@vitest/ui": "4.1.12",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
@@ -11069,19 +11043,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vitest/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/vuvuzela": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vuvuzela/-/vuvuzela-1.0.3.tgz",
|
||||
@@ -11408,7 +11369,7 @@
|
||||
},
|
||||
"src/apps/cli": {
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"version": "1.0.40-cli",
|
||||
"version": "1.0.41-cli",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"minimatch": "^10.2.5",
|
||||
@@ -11427,13 +11388,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.11"
|
||||
"vite": "^8.1.6",
|
||||
"vitest": "^4.1.12"
|
||||
}
|
||||
},
|
||||
"src/apps/webapp": {
|
||||
"name": "livesync-webapp",
|
||||
"version": "1.0.40-webapp",
|
||||
"version": "1.0.41-webapp",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.55"
|
||||
},
|
||||
@@ -11441,11 +11402,11 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"svelte": "5.56.3",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.1.6"
|
||||
}
|
||||
},
|
||||
"src/apps/webpeer": {
|
||||
"version": "1.0.40-webpeer",
|
||||
"version": "1.0.41-webpeer",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.55"
|
||||
},
|
||||
@@ -11456,7 +11417,7 @@
|
||||
"svelte": "5.56.3",
|
||||
"svelte-check": "^4.6.0",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.1.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.40",
|
||||
"version": "1.0.41",
|
||||
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
@@ -127,7 +127,10 @@
|
||||
"test:docker-all:start": "npm run test:docker-all:up && sleep 5 && npm run test:docker-all:init",
|
||||
"test:docker-all:stop": "npm run test:docker-all:down",
|
||||
"update-workspaces": "node update-workspaces.mjs",
|
||||
"version": "node version-bump.mjs && node update-workspaces.mjs && npm run pretty:json && git add manifest.json versions.json src/apps/cli/package.json src/apps/webpeer/package.json src/apps/webapp/package.json"
|
||||
"version": "node version-bump.mjs && node update-workspaces.mjs && npm run pretty:json && git add manifest.json versions.json src/apps/cli/package.json src/apps/webpeer/package.json src/apps/webapp/package.json",
|
||||
"test:e2e:obsidian:startup-integrity": "tsx test/e2e-obsidian/scripts/startup-integrity.ts",
|
||||
"test:e2e:obsidian:third-party-chunks": "tsx test/e2e-obsidian/scripts/third-party-chunks.ts",
|
||||
"test:e2e:obsidian:upgrade-livesync-batching": "tsx test/e2e-obsidian/scripts/upgrade-livesync-batching.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "vorotamoroz",
|
||||
@@ -151,7 +154,7 @@
|
||||
"@types/pouchdb-replication": "^6.4.7",
|
||||
"@types/transform-pouch": "^1.0.6",
|
||||
"@typescript-eslint/parser": "8.69.0",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"@vitest/coverage-v8": "^4.1.12",
|
||||
"@vrtmrz/obsidian-test-session": "0.2.6",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"esbuild": "0.28.1",
|
||||
@@ -186,8 +189,8 @@
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.69.0",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.11",
|
||||
"vite": "^8.1.6",
|
||||
"vitest": "^4.1.12",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "1.0.40-cli",
|
||||
"version": "1.0.41-cli",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -52,7 +52,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.11"
|
||||
"vite": "^8.1.6",
|
||||
"vitest": "^4.1.12"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "livesync-webapp",
|
||||
"private": true,
|
||||
"version": "1.0.40-webapp",
|
||||
"version": "1.0.41-webapp",
|
||||
"type": "module",
|
||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||
"scripts": {
|
||||
@@ -21,6 +21,6 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"svelte": "5.56.3",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.1.6"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webpeer",
|
||||
"private": true,
|
||||
"version": "1.0.40-webpeer",
|
||||
"version": "1.0.41-webpeer",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -24,6 +24,6 @@
|
||||
"svelte": "5.56.3",
|
||||
"svelte-check": "^4.6.0",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.1.6"
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,10 @@ The same scenario checks the five actions in Reporting Tools, the separate Check
|
||||
|
||||
`folder-batch` needs no remote service. It creates 24 notes and imports three notes with colons in their names into nested folders, reflects the database content into the existing files, then renames and deletes the parent through the Obsidian Vault API. It checks exact paths, descendant events, content, Chunks, deletion markers, provenance, and the absence of unexpected files. A note outside the parent must remain writable. The colon fixtures use the adapter to represent externally created files because Obsidian's Vault creation API rejects those names. The scenario also seeds a database-only colon-named note and verifies that Obsidian's refusal to create it preserves its Metadata without writing a differently named Vault file. It does not establish successful restoration of that absent note or behaviour on other operating systems.
|
||||
|
||||
`startup-integrity` uses the registered Hatch integrity-check event with real Obsidian storage and a local database. It verifies that healthy files are inspected without reading their contents, that a controlled stale stat followed by matching real bytes does not produce a repair dialogue, and that a changed final stat leaves the check incomplete until a settled retry. The stat variations are injected; this scenario exercises the checker used during start-up rather than restarting the process. Natural configured start-up remains covered by `startup-scan`.
|
||||
|
||||
`third-party-chunks` reflects synthetic externally generated text through the installed Commonlib and the real Obsidian file handler. It supplies a split surrogate pair at the file-access read boundary, with size checking enabled, then verifies exact Vault and on-disk text, unchanged Metadata, and matching persisted provenance. Complete-pair text and a binary attachment provide controls. The underlying Metadata and Chunks are real local database entries; this does not establish that CouchDB or a particular third-party application generates this chunk layout. No remote service is required.
|
||||
|
||||
`stale-file-restart` needs no remote service. It advances the local database while old Vault bytes remain, persists pending storage events, and restarts the same isolated Vault and profile. It checks that an unchanged file whose recorded revision has been pruned receives the newer database content using persisted content evidence without creating a revision, that unknown-origin content is preserved on a fresh independent branch, and that losing provenance and processing the file again does not duplicate or automatically merge that branch. A third file begins with no provenance while its bytes still match the current database revision; start-up must record that revision without creating a new one, and a later incoming revision must reflect without a conflict. A fourth file has legacy revision-only provenance; start-up must verify its exact content and add a hash without creating a database revision or changing storage. The database advances and pending snapshot are controlled fixtures; start-up processing, persistence, file reflection, and conflict checking run in real Obsidian. The scenario does not simulate a mobile operating system suspending the application.
|
||||
|
||||
The principal entry points are:
|
||||
@@ -265,7 +269,13 @@ The workflow also opens the Customisation Sync dialogue. `--case=visibility` che
|
||||
|
||||
`--case=automatic` separately checks that choosing **Automatic** while Hidden File Sync is disabled displays a Notice without changing modes, files, Metadata revisions, or processed markers. The fixture starts with a saved Automatic selection and requires it to survive start-up and both rejected actions. It also checks disabling the feature after opening **Initial Action**. After enablement, selecting a whole plug-in must initialise both **MAIN** and **DATA**, and transfer their files to the other Vault. Separate temporary Vaults exercise **Overwrite Remote**, **Overwrite Local**, and **Use newer**. The latter two start with different local and database contents; deterministic per-file modification times require **Use newer** to keep the newer database MAIN content and the newer local DATA content.
|
||||
|
||||
`test:e2e:obsidian:livesync-batch` uses two real Obsidian Vaults and a disposable local CouchDB to compare batching disabled, the older `batchSave` choice alone, and the explicit LiveSync batching choice. It covers repeated edits in both directions, template-like writes, deletion and recreation, incoming changes, maximum delay, concurrent paths, manual flush, and restart recovery before and after a database write. Restarts use the saved profile with natural plug-in start-up. `ISSUE1207_CASE_FILTER` selects case names; `ISSUE1207_RESTART_BUCKET=same|exact|different` exercises interrupted processing at the selected timestamp condition. `ISSUE1207_RESTART_SAVE_AFTER_EDIT=true` separately checks a settings save after the pending edit. Use `npm run test:e2e:obsidian:focused -- livesync-batch` to build and run the default matrix. This scenario requires a localhost CouchDB and is outside the complete local suite.
|
||||
`test:e2e:obsidian:livesync-batch` uses two real Obsidian Vaults and a disposable local CouchDB to compare batching disabled, the older `batchSave` choice alone, and the explicit LiveSync batching choice. It covers repeated edits in both directions, template-like writes, deletion and recreation, incoming changes, maximum delay, concurrent paths, manual flush, and restart recovery before and after a local database write. Restarts use the saved profile with natural plug-in start-up.
|
||||
|
||||
`ISSUE1207_CASE_FILTER` selects case names; `ISSUE1207_RESTART_BUCKET=same|exact|different` exercises interrupted processing at the selected timestamp condition. `ISSUE1207_RESTART_SAVE_AFTER_EDIT=true` separately checks a settings save after the pending edit.
|
||||
|
||||
`ISSUE1207_RESTART_BEFORE_PROVENANCE=true` deliberately stops after the local database write and before device-local provenance is recorded. The case without a remote edit demonstrates replay creating an identical sibling and tombstoning one of the two branches; it must converge to one live leaf with exact saved content, and a second restart must add no revision.
|
||||
|
||||
`ISSUE1207_RESTART_REMOTE_EDIT=mergeable|conflicting` requires `ISSUE1207_RESTART_BEFORE_PROVENANCE=true`. These cases persist the enabled test settings before editing so continuous replication remains active, receive a remote descendant before stopping, and restart the same profile. Both remote cases enable sensible merge. The pause is deliberately injected to observe the ordering; it is not a measure of field frequency. The `mergeable` case requires the final bytes to equal the incoming descendant exactly, with that revision retained in ancestry and further restart stable. The `conflicting` case uses a whole-line replacement and requires both bodies to remain for the manual conflict dialogue. The cases are named `enabled-before-provenance-mergeable-restart` and `enabled-before-provenance-conflicting-restart`. Use `npm run test:e2e:obsidian:focused -- livesync-batch` to build and run the default matrix. This scenario requires a localhost CouchDB and is outside the complete local suite.
|
||||
|
||||
`test:e2e:obsidian:optional-file-save-completion` exercises Hidden File Sync and both Customisation Sync formats in one isolated real Obsidian Vault. It holds a local database write to verify that its event stays in the snapshot and that flush waits for the write, then checks unchanged notifications, subsequent edits, and repeated deletions. Injected save failures remain pending and must replay successfully before the next start reaches readiness. This local scenario disables replication and requires no remote service. It is outside `test:e2e:obsidian:local-suite`.
|
||||
|
||||
@@ -281,6 +291,8 @@ The workflow also opens the Customisation Sync dialogue. `--case=visibility` che
|
||||
|
||||
`test:e2e:obsidian:setting-markdown-roundtrip` generates Markdown with and without credentials through the real export command, copies each original file into a separate fresh Vault, and imports it through **Parse setting file** and the visible **Apply settings** dialogue. Each receiver starts with default plug-in settings and no connection profiles, with its own configuration encryption passphrase provided independently of the source through localStorage. The scenario verifies ordinary settings, conditional credentials and complete profiles, encrypted local persistence, and restoration after restarting the same Vault and profile without reseeding settings or localStorage. Imports without credentials may create new profiles through the existing legacy migration; those profiles must contain none of the omitted source credentials. This scenario requires no remote service and is included in the focused wrapper and local suite.
|
||||
|
||||
`test:e2e:obsidian:upgrade-livesync-batching` verifies the introduction of the separate LiveSync batching choice using the pinned, published 1.0.40 artefacts. It first saves and restarts the old release with legacy batching enabled and LiveSync mode selected. Replacing only the plug-in artefacts must retain the Vault, local database, device identity, remote profile, and existing Metadata revision, while leaving the new LiveSync batching choice disabled. The upgraded Vault and an independent current-version Vault exchange notes through CouchDB. Natural restarts retain both the disabled LiveSync choice and the original batching choice when switching to periodic mode. The source release is downloaded into the verified release cache; `E2E_LIVESYNC_TARGET_ARTIFACT_ROOT` selects an exact target build. This tests the update behaviour, while installation through BRAT remains a separate acceptance step.
|
||||
|
||||
`test:e2e:obsidian:upgrade-from-stable` is the release-acceptance upgrade workflow. It installs the exact published 0.25.83 artefacts into an isolated Vault, verifies their pinned SHA-256 values, and then replaces only the plug-in artefacts with the current target while retaining the same Vault and isolated Obsidian profile. The first run downloads the old release into the ignored `_testdata/releases` cache; every later run verifies the cached bytes before use.
|
||||
|
||||
The workflow first exercises a non-empty legacy settings document which has no `isConfigured` or file-name case value. It verifies that 0.25.83 treats a default-equivalent document as unconfigured. That release can persist the inferred boolean during a later, unrelated settings-save event, so the runner accepts either an absent value or the inferred `false` on disk, then restores the same minimal pre-flag document deliberately before installing 1.0. The target independently proves its direct migration: the Vault remains unconfigured instead of receiving new-Vault recommendations, case-insensitive handling becomes explicit, no compatibility pause or acknowledgement marker is created while onboarding remains pending, and a second 1.0 start is idempotent. The absent marker is deliberately deferred while onboarding is pending; a later configured start records the current version if no other review is required. This fixture rewrite is limited to the missing-flag boundary; the configured transport upgrades use only state created and saved by 0.25.83 itself.
|
||||
|
||||
@@ -180,12 +180,17 @@ async function waitFor(
|
||||
throw new Error(`State did not settle: ${JSON.stringify(state)}`);
|
||||
}
|
||||
|
||||
async function settled(pair: Pair, path: string, body: string): Promise<[Snapshot, Snapshot]> {
|
||||
async function settled(
|
||||
pair: Pair,
|
||||
path: string,
|
||||
body: string,
|
||||
allowDeletedBranches = false
|
||||
): Promise<[Snapshot, Snapshot]> {
|
||||
const ready = (state: Snapshot) =>
|
||||
state.content === body &&
|
||||
liveLeaves(state).length === 1 &&
|
||||
liveLeaves(state)[0].body === body &&
|
||||
state.leaves.length === 1 &&
|
||||
(allowDeletedBranches || state.leaves.length === 1) &&
|
||||
!state.waiting.some((item) => item.path === path);
|
||||
const deadline = Date.now() + 20000;
|
||||
let states: [Snapshot, Snapshot];
|
||||
@@ -234,6 +239,15 @@ async function main(): Promise<void> {
|
||||
await assertCouchDbReachable(config);
|
||||
const dbName = makeUniqueDatabaseName(config.dbPrefix, "live-batch");
|
||||
const couch = { ...config, dbName };
|
||||
const remoteRestartEdit = process.env.ISSUE1207_RESTART_REMOTE_EDIT;
|
||||
check(
|
||||
!remoteRestartEdit || ["mergeable", "conflicting"].includes(remoteRestartEdit),
|
||||
"Unknown remote edit for the provenance restart case"
|
||||
);
|
||||
check(
|
||||
!remoteRestartEdit || process.env.ISSUE1207_RESTART_BEFORE_PROVENANCE === "true",
|
||||
"A remote restart edit requires the provenance gate"
|
||||
);
|
||||
const overrides = {
|
||||
doctorProcessedVersion: "1.0.0",
|
||||
notifyThresholdOfRemoteStorageSize: -1,
|
||||
@@ -248,7 +262,7 @@ async function main(): Promise<void> {
|
||||
syncAfterMerge: false,
|
||||
checkConflictOnlyOnOpen: true,
|
||||
showMergeDialogOnlyOnActive: true,
|
||||
disableMarkdownAutoMerge: true,
|
||||
disableMarkdownAutoMerge: !remoteRestartEdit,
|
||||
resolveConflictsByNewerFile: false,
|
||||
writeDocumentsIfConflicted: false,
|
||||
writeLogToTheFile: false,
|
||||
@@ -259,8 +273,6 @@ async function main(): Promise<void> {
|
||||
evidence.commonlibVersion = JSON.parse(
|
||||
await readFile("node_modules/@vrtmrz/livesync-commonlib/package.json", "utf8")
|
||||
).version;
|
||||
evidence.sourceBaseRevision = "73a14d4e4b0b414a1acfe170d01500abb3dca2e9";
|
||||
evidence.unreleasedImplementation = true;
|
||||
if (process.env.ISSUE1207_COMMONLIB_ARTEFACT) {
|
||||
evidence.commonlibArtefactSha256 = createHash("sha256")
|
||||
.update(new Uint8Array(readFileSync(process.env.ISSUE1207_COMMONLIB_ARTEFACT)))
|
||||
@@ -370,6 +382,31 @@ async function main(): Promise<void> {
|
||||
type:item.type,path:item.args?.file?.path,oldPath:item.args?.oldPath}))};`
|
||||
),
|
||||
});
|
||||
if (name === "enabled-settings-save-restart" || name.startsWith("enabled-before-provenance")) {
|
||||
observations.push({
|
||||
name,
|
||||
device,
|
||||
revisionEvidence: await evaluate(
|
||||
page,
|
||||
`
|
||||
const path=${JSON.stringify(`${prefix}restart.md`)};
|
||||
const id=await core.services.path.path2id(path);
|
||||
const leaves=await core.localDatabase.getRaw(id,{open_revs:'all',revs:true});
|
||||
const revisions=new Set();
|
||||
for(const leaf of leaves){const h=leaf.ok?._revisions;
|
||||
for(let i=0;i<(h?.ids.length??0);i++)revisions.add((h.start-i)+'-'+h.ids[i]);}
|
||||
const result=[];
|
||||
for(const rev of revisions){
|
||||
const raw=await core.localDatabase.getRaw(id,{rev,revs:true}).catch(error=>({error:String(error)}));
|
||||
const doc=await core.localDatabase.getDBEntry(path,{rev,revs:true},false,true,true).catch(()=>false);
|
||||
result.push({rev,deleted:raw._deleted??false,ctime:raw.ctime,mtime:raw.mtime,
|
||||
size:raw.size,error:raw.error,body:doc?(Array.isArray(doc.data)?doc.data.join(''):doc.data):null});
|
||||
}
|
||||
return {revisions:result,provenance:await core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1').get(path)};
|
||||
`
|
||||
),
|
||||
});
|
||||
}
|
||||
await page.screenshot({ path: join(diagnostics, `${name}-${device}-failure.png`) });
|
||||
try {
|
||||
await evaluate(page, "await manager.waitForIdle();");
|
||||
@@ -663,27 +700,61 @@ async function main(): Promise<void> {
|
||||
|
||||
const saveAfterEdit = process.env.ISSUE1207_RESTART_SAVE_AFTER_EDIT === "true";
|
||||
const restartBucket = process.env.ISSUE1207_RESTART_BUCKET;
|
||||
const stopBeforeProvenance = process.env.ISSUE1207_RESTART_BEFORE_PROVENANCE === "true";
|
||||
check(!stopBeforeProvenance || !restartBucket, "The provenance and timestamp gates are separate cases");
|
||||
check(
|
||||
!restartBucket || ["same", "exact", "different"].includes(restartBucket),
|
||||
"Unknown restart timestamp condition"
|
||||
);
|
||||
const restartName = restartBucket
|
||||
? `enabled-inflight-restart-${restartBucket}`
|
||||
: saveAfterEdit
|
||||
? "enabled-settings-save-restart"
|
||||
: "enabled-restart";
|
||||
const restartName = stopBeforeProvenance
|
||||
? `enabled-before-provenance${remoteRestartEdit ? `-${remoteRestartEdit}` : ""}-restart`
|
||||
: restartBucket
|
||||
? `enabled-inflight-restart-${restartBucket}`
|
||||
: saveAfterEdit
|
||||
? "enabled-settings-save-restart"
|
||||
: "enabled-restart";
|
||||
await run(restartName, async () => {
|
||||
const path = `${prefix}restart.md`;
|
||||
const beforeContent =
|
||||
remoteRestartEdit === "mergeable"
|
||||
? "Heading\n\nLocal: original\n\nRemote: original\n\nFooter\n"
|
||||
: "Before restart\n";
|
||||
const localContent =
|
||||
remoteRestartEdit === "mergeable"
|
||||
? "Heading\n\nLocal: saved\n\nRemote: original\n\nFooter\n"
|
||||
: "Retained after restart\n";
|
||||
const remoteContent =
|
||||
remoteRestartEdit === "mergeable"
|
||||
? "Heading\n\nLocal: saved\n\nRemote: changed remotely\n\nFooter\n"
|
||||
: "Remote edit after the durable save\n";
|
||||
const beforeRestart = await withPair(async (pair) => {
|
||||
await options(pair.a, "enabled-batch", 5, 60);
|
||||
await options(pair.b, "normal");
|
||||
if (!saveAfterEdit)
|
||||
// Remote cases keep continuous replication running while the local save is held.
|
||||
if (!saveAfterEdit || remoteRestartEdit)
|
||||
await evaluate(
|
||||
pair.a,
|
||||
"await core.services.setting.saveSettingData(); await core.services.control.applySettings();"
|
||||
);
|
||||
await create(pair.b, path, "Before restart\n");
|
||||
const initial = await settled(pair, path, "Before restart\n");
|
||||
await create(pair.b, path, beforeContent);
|
||||
const initial = await settled(pair, path, beforeContent);
|
||||
if (stopBeforeProvenance) {
|
||||
await evaluate(
|
||||
pair.a,
|
||||
`
|
||||
const access=core.serviceModules.databaseFileAccess;
|
||||
const original=access.storeWithBaseRevision.bind(access);
|
||||
access.storeWithBaseRevision=async(file,...args)=>{
|
||||
const revision=await original(file,...args);
|
||||
if(file.path===${JSON.stringify(path)}&&revision){
|
||||
globalThis.__issue1207Batch.beforeProvenanceRevision=revision;
|
||||
await new Promise(resolve=>setTimeout(resolve,30000));
|
||||
}
|
||||
return revision;
|
||||
};
|
||||
`
|
||||
);
|
||||
}
|
||||
if (restartBucket) {
|
||||
check(initial[0].databaseMtime !== null, "Missing initial database mtime");
|
||||
const mtime =
|
||||
@@ -703,11 +774,27 @@ async function main(): Promise<void> {
|
||||
};
|
||||
await app.vault.modify(app.vault.getFileByPath(${JSON.stringify(path)}),"Retained after restart\\n",{mtime:${mtime}});`
|
||||
);
|
||||
} else await modify(pair.a, path, "Retained after restart\n");
|
||||
} else await modify(pair.a, path, localContent);
|
||||
const pending = await waitFor(pair.a, path, (state) =>
|
||||
state.waiting.some((item) => item.path === path)
|
||||
);
|
||||
if (saveAfterEdit) await evaluate(pair.a, "await core.services.setting.saveSettingData();");
|
||||
if (saveAfterEdit && !remoteRestartEdit) {
|
||||
await evaluate(pair.a, "await core.services.setting.saveSettingData();");
|
||||
observations.push({
|
||||
phase: "settings-save-before-stop",
|
||||
initial,
|
||||
pending,
|
||||
current: await Promise.all([snapshot(pair.a, path), snapshot(pair.b, path)]),
|
||||
sourceProbe: await evaluate(
|
||||
pair.a,
|
||||
`return {
|
||||
events:globalThis.__issue1207Batch.events,
|
||||
changes:globalThis.__issue1207Batch.changes,
|
||||
provenance:await core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1').get(${JSON.stringify(path)})
|
||||
};`
|
||||
),
|
||||
});
|
||||
}
|
||||
if (restartBucket)
|
||||
await evaluate(
|
||||
pair.a,
|
||||
@@ -715,6 +802,82 @@ async function main(): Promise<void> {
|
||||
while(!globalThis.__issue1207Batch.inflightPaused&&Date.now()<deadline) await new Promise(resolve=>setTimeout(resolve,10));
|
||||
if(!globalThis.__issue1207Batch.inflightPaused) throw new Error('The in-flight test gate did not start');`
|
||||
);
|
||||
let durableBeforeProvenance: Snapshot | undefined;
|
||||
let remoteAdvanced: Snapshot | undefined;
|
||||
if (stopBeforeProvenance) {
|
||||
await evaluate(
|
||||
pair.a,
|
||||
`
|
||||
const deadline=Date.now()+core.services.setting.currentSettings().batchSaveMinimumDelay*1000+5000;
|
||||
while(!globalThis.__issue1207Batch.beforeProvenanceRevision&&Date.now()<deadline)
|
||||
await new Promise(resolve=>setTimeout(resolve,10));
|
||||
if(!globalThis.__issue1207Batch.beforeProvenanceRevision)throw new Error('The durable-write provenance gate did not start');
|
||||
`
|
||||
);
|
||||
durableBeforeProvenance = await snapshot(pair.a, path);
|
||||
const provenance = await evaluate<{ revision: string }>(
|
||||
pair.a,
|
||||
`return await core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1').get(${JSON.stringify(path)});`
|
||||
);
|
||||
check(
|
||||
durableBeforeProvenance.rev !== initial[0].rev &&
|
||||
liveLeaves(durableBeforeProvenance)[0]?.body === localContent,
|
||||
"The gated database write is not durable"
|
||||
);
|
||||
check(
|
||||
provenance?.revision === initial[0].rev,
|
||||
"The fixture did not stop before provenance was updated"
|
||||
);
|
||||
observations.push({
|
||||
phase: "durable-before-provenance",
|
||||
state: durableBeforeProvenance,
|
||||
provenance,
|
||||
});
|
||||
if (remoteRestartEdit) {
|
||||
const receivedOriginal = await waitFor(
|
||||
pair.b,
|
||||
path,
|
||||
(state) => state.rev === durableBeforeProvenance!.rev && state.content === localContent
|
||||
);
|
||||
await modify(pair.b, path, remoteContent);
|
||||
remoteAdvanced = await waitFor(
|
||||
pair.b,
|
||||
path,
|
||||
(state) =>
|
||||
liveLeaves(state).length === 1 &&
|
||||
liveLeaves(state)[0].body === remoteContent &&
|
||||
state.rev === liveLeaves(state)[0].rev &&
|
||||
state.content === remoteContent
|
||||
);
|
||||
check(
|
||||
liveLeaves(remoteAdvanced)[0].ancestry.includes(durableBeforeProvenance.rev!),
|
||||
"The remote edit did not descend from the already durable local save"
|
||||
);
|
||||
const localDbAdvanced = await waitFor(
|
||||
pair.a,
|
||||
path,
|
||||
(state) =>
|
||||
state.rev === remoteAdvanced!.rev &&
|
||||
liveLeaves(state).length === 1 &&
|
||||
liveLeaves(state)[0].body === remoteContent
|
||||
);
|
||||
const stillOldProvenance = await evaluate<{ revision: string }>(
|
||||
pair.a,
|
||||
`return await core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1').get(${JSON.stringify(path)});`
|
||||
);
|
||||
check(
|
||||
localDbAdvanced.content === localContent && stillOldProvenance.revision === initial[0].rev,
|
||||
"The incoming DB change did not precede Vault reflection and provenance persistence"
|
||||
);
|
||||
observations.push({
|
||||
phase: "remote-edit-local-db-ahead-of-storage",
|
||||
receivedOriginal,
|
||||
remoteAdvanced,
|
||||
localDbAdvanced,
|
||||
stillOldProvenance,
|
||||
});
|
||||
}
|
||||
}
|
||||
await evaluate(pair.a, "await manager._takeSnapshot();");
|
||||
check(pending.rev === initial[0].rev, "Restart test did not retain a pending edit");
|
||||
if (restartBucket) {
|
||||
@@ -746,16 +909,18 @@ async function main(): Promise<void> {
|
||||
sameBucket,
|
||||
});
|
||||
}
|
||||
if (!saveAfterEdit && !restartBucket) {
|
||||
if (stopBeforeProvenance || (!saveAfterEdit && !restartBucket)) {
|
||||
const strictState = await snapshot(pair.a, path);
|
||||
const saved = await evaluate<Array<{ path: string }>>(
|
||||
pair.a,
|
||||
`return (await manager.adapter.persistence.loadSnapshot()).map(item=>({path:item.args?.file?.path}));`
|
||||
);
|
||||
check(
|
||||
strictState.rev === initial[0].rev && strictState.waiting.some((item) => item.path === path),
|
||||
"Restart fixture flushed before shutdown"
|
||||
);
|
||||
if (!stopBeforeProvenance)
|
||||
check(
|
||||
strictState.rev === initial[0].rev &&
|
||||
strictState.waiting.some((item) => item.path === path),
|
||||
"Restart fixture flushed before shutdown"
|
||||
);
|
||||
check(
|
||||
saved.some((item) => item.path === path),
|
||||
"Restart fixture did not persist the pending event"
|
||||
@@ -778,12 +943,12 @@ async function main(): Promise<void> {
|
||||
const settings=core.services.setting.currentSettings();
|
||||
return {initialized:await core.kvDB.get('initialized'),restorationRequested:manager.snapShotRestored!==null,
|
||||
settings:{isConfigured:settings.isConfigured,liveSync:settings.liveSync,batchSave:settings.batchSave,batchSaveInLiveSync:settings.batchSaveInLiveSync,
|
||||
suspendFileWatching:settings.suspendFileWatching},
|
||||
suspendFileWatching:settings.suspendFileWatching,disableMarkdownAutoMerge:settings.disableMarkdownAutoMerge},
|
||||
events:events?.map(item=>({type:item.type,path:item.args?.file?.path,
|
||||
stat:item.args?.file?.stat,skipBatchWait:item.skipBatchWait}))};`
|
||||
),
|
||||
});
|
||||
return { initial, pending };
|
||||
return { initial, pending, durableBeforeProvenance, remoteAdvanced };
|
||||
});
|
||||
await stop(slots[0]);
|
||||
const persistedSettings = JSON.parse(
|
||||
@@ -817,11 +982,82 @@ async function main(): Promise<void> {
|
||||
events:events?.map(item=>({type:item.type,path:item.args?.file?.path,stat:item.args?.file?.stat}))};`
|
||||
),
|
||||
});
|
||||
const result = await settled(pair, path, "Retained after restart\n");
|
||||
if (remoteRestartEdit === "conflicting") {
|
||||
const preservesBothBodies = (state: Snapshot) => {
|
||||
const leaves = liveLeaves(state);
|
||||
return (
|
||||
leaves.length === 2 &&
|
||||
leaves.some((leaf) => leaf.body === localContent) &&
|
||||
leaves.some(
|
||||
(leaf) => leaf.body === remoteContent && leaf.rev === beforeRestart.remoteAdvanced!.rev
|
||||
)
|
||||
);
|
||||
};
|
||||
const result = await Promise.all([
|
||||
waitFor(pair.a, path, preservesBothBodies),
|
||||
waitFor(pair.b, path, preservesBothBodies),
|
||||
]);
|
||||
check(
|
||||
result[0].content === localContent && result[1].content === remoteContent,
|
||||
"An unresolved conflict overwrote one of the preserved Vault contents"
|
||||
);
|
||||
const manualDialogue = pair.a.locator(".modal").filter({ hasText: "Conflicting changes" });
|
||||
await manualDialogue.waitFor({ state: "visible", timeout: 10000 });
|
||||
await pair.a.screenshot({ path: join(diagnostics, `${restartName}-manual-conflict.png`) });
|
||||
return { result, manualConflict: true, restoredGuard: result[0].shouldBatchSave };
|
||||
}
|
||||
const expectedContent = remoteRestartEdit ? remoteContent : localContent;
|
||||
const result = await settled(pair, path, expectedContent, saveAfterEdit || stopBeforeProvenance);
|
||||
check(result[0].shouldBatchSave, "The saved LiveSync batching choice was not restored");
|
||||
if (beforeRestart.remoteAdvanced) {
|
||||
for (const state of result)
|
||||
check(
|
||||
liveLeaves(state)[0].ancestry.includes(beforeRestart.remoteAdvanced.rev!),
|
||||
"Sensible merge lost the later remote revision's ancestry"
|
||||
);
|
||||
check(
|
||||
(await pair.a.locator(".modal").filter({ hasText: "Conflicting changes" }).count()) === 0,
|
||||
"The mergeable recovery left a manual conflict dialogue"
|
||||
);
|
||||
}
|
||||
for (const [index, page] of [pair.a, pair.b].entries()) {
|
||||
const provenance = await evaluate<{ revision: string }>(
|
||||
page,
|
||||
`const store=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');
|
||||
const deadline=Date.now()+5000;
|
||||
let record;
|
||||
do {
|
||||
record=await store.get(${JSON.stringify(path)});
|
||||
if(record?.revision===${JSON.stringify(result[index].rev)})return record;
|
||||
await new Promise(resolve=>setTimeout(resolve,10));
|
||||
}while(Date.now()<deadline);
|
||||
return record;`
|
||||
);
|
||||
check(
|
||||
provenance?.revision === result[index].rev,
|
||||
"Recovery did not record the converged file revision"
|
||||
);
|
||||
}
|
||||
return { result, restoredGuard: result[0].shouldBatchSave };
|
||||
});
|
||||
return { beforeRestart, afterRestart };
|
||||
let repeatedRestart: Snapshot[] | undefined;
|
||||
if (stopBeforeProvenance && remoteRestartEdit !== "conflicting") {
|
||||
await stop(slots[0]);
|
||||
await start(slots[0], true);
|
||||
repeatedRestart = await withPair(async (pair) => {
|
||||
const result = await settled(pair, path, remoteRestartEdit ? remoteContent : localContent, true);
|
||||
check(
|
||||
result[0].rev === afterRestart.result[0].rev,
|
||||
"A repeated restart created another revision after recovery had converged"
|
||||
);
|
||||
check(
|
||||
result[0].leaves.length === afterRestart.result[0].leaves.length,
|
||||
"A repeated restart added another branch after recovery had converged"
|
||||
);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
return { beforeRestart, afterRestart, repeatedRestart };
|
||||
});
|
||||
|
||||
await run("enabled-after-write-restart", async () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ const focusedScenarios = new Set([
|
||||
"p2p-pane",
|
||||
"replication-status",
|
||||
"vault-reflection",
|
||||
"third-party-chunks",
|
||||
"couchdb-upload",
|
||||
"couchdb-http-cache",
|
||||
"chunk-fetch-retry",
|
||||
@@ -36,6 +37,7 @@ const focusedScenarios = new Set([
|
||||
"p2p-setup-uri-workflow",
|
||||
"partial-startup-file-failure",
|
||||
"startup-scan",
|
||||
"startup-integrity",
|
||||
"stale-file-restart",
|
||||
"folder-batch",
|
||||
"setup-uri-workflow",
|
||||
@@ -52,6 +54,7 @@ const focusedScenarios = new Set([
|
||||
"setting-markdown-export",
|
||||
"setting-markdown-roundtrip",
|
||||
"upgrade-from-stable",
|
||||
"upgrade-livesync-batching",
|
||||
]);
|
||||
|
||||
function usage(): string {
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
/** Checks incomplete-document integrity against real Vault storage in Obsidian. */
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { requireObsidianBinary, requireObsidianCli } from "../runner/environment.ts";
|
||||
import {
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObsidianDeviceLocalState,
|
||||
waitForLiveSyncCoreReady,
|
||||
waitForLocalDatabaseEntry,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000";
|
||||
|
||||
const filePath = "startup-integrity.md";
|
||||
const fileContent = "startup integrity remains healthy\n";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cliBinary = requireObsidianCli();
|
||||
const vault = await createTemporaryVault("obsidian-startup-integrity-");
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
const bundleHash = createHash("sha256")
|
||||
.update(new Uint8Array(await readFile("main.js")))
|
||||
.digest("hex");
|
||||
const settings = createE2eCouchDbPluginData(
|
||||
// No replication is enabled; this scenario uses only the local database.
|
||||
{ uri: "http://127.0.0.1:1", username: "unused", password: "unused", dbName: "startup-integrity-local" },
|
||||
{
|
||||
deviceAndVaultName: "startup-integrity-device",
|
||||
liveSync: false,
|
||||
periodicReplication: false,
|
||||
syncOnStart: false,
|
||||
syncOnSave: false,
|
||||
syncOnFileOpen: false,
|
||||
syncOnEditorSave: false,
|
||||
syncAfterMerge: false,
|
||||
}
|
||||
);
|
||||
|
||||
async function evaluate<T>(body: string): Promise<T> {
|
||||
if (!session) throw new Error("The Obsidian session is not running");
|
||||
return await evalObsidianJson<T>(
|
||||
cliBinary,
|
||||
`(async()=>{
|
||||
const core=app.plugins.plugins['obsidian-livesync']?.core;
|
||||
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||||
const require=(condition,message)=>{if(!condition)throw new Error(message);};
|
||||
const waitFor=async(predicate,message,timeoutMs=15000)=>{
|
||||
const deadline=Date.now()+timeoutMs;
|
||||
while(Date.now()<deadline){
|
||||
if(await predicate())return;
|
||||
await sleep(20);
|
||||
}
|
||||
throw new Error(message);
|
||||
};
|
||||
${body}
|
||||
})()`,
|
||||
session.cliEnv
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary,
|
||||
vault,
|
||||
pluginData: settings,
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
await evaluate(`
|
||||
require(core, 'Self-hosted LiveSync did not load');
|
||||
require(app.vault.getAbstractFileByPath(${JSON.stringify(filePath)})===null,
|
||||
'The integrity fixture already exists in the Vault');
|
||||
await app.vault.create(${JSON.stringify(filePath)},${JSON.stringify(fileContent)});
|
||||
return JSON.stringify({created:${JSON.stringify(filePath)}});
|
||||
`);
|
||||
await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, filePath);
|
||||
|
||||
const observations = await evaluate<unknown[]>(`
|
||||
require(await core.services.fileProcessing.commitPendingFileEvents(), 'Initial local flush failed');
|
||||
const path=${JSON.stringify(filePath)};
|
||||
const expectedContent=${JSON.stringify(fileContent)};
|
||||
const storage=core.serviceModules.storageAccess;
|
||||
const fileHandler=core.serviceModules.fileHandler;
|
||||
const keyValueDB=core.services.keyValueDB.kvDB;
|
||||
const confirm=core.services.UI.confirm;
|
||||
const noticeGroups=core.services.context.noticeGroups;
|
||||
const events=core.services.context.events;
|
||||
require(storage&&typeof storage.statHidden==='function'&&
|
||||
typeof storage.readHiddenFileBinary==='function',
|
||||
'The real StorageAccess methods were not available');
|
||||
require(fileHandler&&typeof fileHandler.storeFileToDB==='function',
|
||||
'The real file handler was not available');
|
||||
|
||||
const snapshot=async()=>{
|
||||
const meta=await core.localDatabase.getDBEntryMeta(path,undefined,true);
|
||||
require(meta&&meta._rev,'The integrity fixture Metadata was missing');
|
||||
const doc=await core.localDatabase.getDBEntryFromMeta(meta);
|
||||
require(doc&&doc.data!==undefined,'The integrity fixture Chunks could not be loaded');
|
||||
const content=Array.isArray(doc.data)?doc.data.join(''):doc.data;
|
||||
require(typeof content==='string','The Markdown fixture did not load as text');
|
||||
const bytes=Array.from(new TextEncoder().encode(content));
|
||||
require(meta.size===bytes.length,'Metadata size did not match the stored bytes');
|
||||
require(doc.size===bytes.length,'Loaded document size did not match the stored bytes');
|
||||
return {revision:meta._rev,metadataSize:meta.size,documentSize:doc.size,content,bytes};
|
||||
};
|
||||
const assertUnchanged=(before,after,label)=>{
|
||||
require(before.revision===after.revision,label+': the Metadata revision changed');
|
||||
require(before.metadataSize===after.metadataSize,label+': the Metadata size changed');
|
||||
require(before.content===after.content,label+': the stored content changed');
|
||||
require(JSON.stringify(before.bytes)===JSON.stringify(after.bytes),
|
||||
label+': the stored bytes changed');
|
||||
};
|
||||
const activeBytes=async()=>{
|
||||
const content=await app.vault.adapter.read(path);
|
||||
return {content,bytes:Array.from(new TextEncoder().encode(content))};
|
||||
};
|
||||
const marker=async()=>await keyValueDB.get('checkIncompleteDocs')===true;
|
||||
const activeState={current:null};
|
||||
const originalStat=storage.statHidden;
|
||||
const originalRead=storage.readHiddenFileBinary;
|
||||
const originalStore=fileHandler.storeFileToDB;
|
||||
const originalDialogue=confirm.askSelectStringDialogue;
|
||||
const ownStat=Object.prototype.hasOwnProperty.call(storage,'statHidden');
|
||||
const ownRead=Object.prototype.hasOwnProperty.call(storage,'readHiddenFileBinary');
|
||||
const ownStore=Object.prototype.hasOwnProperty.call(fileHandler,'storeFileToDB');
|
||||
const ownDialogue=Object.prototype.hasOwnProperty.call(confirm,'askSelectStringDialogue');
|
||||
const unhandledErrors=[];
|
||||
const onUnhandled=event=>{
|
||||
const reason=event.reason;
|
||||
unhandledErrors.push(String(reason&&reason.message?reason.message:reason));
|
||||
event.preventDefault();
|
||||
};
|
||||
let installedStat=false;
|
||||
let installedRead=false;
|
||||
let installedStore=false;
|
||||
let installedDialogue=false;
|
||||
const restore=(owner,key,hadOwn,original)=>{
|
||||
if(hadOwn)owner[key]=original;
|
||||
else delete owner[key];
|
||||
};
|
||||
|
||||
const onStat=async function(candidatePath){
|
||||
const actual=await originalStat.call(this,candidatePath);
|
||||
const current=activeState.current;
|
||||
if(!current||candidatePath!==path)return actual;
|
||||
current.statCalls++;
|
||||
require(actual&&actual.type==='file','StorageAccess did not report the fixture as a file');
|
||||
current.actualStats.push({size:actual.size,mtime:actual.mtime,ctime:actual.ctime});
|
||||
if(current.mode==='stat-read-race'&¤t.statCalls===1){
|
||||
const oldStat={...actual,size:actual.size+1};
|
||||
current.returnedStats.push({size:oldStat.size,mtime:oldStat.mtime,ctime:oldStat.ctime});
|
||||
return oldStat;
|
||||
}
|
||||
if(current.mode==='unstable-final-stat'&¤t.statCalls===2){
|
||||
const changedStat={...actual,mtime:actual.mtime+1};
|
||||
current.returnedStats.push({size:changedStat.size,mtime:changedStat.mtime,ctime:changedStat.ctime});
|
||||
return changedStat;
|
||||
}
|
||||
current.returnedStats.push({size:actual.size,mtime:actual.mtime,ctime:actual.ctime});
|
||||
return actual;
|
||||
};
|
||||
const onRead=async function(candidatePath){
|
||||
const bytes=await originalRead.call(this,candidatePath);
|
||||
const current=activeState.current;
|
||||
if(current&&candidatePath===path){
|
||||
current.readCalls++;
|
||||
current.readBytes=Array.from(new Uint8Array(bytes));
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
const onStore=async function(file,...args){
|
||||
const current=activeState.current;
|
||||
if(current&&file?.path===path)current.repairCalls++;
|
||||
return await originalStore.call(this,file,...args);
|
||||
};
|
||||
const onDialogue=async function(message,buttons,...args){
|
||||
const current=activeState.current;
|
||||
if(current){
|
||||
current.dialogueCalls++;
|
||||
current.dialogueMessages.push(String(message));
|
||||
return Array.isArray(buttons)&&buttons.length>0?buttons[0]:false;
|
||||
}
|
||||
return await originalDialogue.call(this,message,buttons,...args);
|
||||
};
|
||||
|
||||
const runRegisteredCheck=async current=>{
|
||||
const originalFinish=noticeGroups.finish;
|
||||
const ownFinish=Object.prototype.hasOwnProperty.call(noticeGroups,'finish');
|
||||
let finished=false;
|
||||
noticeGroups.finish=function(...args){
|
||||
try{return originalFinish.apply(this,args);}
|
||||
finally{
|
||||
if(args[0]==='startup-integrity-check')finished=true;
|
||||
}
|
||||
};
|
||||
activeState.current=current;
|
||||
try{
|
||||
events.emitEvent('request-run-fix-incomplete');
|
||||
await waitFor(()=>finished,'The registered incomplete-document check did not finish');
|
||||
}finally{
|
||||
activeState.current=null;
|
||||
restore(noticeGroups,'finish',ownFinish,originalFinish);
|
||||
}
|
||||
};
|
||||
const makeObservation=mode=>({
|
||||
mode,
|
||||
statCalls:0,
|
||||
actualStats:[],
|
||||
returnedStats:[],
|
||||
readCalls:0,
|
||||
readBytes:[],
|
||||
dialogueCalls:0,
|
||||
dialogueMessages:[],
|
||||
repairCalls:0,
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection',onUnhandled);
|
||||
try{
|
||||
storage.statHidden=onStat;
|
||||
installedStat=true;
|
||||
storage.readHiddenFileBinary=onRead;
|
||||
installedRead=true;
|
||||
fileHandler.storeFileToDB=onStore;
|
||||
installedStore=true;
|
||||
confirm.askSelectStringDialogue=onDialogue;
|
||||
installedDialogue=true;
|
||||
|
||||
const firstStorageStat=await storage.statHidden(path);
|
||||
require(firstStorageStat&&firstStorageStat.type==='file',
|
||||
'The real Vault file was not visible through StorageAccess');
|
||||
const firstVault=await activeBytes();
|
||||
const first=snapshot();
|
||||
const healthyBefore=await first;
|
||||
require(firstVault.content===expectedContent&&healthyBefore.content===expectedContent,
|
||||
'The fixture content did not match between the Vault and Metadata');
|
||||
require(firstStorageStat.size===healthyBefore.bytes.length,
|
||||
'The real Vault size did not match the Metadata bytes');
|
||||
|
||||
await keyValueDB.set('checkIncompleteDocs',false);
|
||||
const healthy=makeObservation('healthy');
|
||||
const healthyUnhandledBefore=unhandledErrors.length;
|
||||
await runRegisteredCheck(healthy);
|
||||
const healthyAfter=await snapshot();
|
||||
const healthyMarker=await marker();
|
||||
const healthyVaultAfter=await activeBytes();
|
||||
require(healthy.statCalls===2,'The healthy fixture was not checked with both StorageAccess stats');
|
||||
require(healthy.readCalls===0,'The healthy fixture was read only to determine its size');
|
||||
require(healthy.dialogueCalls===0,'The healthy fixture opened a repair dialogue');
|
||||
require(healthy.repairCalls===0,'The healthy fixture invoked repair');
|
||||
require(healthyMarker,'The healthy integrity check did not set its completion marker');
|
||||
require(unhandledErrors.length===healthyUnhandledBefore,
|
||||
'The healthy integrity check raised an unhandled error');
|
||||
require(healthyVaultAfter.content===expectedContent,'The healthy Vault file changed');
|
||||
assertUnchanged(healthyBefore,healthyAfter,'Healthy scan');
|
||||
const observations=[
|
||||
{
|
||||
case:'healthy',
|
||||
revision:healthyAfter.revision,
|
||||
storedBytes:healthyAfter.bytes.length,
|
||||
readCalls:healthy.readCalls,
|
||||
statCalls:healthy.statCalls,
|
||||
dialogueCalls:healthy.dialogueCalls,
|
||||
repairCalls:healthy.repairCalls,
|
||||
completionMarker:healthyMarker,
|
||||
storedBytesUnchanged:true,
|
||||
},
|
||||
];
|
||||
|
||||
const raceBefore=await snapshot();
|
||||
await keyValueDB.set('checkIncompleteDocs',false);
|
||||
const race=makeObservation('stat-read-race');
|
||||
const raceUnhandledBefore=unhandledErrors.length;
|
||||
await runRegisteredCheck(race);
|
||||
const raceAfter=await snapshot();
|
||||
const raceMarker=await marker();
|
||||
const raceVaultAfter=await activeBytes();
|
||||
require(race.statCalls===1,'The controlled stat/read race did not use one initial stat');
|
||||
require(race.actualStats[0]?.size===raceBefore.bytes.length,
|
||||
'The real StorageAccess stat did not see the Metadata size');
|
||||
require(race.returnedStats[0]?.size===raceBefore.bytes.length+1,
|
||||
'The checker did not see the controlled old mismatched size');
|
||||
require(race.readCalls===1,'The controlled mismatch did not read real Vault bytes');
|
||||
require(JSON.stringify(race.readBytes)===JSON.stringify(raceBefore.bytes),
|
||||
'The actual StorageAccess read did not match the database bytes');
|
||||
require(race.dialogueCalls===0,'The settled stat/read race opened a repair dialogue');
|
||||
require(race.repairCalls===0,'The settled stat/read race invoked repair');
|
||||
require(raceMarker,'The settled stat/read race did not set its completion marker');
|
||||
require(unhandledErrors.length===raceUnhandledBefore,
|
||||
'The settled stat/read race raised an unhandled error');
|
||||
require(raceVaultAfter.content===expectedContent,'The Vault file changed during the stat/read race');
|
||||
assertUnchanged(raceBefore,raceAfter,'Settled stat/read race');
|
||||
observations.push({
|
||||
case:'stat-read-race',
|
||||
revision:raceAfter.revision,
|
||||
storedBytes:raceAfter.bytes.length,
|
||||
observedStatSize:race.returnedStats[0]?.size,
|
||||
readCalls:race.readCalls,
|
||||
readMatchedDatabase:true,
|
||||
dialogueCalls:race.dialogueCalls,
|
||||
repairCalls:race.repairCalls,
|
||||
completionMarker:raceMarker,
|
||||
storedBytesUnchanged:true,
|
||||
});
|
||||
|
||||
const unstableBefore=await snapshot();
|
||||
await keyValueDB.set('checkIncompleteDocs',false);
|
||||
const unstable=makeObservation('unstable-final-stat');
|
||||
const unstableUnhandledBefore=unhandledErrors.length;
|
||||
await runRegisteredCheck(unstable);
|
||||
await waitFor(()=>unhandledErrors.length>unstableUnhandledBefore,
|
||||
'The changed final stat did not reject the incomplete-document check');
|
||||
const unstableError=unhandledErrors.slice(unstableUnhandledBefore).join(' | ');
|
||||
const unstableAfter=await snapshot();
|
||||
const unstableMarker=await marker();
|
||||
require(unstable.statCalls===2,'The changed final stat was not checked');
|
||||
require(unstable.returnedStats[1]?.mtime!==unstable.actualStats[1]?.mtime,
|
||||
'The final StorageAccess stat was not changed by the race fixture');
|
||||
require(unstable.readCalls===0,'The healthy path was read during the final-stat race');
|
||||
require(unstableError.includes('Storage changed during the incomplete document check'),
|
||||
'The final-stat race did not report the expected incomplete-check error');
|
||||
require(unstableMarker===false,'The incomplete check was marked complete after storage changed');
|
||||
require(unstable.dialogueCalls===0,'The final-stat race opened a repair dialogue');
|
||||
require(unstable.repairCalls===0,'The final-stat race invoked repair');
|
||||
assertUnchanged(unstableBefore,unstableAfter,'Final-stat race');
|
||||
observations.push({
|
||||
case:'unstable-final-stat',
|
||||
revision:unstableAfter.revision,
|
||||
statCalls:unstable.statCalls,
|
||||
readCalls:unstable.readCalls,
|
||||
reportedError:unstableError,
|
||||
completionMarker:unstableMarker,
|
||||
storedBytesUnchanged:true,
|
||||
});
|
||||
|
||||
await keyValueDB.set('checkIncompleteDocs',false);
|
||||
const retry=makeObservation('settled-retry');
|
||||
const retryUnhandledBefore=unhandledErrors.length;
|
||||
await runRegisteredCheck(retry);
|
||||
const retryAfter=await snapshot();
|
||||
const retryMarker=await marker();
|
||||
require(retry.statCalls===2,'The settled retry did not complete both StorageAccess stats');
|
||||
require(retry.readCalls===0,'The settled retry read healthy storage');
|
||||
require(retry.dialogueCalls===0,'The settled retry opened a repair dialogue');
|
||||
require(retry.repairCalls===0,'The settled retry invoked repair');
|
||||
require(retryMarker,'The settled retry did not set its completion marker');
|
||||
require(unhandledErrors.length===retryUnhandledBefore,
|
||||
'The settled retry raised an unhandled error');
|
||||
assertUnchanged(unstableBefore,retryAfter,'Settled retry');
|
||||
observations.push({
|
||||
case:'settled-retry',
|
||||
revision:retryAfter.revision,
|
||||
statCalls:retry.statCalls,
|
||||
readCalls:retry.readCalls,
|
||||
dialogueCalls:retry.dialogueCalls,
|
||||
repairCalls:retry.repairCalls,
|
||||
completionMarker:retryMarker,
|
||||
storedBytesUnchanged:true,
|
||||
});
|
||||
return JSON.stringify(observations);
|
||||
}finally{
|
||||
window.removeEventListener('unhandledrejection',onUnhandled);
|
||||
if(installedDialogue)restore(confirm,'askSelectStringDialogue',ownDialogue,originalDialogue);
|
||||
if(installedStore)restore(fileHandler,'storeFileToDB',ownStore,originalStore);
|
||||
if(installedRead)restore(storage,'readHiddenFileBinary',ownRead,originalRead);
|
||||
if(installedStat)restore(storage,'statHidden',ownStat,originalStat);
|
||||
}
|
||||
`);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
bundleHash,
|
||||
trigger: "registered-request-run-fix-incomplete-event",
|
||||
naturalRestart: false,
|
||||
observations,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
await session?.app.stop();
|
||||
} finally {
|
||||
await vault.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,269 @@
|
||||
/** Exercises synthetic split-surrogate input through real Obsidian file reflection. */
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { requireObsidianBinary, requireObsidianCli } from "../runner/environment.ts";
|
||||
import {
|
||||
assertEqual,
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObsidianDeviceLocalState,
|
||||
waitForLiveSyncCoreReady,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cliBinary = requireObsidianCli();
|
||||
const textPath = "synthetic-split-pair.md";
|
||||
const controlPath = "complete-pair.md";
|
||||
const binaryPath = "attachment.bin";
|
||||
const textContent = "prefix 🙂 suffix\n" + "normal chunk payload\n".repeat(50_000);
|
||||
const controlContent = "prefix 🙂 suffix";
|
||||
const binaryBytes = Buffer.from([0, 255, 31, 128]);
|
||||
const textSize = Buffer.byteLength(textContent, "utf8");
|
||||
const textHash = `sha256:${createHash("sha256").update(textContent, "utf8").digest("hex")}`;
|
||||
const controlSize = Buffer.byteLength(controlContent, "utf8");
|
||||
const controlHash = `sha256:${createHash("sha256").update(controlContent, "utf8").digest("hex")}`;
|
||||
const settings = createE2eCouchDbPluginData(
|
||||
{ uri: "http://127.0.0.1:1", username: "unused", password: "unused", dbName: "third-party-chunks-local" },
|
||||
{
|
||||
customChunkSize: 0,
|
||||
processSizeMismatchedFiles: false,
|
||||
liveSync: false,
|
||||
periodicReplication: false,
|
||||
syncOnStart: false,
|
||||
syncOnSave: false,
|
||||
syncOnFileOpen: false,
|
||||
syncOnEditorSave: false,
|
||||
syncAfterMerge: false,
|
||||
}
|
||||
);
|
||||
const bundleHash = createHash("sha256")
|
||||
.update(new Uint8Array(await readFile("main.js")))
|
||||
.digest("hex");
|
||||
const vault = await createTemporaryVault("obsidian-third-party-chunks-");
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
|
||||
try {
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary,
|
||||
vault,
|
||||
pluginData: settings,
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
lifecycle: {
|
||||
beforeLaunch: async () => {
|
||||
const registryPath = join(vault.userDataPath, "obsidian.json");
|
||||
const registry = JSON.parse(await readFile(registryPath, "utf8"));
|
||||
registry.updateDisabled = true;
|
||||
await writeFile(registryPath, JSON.stringify(registry));
|
||||
},
|
||||
},
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
|
||||
const reflection = await evalObsidianJson<{
|
||||
sizeCheckingEnabled: boolean;
|
||||
split: {
|
||||
reflected: boolean;
|
||||
metadataRevisionUnchanged: boolean;
|
||||
chunkCount: number;
|
||||
provenanceRevision: string;
|
||||
contentSize: number;
|
||||
contentHash: string;
|
||||
};
|
||||
control: {
|
||||
reflected: boolean;
|
||||
content: string;
|
||||
metadataRevisionUnchanged: boolean;
|
||||
provenanceRevision: string;
|
||||
contentSize: number;
|
||||
contentHash: string;
|
||||
};
|
||||
binary: { reflected: boolean; bytes: number[]; metadataRevisionUnchanged: boolean };
|
||||
}>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const require=(condition,message)=>{if(!condition)throw new Error(message);};",
|
||||
"const textPath='synthetic-split-pair.md';",
|
||||
"const controlPath='complete-pair.md';",
|
||||
"const binaryPath='attachment.bin';",
|
||||
"const textContent='prefix 🙂 suffix\\n'+'normal chunk payload\\n'.repeat(50000);",
|
||||
"const controlContent='prefix 🙂 suffix';",
|
||||
"const binaryBytes=[0,255,31,128];",
|
||||
"const store=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');",
|
||||
"require(core.settings.processSizeMismatchedFiles===false,'Size mismatch checking must remain enabled');",
|
||||
"const seed=async(path,blob,type,datatype)=>{",
|
||||
" require(!app.vault.getAbstractFileByPath(path),'Fixture already exists in the Vault: '+path);",
|
||||
" const id=await core.services.path.path2id(path);",
|
||||
" const now=Date.now();",
|
||||
" const saved=await core.localDatabase.putDBEntry({_id:id,path,data:blob,ctime:now,mtime:now,",
|
||||
" size:blob.size,children:[],datatype,type,eden:{} });",
|
||||
" require(saved?.ok,'Could not create Metadata and Chunks: '+path);",
|
||||
" const meta=await core.localDatabase.getDBEntryMeta(path,undefined,true);",
|
||||
" require(meta&&meta._rev===saved.rev,'Could not reload the created Metadata: '+path);",
|
||||
" return meta;",
|
||||
"};",
|
||||
"const textMeta=await seed(textPath,new Blob([textContent],{type:'text/plain'}),'plain','plain');",
|
||||
"require((textMeta.children??[]).length>0,'The primary fixture did not create normal Chunks');",
|
||||
"const savedText=await core.localDatabase.getDBEntry(textPath,{rev:textMeta._rev,revs:true},false,true,true);",
|
||||
"const storedText=Array.isArray(savedText?.data)?savedText.data.join(''):savedText?.data;",
|
||||
"require(storedText===textContent,'The seeded local database content did not match the joined text');",
|
||||
"const controlMeta=await seed(controlPath,new Blob([controlContent],{type:'text/plain'}),'plain','plain');",
|
||||
"const binaryMeta=await seed(binaryPath,new Blob([new Uint8Array(binaryBytes)],{type:'application/octet-stream'}),'newnote','newnote');",
|
||||
"const initialTextRevision=textMeta._rev;",
|
||||
"const initialControlRevision=controlMeta._rev;",
|
||||
"const database=core.serviceModules.databaseFileAccess;",
|
||||
"require(typeof database?.fetchEntryFromMeta==='function','The database file-access provider is missing');",
|
||||
"const descriptor=Object.getOwnPropertyDescriptor(database,'fetchEntryFromMeta');",
|
||||
"const original=database.fetchEntryFromMeta;",
|
||||
"let supplied=0;",
|
||||
"const pairIndex=textContent.indexOf('🙂');",
|
||||
"const syntheticChunks=[textContent.slice(0,pairIndex+1),textContent.slice(pairIndex+1)];",
|
||||
"require(syntheticChunks[0]==='prefix \\uD83D','The synthetic first chunk did not end with the high surrogate');",
|
||||
"require(syntheticChunks[1].startsWith('\\uDE42 suffix'),'The synthetic second chunk did not start with the low surrogate');",
|
||||
"database.fetchEntryFromMeta=async function(meta,...args){",
|
||||
" const loaded=await original.call(database,meta,...args);",
|
||||
" if(meta?._id!==textMeta._id||meta?.path!==textPath||!loaded)return loaded;",
|
||||
" supplied++;",
|
||||
" return {...loaded,data:syntheticChunks};",
|
||||
"};",
|
||||
"let reflectedText=false;",
|
||||
"try{reflectedText=await core.serviceModules.fileHandler.dbToStorage(textMeta,null,true);}",
|
||||
"finally{if(descriptor)Object.defineProperty(database,'fetchEntryFromMeta',descriptor);else delete database.fetchEntryFromMeta;}",
|
||||
"require(supplied===1,'The synthetic provider was not used exactly once');",
|
||||
"require(reflectedText,'Real Obsidian reflection rejected the joined text');",
|
||||
"const textFile=app.vault.getAbstractFileByPath(textPath);",
|
||||
"require(textFile,'The reflected text file is missing from the Vault');",
|
||||
"const vaultText=await app.vault.read(textFile);",
|
||||
"require(vaultText===textContent,'The Vault text does not match the joined input');",
|
||||
"const textMetaAfter=await core.localDatabase.getDBEntryMeta(textPath,undefined,true);",
|
||||
"require(textMetaAfter?._rev===initialTextRevision,'Text reflection changed the Metadata revision');",
|
||||
"const textProvenance=await store.get(textPath);",
|
||||
"require(textProvenance?.revision===initialTextRevision,'Text provenance has the wrong revision');",
|
||||
`require(textProvenance?.contentSize===${textSize},'Text provenance has the wrong UTF-8 byte size');`,
|
||||
`require(textProvenance?.contentHash===${JSON.stringify(textHash)},'Text provenance has the wrong content hash');`,
|
||||
"const reflectedControl=await core.serviceModules.fileHandler.dbToStorage(controlMeta,null,true);",
|
||||
"require(reflectedControl,'Real Obsidian reflection rejected the complete-pair control');",
|
||||
"const controlFile=app.vault.getAbstractFileByPath(controlPath);",
|
||||
"require(controlFile&&await app.vault.read(controlFile)===controlContent,'The complete-pair control content changed');",
|
||||
"const controlVaultText=await app.vault.read(controlFile);",
|
||||
"const controlMetaAfter=await core.localDatabase.getDBEntryMeta(controlPath,undefined,true);",
|
||||
"require(controlMetaAfter?._rev===initialControlRevision,'Control reflection changed the Metadata revision');",
|
||||
"const controlProvenance=await store.get(controlPath);",
|
||||
"require(controlProvenance?.revision===initialControlRevision,'Control provenance has the wrong revision');",
|
||||
`require(controlProvenance?.contentSize===${controlSize},'Control provenance has the wrong UTF-8 byte size');`,
|
||||
`require(controlProvenance?.contentHash===${JSON.stringify(controlHash)},'Control provenance has the wrong content hash');`,
|
||||
"const reflectedBinary=await core.serviceModules.fileHandler.dbToStorage(binaryMeta,null,true);",
|
||||
"require(reflectedBinary,'Real Obsidian reflection rejected the binary attachment');",
|
||||
"const binaryFile=app.vault.getAbstractFileByPath(binaryPath);",
|
||||
"require(binaryFile,'The reflected binary attachment is missing from the Vault');",
|
||||
"const binaryVaultBytes=Array.from(new Uint8Array(await app.vault.readBinary(binaryFile)));",
|
||||
"require(JSON.stringify(binaryVaultBytes)===JSON.stringify(binaryBytes),'The Vault binary bytes changed');",
|
||||
"const binaryMetaAfter=await core.localDatabase.getDBEntryMeta(binaryPath,undefined,true);",
|
||||
"require(binaryMetaAfter?._rev===binaryMeta._rev,'Binary reflection changed the Metadata revision');",
|
||||
"return JSON.stringify({sizeCheckingEnabled:!core.settings.processSizeMismatchedFiles,",
|
||||
" split:{reflected:reflectedText,metadataRevisionUnchanged:textMetaAfter._rev===initialTextRevision,chunkCount:textMeta.children.length,",
|
||||
" provenanceRevision:textProvenance.revision,contentSize:textProvenance.contentSize,contentHash:textProvenance.contentHash},",
|
||||
" control:{reflected:reflectedControl,content:controlVaultText,",
|
||||
" metadataRevisionUnchanged:controlMetaAfter._rev===initialControlRevision,provenanceRevision:controlProvenance.revision,",
|
||||
" contentSize:controlProvenance.contentSize,contentHash:controlProvenance.contentHash},",
|
||||
" binary:{reflected:reflectedBinary,bytes:binaryVaultBytes,metadataRevisionUnchanged:binaryMetaAfter._rev===binaryMeta._rev}});",
|
||||
"})()",
|
||||
].join(""),
|
||||
session.cliEnv
|
||||
);
|
||||
|
||||
assertEqual(reflection.sizeCheckingEnabled, true, "Size mismatch checking was disabled.");
|
||||
assertEqual(reflection.split.reflected, true, "Split-pair text was not reflected.");
|
||||
assertEqual(
|
||||
reflection.split.metadataRevisionUnchanged,
|
||||
true,
|
||||
"Split-pair reflection changed its Metadata revision."
|
||||
);
|
||||
assertEqual(reflection.split.provenanceRevision.length > 0, true, "Split-pair provenance is missing.");
|
||||
assertEqual(reflection.split.contentSize, textSize, "Split-pair provenance has the wrong UTF-8 byte size.");
|
||||
assertEqual(reflection.split.contentHash, textHash, "Split-pair provenance has the wrong content hash.");
|
||||
assertEqual(reflection.control.reflected, true, "Complete-pair control was not reflected.");
|
||||
assertEqual(reflection.control.content, controlContent, "Complete-pair control content changed.");
|
||||
assertEqual(
|
||||
reflection.control.metadataRevisionUnchanged,
|
||||
true,
|
||||
"Control reflection changed its Metadata revision."
|
||||
);
|
||||
assertEqual(reflection.control.contentSize, controlSize, "Control provenance has the wrong UTF-8 byte size.");
|
||||
assertEqual(reflection.control.contentHash, controlHash, "Control provenance has the wrong content hash.");
|
||||
assertEqual(reflection.binary.reflected, true, "Binary attachment was not reflected.");
|
||||
if (!Buffer.from(reflection.binary.bytes).equals(binaryBytes)) {
|
||||
throw new Error("The Obsidian Vault API returned different binary attachment bytes.");
|
||||
}
|
||||
assertEqual(
|
||||
reflection.binary.metadataRevisionUnchanged,
|
||||
true,
|
||||
"Binary reflection changed its Metadata revision."
|
||||
);
|
||||
|
||||
const diskText = await readFile(join(vault.path, textPath));
|
||||
assertEqual(diskText.toString("utf8"), textContent, "On-disk UTF-8 text differs from the joined input.");
|
||||
assertEqual(diskText.byteLength, textSize, "On-disk UTF-8 text has the wrong byte length.");
|
||||
const diskControl = await readFile(join(vault.path, controlPath));
|
||||
assertEqual(
|
||||
diskControl.toString("utf8"),
|
||||
controlContent,
|
||||
"On-disk complete-pair control differs from the input."
|
||||
);
|
||||
const diskBinary = await readFile(join(vault.path, binaryPath));
|
||||
if (!diskBinary.equals(binaryBytes)) throw new Error("On-disk binary attachment bytes changed.");
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
bundleHash,
|
||||
syntheticInput:
|
||||
"A local fetchEntryFromMeta result split the joined text between the emoji surrogates; this does not establish that CouchDB or any named third-party producer emits lone-surrogate chunks.",
|
||||
sizeCheckingEnabled: reflection.sizeCheckingEnabled,
|
||||
splitText: {
|
||||
reflected: reflection.split.reflected,
|
||||
metadataRevisionUnchanged: reflection.split.metadataRevisionUnchanged,
|
||||
realChunkCount: reflection.split.chunkCount,
|
||||
vaultTextMatches: true,
|
||||
onDiskUtf8Bytes: diskText.byteLength,
|
||||
provenanceRevision: reflection.split.provenanceRevision,
|
||||
contentSize: reflection.split.contentSize,
|
||||
contentHash: reflection.split.contentHash,
|
||||
},
|
||||
completePairControl: {
|
||||
reflected: reflection.control.reflected,
|
||||
metadataRevisionUnchanged: reflection.control.metadataRevisionUnchanged,
|
||||
vaultAndDiskContentMatch: true,
|
||||
provenanceContentSize: reflection.control.contentSize,
|
||||
provenanceContentHash: reflection.control.contentHash,
|
||||
},
|
||||
binaryAttachment: {
|
||||
reflected: reflection.binary.reflected,
|
||||
exactVaultBytes: true,
|
||||
exactDiskBytes: true,
|
||||
metadataRevisionUnchanged: reflection.binary.metadataRevisionUnchanged,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
await session?.app.stop();
|
||||
} finally {
|
||||
await vault.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,312 @@
|
||||
/** Keeps the 1.0.40 batching choice and Vault state when installing the current release. */
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import {
|
||||
assertCouchDbReachable,
|
||||
createCouchDbDatabase,
|
||||
deleteCouchDbDatabase,
|
||||
loadCouchDbConfig,
|
||||
makeUniqueDatabaseName,
|
||||
waitForCouchDbDocs,
|
||||
} from "../runner/couchdb.ts";
|
||||
import { requireObsidianBinary, requireObsidianCli } from "../runner/environment.ts";
|
||||
import {
|
||||
assertEqual,
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObsidianDeviceLocalState,
|
||||
prepareRemote,
|
||||
pushLocalChanges,
|
||||
waitForLiveSyncCoreReady,
|
||||
waitForLocalDatabaseEntry,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { ensurePinnedReleaseArtifact, type PinnedPluginRelease } from "../runner/releaseArtifact.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { readRuntimeUpgradeState, waitForPersistentNodeIdentity } from "../runner/upgradeWorkflow.ts";
|
||||
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const sourceRelease: PinnedPluginRelease = {
|
||||
pluginId: "obsidian-livesync",
|
||||
version: "1.0.40",
|
||||
files: [
|
||||
{
|
||||
name: "main.js",
|
||||
url: "https://github.com/vrtmrz/obsidian-livesync/releases/download/1.0.40/main.js",
|
||||
sha256: "bdd6b18ffe36a6a72ce774e857889b70d379559e4a5a3ccafee357a42daca6e9",
|
||||
},
|
||||
{
|
||||
name: "manifest.json",
|
||||
url: "https://github.com/vrtmrz/obsidian-livesync/releases/download/1.0.40/manifest.json",
|
||||
sha256: "8f8f57fb105aba0ce7f9ea5104dbed92fea837d49fdf27b4f02b6099ddf1ba3b",
|
||||
},
|
||||
{
|
||||
name: "styles.css",
|
||||
url: "https://github.com/vrtmrz/obsidian-livesync/releases/download/1.0.40/styles.css",
|
||||
sha256: "4ba00bef417ee98d9a62dd9febb792dc6555949cfc9e8e069450ad699ab2b9f0",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
type BatchState = {
|
||||
batchSave: boolean;
|
||||
batchSaveInLiveSync: boolean | null;
|
||||
liveSync: boolean;
|
||||
periodicReplication: boolean;
|
||||
shouldBatchSave: boolean;
|
||||
minimum: number;
|
||||
maximum: number;
|
||||
};
|
||||
|
||||
const beforePath = "upgrade-batching-before.md";
|
||||
const afterPath = "upgrade-batching-after.md";
|
||||
const returnPath = "upgrade-batching-return.md";
|
||||
const beforeContent = "Saved by the published 1.0.40 plug-in.\n";
|
||||
const afterContent = "Saved after upgrading with LiveSync batching still disabled.\n";
|
||||
const returnContent = "Returned by an independent current-release Vault.\n";
|
||||
|
||||
async function artefactHashes(root: string): Promise<Record<string, string>> {
|
||||
return Object.fromEntries(
|
||||
await Promise.all(
|
||||
sourceRelease.files.map(async ({ name }) => [
|
||||
name,
|
||||
createHash("sha256")
|
||||
.update(new Uint8Array(await readFile(join(root, name))))
|
||||
.digest("hex"),
|
||||
])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cliBinary = requireObsidianCli();
|
||||
const sourceRoot = await ensurePinnedReleaseArtifact(sourceRelease);
|
||||
const targetRoot = resolve(process.env.E2E_LIVESYNC_TARGET_ARTIFACT_ROOT?.trim() || process.cwd());
|
||||
const targetManifest = JSON.parse(await readFile(join(targetRoot, "manifest.json"), "utf8"));
|
||||
if (targetManifest.id !== sourceRelease.pluginId || targetManifest.version === sourceRelease.version) {
|
||||
throw new Error("The target must be a newer Self-hosted LiveSync build.");
|
||||
}
|
||||
const hashes = await artefactHashes(targetRoot);
|
||||
const config = await loadCouchDbConfig();
|
||||
if (!["127.0.0.1", "localhost"].includes(new URL(config.uri).hostname)) {
|
||||
throw new Error("Use a disposable local CouchDB for the upgrade scenario.");
|
||||
}
|
||||
const dbName = makeUniqueDatabaseName(config.dbPrefix, "upgrade-batching");
|
||||
const vault = await createTemporaryVault("obsidian-upgrade-batching-");
|
||||
const verifier = await createTemporaryVault("obsidian-upgrade-verifier-");
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
let ownsDatabase = false;
|
||||
const pluginData = createE2eCouchDbPluginData(
|
||||
{ ...config, dbName },
|
||||
{
|
||||
batchSave: true,
|
||||
batchSaveMinimumDelay: 3,
|
||||
batchSaveMaximumDelay: 7,
|
||||
liveSync: false,
|
||||
periodicReplication: false,
|
||||
syncOnFileOpen: false,
|
||||
syncOnEditorSave: false,
|
||||
syncAfterMerge: false,
|
||||
doctorProcessedVersion: "1.0.0",
|
||||
}
|
||||
);
|
||||
|
||||
async function stop(): Promise<void> {
|
||||
if (session) await session.app.stop();
|
||||
session = undefined;
|
||||
}
|
||||
|
||||
async function start(currentVault: TemporaryVault, artifactRoot: string, seed = false): Promise<void> {
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary,
|
||||
vault: currentVault,
|
||||
artifactRoot,
|
||||
pluginStartup: seed ? undefined : "natural",
|
||||
pluginData: seed ? pluginData : undefined,
|
||||
localStorageEntries: seed ? createE2eObsidianDeviceLocalState(currentVault.name) : undefined,
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
}
|
||||
|
||||
async function evaluate<T>(body: string): Promise<T> {
|
||||
if (!session) throw new Error("No active Obsidian session");
|
||||
return await evalObsidianJson<T>(
|
||||
cliBinary,
|
||||
`(async()=>{
|
||||
const core=app.plugins.plugins['obsidian-livesync'].core;
|
||||
return JSON.stringify(await(async()=>{${body}})());
|
||||
})()`,
|
||||
session.cliEnv
|
||||
);
|
||||
}
|
||||
|
||||
async function batching(): Promise<BatchState> {
|
||||
return await evaluate<BatchState>(`
|
||||
const s=core.services.setting.currentSettings();
|
||||
return {batchSave:s.batchSave,batchSaveInLiveSync:s.batchSaveInLiveSync??null,
|
||||
liveSync:s.liveSync,periodicReplication:s.periodicReplication,
|
||||
shouldBatchSave:core.serviceModules.storageAccess.vaultManager.shouldBatchSave,
|
||||
minimum:s.batchSaveMinimumDelay,maximum:s.batchSaveMaximumDelay};
|
||||
`);
|
||||
}
|
||||
|
||||
async function createNote(path: string, content: string): Promise<void> {
|
||||
await evaluate(`await app.vault.create(${JSON.stringify(path)},${JSON.stringify(content)}); return true;`);
|
||||
const entry = await waitForLocalDatabaseEntry(cliBinary, session!.cliEnv, path);
|
||||
if ((await batching()).liveSync) {
|
||||
await waitForCouchDbDocs(config, dbName, (docs) =>
|
||||
docs.some((doc) => doc._id === entry.id && doc._rev === entry.rev)
|
||||
);
|
||||
} else {
|
||||
await pushLocalChanges(cliBinary, session!.cliEnv);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertNote(currentVault: TemporaryVault, path: string, content: string): Promise<void> {
|
||||
const actual = await evaluate(`
|
||||
const until=Date.now()+15000;
|
||||
while(Date.now()<until){
|
||||
const file=app.vault.getAbstractFileByPath(${JSON.stringify(path)});
|
||||
if(file?.stat){const value=await app.vault.read(file);if(value===${JSON.stringify(content)})return value;}
|
||||
await new Promise(resolve=>setTimeout(resolve,100));
|
||||
}
|
||||
throw new Error('Expected Vault contents did not arrive: '+${JSON.stringify(path)});
|
||||
`);
|
||||
assertEqual(actual, content, "The Obsidian Vault did not contain the expected text.");
|
||||
assertEqual(await readFile(join(currentVault.path, path), "utf8"), content, "On-disk text differed.");
|
||||
}
|
||||
|
||||
function assertUpgradedBatching(state: BatchState, live: boolean): void {
|
||||
assertEqual(state.batchSave, true, "The legacy batching choice changed during upgrade.");
|
||||
assertEqual(state.batchSaveInLiveSync, false, "Upgrade unexpectedly enabled LiveSync batching.");
|
||||
assertEqual(state.liveSync, live, "The selected synchronisation mode changed.");
|
||||
assertEqual(state.shouldBatchSave, !live, "The retained choice applied to the wrong mode.");
|
||||
assertEqual(state.minimum, 3, "The minimum batch delay changed.");
|
||||
assertEqual(state.maximum, 7, "The maximum batch delay changed.");
|
||||
}
|
||||
|
||||
try {
|
||||
await assertCouchDbReachable(config);
|
||||
await createCouchDbDatabase(config, dbName);
|
||||
ownsDatabase = true;
|
||||
await start(vault, sourceRoot, true);
|
||||
await prepareRemote(cliBinary, session!.cliEnv);
|
||||
await createNote(beforePath, beforeContent);
|
||||
await evaluate(`
|
||||
const s=core.services.setting.currentSettings();
|
||||
s.liveSync=true;
|
||||
await core.services.setting.saveSettingData();
|
||||
await core.services.control.applySettings();
|
||||
return true;
|
||||
`);
|
||||
await stop();
|
||||
|
||||
// Confirm that the old release itself persisted this state before replacing any artefacts.
|
||||
await start(vault, sourceRoot);
|
||||
const oldBatch = await batching();
|
||||
assertEqual(oldBatch.batchSave, true, "The old release did not retain its batching choice.");
|
||||
assertEqual(oldBatch.batchSaveInLiveSync, null, "The old release unexpectedly owns the new setting.");
|
||||
assertEqual(oldBatch.liveSync, true, "The old release did not retain LiveSync mode.");
|
||||
assertEqual(oldBatch.shouldBatchSave, false, "The old release unexpectedly batches LiveSync mode.");
|
||||
await waitForPersistentNodeIdentity(cliBinary, session!.cliEnv);
|
||||
const before = await readRuntimeUpgradeState(cliBinary, session!.cliEnv);
|
||||
const originalEntry = await waitForLocalDatabaseEntry(cliBinary, session!.cliEnv, beforePath);
|
||||
await assertNote(vault, beforePath, beforeContent);
|
||||
await stop();
|
||||
const savedOldData = JSON.parse(
|
||||
await readFile(join(vault.path, ".obsidian/plugins/obsidian-livesync/data.json"), "utf8")
|
||||
);
|
||||
assertEqual(
|
||||
Object.prototype.hasOwnProperty.call(savedOldData, "batchSaveInLiveSync"),
|
||||
false,
|
||||
"The old settings fixture contains the new option."
|
||||
);
|
||||
|
||||
await start(vault, targetRoot);
|
||||
const upgraded = await readRuntimeUpgradeState(cliBinary, session!.cliEnv);
|
||||
assertEqual(upgraded.pluginVersion, targetManifest.version, "The requested target build was not loaded.");
|
||||
assertEqual(upgraded.localDatabaseName, before.localDatabaseName, "Upgrade replaced the local database.");
|
||||
assertEqual(upgraded.nodeId, before.nodeId, "Upgrade replaced the device identity.");
|
||||
assertEqual(
|
||||
upgraded.settings.activeConfigurationId,
|
||||
before.settings.activeConfigurationId,
|
||||
"Upgrade changed the remote profile."
|
||||
);
|
||||
assertEqual(upgraded.settings.couchDB_DBNAME, dbName, "Upgrade selected another remote database.");
|
||||
assertEqual(upgraded.settings.versionUpFlash, "", "Upgrade unexpectedly paused for compatibility review.");
|
||||
const firstBatch = await batching();
|
||||
assertUpgradedBatching(firstBatch, true);
|
||||
assertEqual(
|
||||
(await waitForLocalDatabaseEntry(cliBinary, session!.cliEnv, beforePath)).rev,
|
||||
originalEntry.rev,
|
||||
"Upgrade rewrote existing Metadata."
|
||||
);
|
||||
await assertNote(vault, beforePath, beforeContent);
|
||||
await createNote(afterPath, afterContent);
|
||||
await stop();
|
||||
|
||||
await start(verifier, targetRoot, true);
|
||||
await prepareRemote(cliBinary, session!.cliEnv);
|
||||
await pushLocalChanges(cliBinary, session!.cliEnv);
|
||||
await assertNote(verifier, beforePath, beforeContent);
|
||||
await assertNote(verifier, afterPath, afterContent);
|
||||
await createNote(returnPath, returnContent);
|
||||
await stop();
|
||||
|
||||
await start(vault, targetRoot);
|
||||
assertUpgradedBatching(await batching(), true);
|
||||
await assertNote(vault, returnPath, returnContent);
|
||||
await evaluate(`
|
||||
const s=core.services.setting.currentSettings();
|
||||
s.liveSync=false;s.periodicReplication=true;s.periodicReplicationInterval=60;
|
||||
await core.services.setting.saveSettingData();
|
||||
await core.services.control.applySettings();
|
||||
return true;
|
||||
`);
|
||||
assertUpgradedBatching(await batching(), false);
|
||||
await stop();
|
||||
await start(vault, targetRoot);
|
||||
const finalBatch = await batching();
|
||||
assertUpgradedBatching(finalBatch, false);
|
||||
assertEqual(finalBatch.periodicReplication, true, "The periodic mode choice did not survive restart.");
|
||||
await assertNote(vault, returnPath, returnContent);
|
||||
assertEqual(
|
||||
JSON.stringify(await artefactHashes(targetRoot)),
|
||||
JSON.stringify(hashes),
|
||||
"Target artefacts changed during the test."
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
passed: true,
|
||||
sourceVersion: sourceRelease.version,
|
||||
targetVersion: targetManifest.version,
|
||||
sourceHashes: Object.fromEntries(sourceRelease.files.map((file) => [file.name, file.sha256])),
|
||||
targetHashes: hashes,
|
||||
oldBatch,
|
||||
firstBatch,
|
||||
finalBatch,
|
||||
retainedDatabase: true,
|
||||
retainedNodeIdentity: true,
|
||||
retainedMetadataRevision: true,
|
||||
twoWayTransfer: true,
|
||||
naturalRestarts: true,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
await stop();
|
||||
await vault.dispose();
|
||||
await verifier.dispose();
|
||||
if (ownsDatabase) await deleteCouchDbDatabase(config, dbName);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
+49
-25
@@ -12,40 +12,64 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Customisation Sync
|
||||
## 1.0.41
|
||||
|
||||
#### Fixed
|
||||
|
||||
- We can reload the customisation list and apply every file in a multi-file item on the first attempt. (#1271)
|
||||
- Reloading or clearing the list also prevents earlier pending updates from restoring removed files and items.
|
||||
|
||||
### Setup tools
|
||||
|
||||
#### Fixed
|
||||
|
||||
- We can generate a Setup URI with prefixed connection and encryption variables, avoiding collisions with shell or system variables such as `hostname` and `USERNAME`. (#817, #1274)
|
||||
- The generator reads `LIVESYNC_HOST`, `LIVESYNC_USER`, `LIVESYNC_PASS`, `LIVESYNC_DB`, and `LIVESYNC_PASSPHRASE`. The previous names remain supported when the corresponding prefixed value is unset or blank.
|
||||
26th October, 2026
|
||||
|
||||
### Synchronisation
|
||||
|
||||
#### New Feature
|
||||
|
||||
- We can see CouchDB change-history comparison progressing before new documents arrive, with a ⏩ status icon and a comparison count before the upload count. An unchanged count disappears after three seconds.
|
||||
- We can hide or style individual status items with CSS snippets using the `livesync-stat-` classes. Tabular, right-aligned numbers retain their width while visible, reducing movement as counts change. Wider gaps between editor status items make the counts easier to read. The `livesync-stat-inactive` class lets CSS snippets control how inactive items disappear.
|
||||
- We can enable **Batch database updates in LiveSync mode** to combine repeated local edits before sending them to other devices.
|
||||
- The choice starts disabled, including when older settings contain the previously ignored batch option. Each mode retains its own choice and uses the same minimum and maximum delays.
|
||||
- Batching delays remote visibility and reduces intermediate updates. It does not establish when a template has finished or prevent genuine concurrent edits.
|
||||
- Rapid edits can produce fewer intermediate versions on other devices while LiveSync mode remains active.
|
||||
- Enable **Batch database updates in LiveSync mode** to group successive local edits.
|
||||
- Disabled by default; each synchronisation mode keeps its own choice.
|
||||
- Updates reach other devices later. Batching does not detect template completion or prevent genuine concurrent-edit conflicts.
|
||||
- CouchDB synchronisation no longer looks idle while it is comparing change history.
|
||||
- A ⏩ icon and comparison count show activity before documents are transferred.
|
||||
- This count represents history entries, not files remaining to synchronise.
|
||||
- Status counts are easier to follow, with less movement as values change.
|
||||
- CSS snippets can hide unwanted items or style individual `livesync-stat-` items, including inactive ones.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Local changes remain available for recovery while their database save is running, including when the application stops after a batch delay ends.
|
||||
- Manual synchronisation and settings application now wait for the accepted local saves to finish and report failures. Pending events retain the existing snapshot format.
|
||||
- Hidden File Sync and Customisation Sync now report completion after saving, including delayed customisation updates. Failed saves remain pending, while unchanged notifications allow later edits to proceed.
|
||||
- Start-up reconciliation checks content when modification times are equal, so an edit within the same timestamp interval is not mistaken for an unchanged file.
|
||||
- Existing revision provenance preserves the correct edit branch. Unreadable or continually changing content remains unresolved rather than being reported as successfully reconciled.
|
||||
- We receive a clear Notice when **Automatic** requires Hidden File Sync to be enabled on this device. Our existing mode remains unchanged, and hidden-file initialisation cannot start while the feature is disabled. (#1270)
|
||||
- We can initialise a whole plug-in's **MAIN** and **DATA** selections using the chosen Automatic initial action.
|
||||
- We can run startup integrity checks with lower memory use for healthy Vault files. Files that change between the size check and content reading no longer trigger an unnecessary repair warning when their actual sizes agree and there are no conflicts. (#1283)
|
||||
- Recent Vault edits remain recoverable after an interrupted local database save.
|
||||
- This includes edits whose modification times compare equal to the database copy, and pending Hidden File Sync and Customisation Sync changes.
|
||||
- Manual synchronisation no longer starts before already accepted local changes finish saving to the database.
|
||||
- Settings application also waits for those saves. Failed saves stay pending and report a failure.
|
||||
- Text from third-party tools is handled more robustly.
|
||||
- Characters split across Chunks no longer trigger false corruption warnings.
|
||||
- Start-up integrity checks use less memory for healthy Vault files. (#1283)
|
||||
- Start-up checks avoid false repair warnings for files that change during inspection. (#1283)
|
||||
- This applies when the final size matches the database and there are no conflicts.
|
||||
|
||||
### Customisation Sync
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Applying a multi-file customisation no longer skips files after the list is refreshed. (#1271)
|
||||
- Choosing **Automatic** now explains a missing Hidden File Sync prerequisite instead of proceeding into a failed initialisation. (#1270)
|
||||
- Enable and initialise Hidden File Sync on this device first. The current mode is kept when the prerequisite is missing.
|
||||
- Initialising a whole plug-in in **Automatic** mode no longer omits its selected **MAIN** or **DATA** files.
|
||||
|
||||
### Setup tools
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Setup URI generation can avoid shell-variable collisions and unintended use of the operating-system username. (#817, #1274)
|
||||
- Use `LIVESYNC_HOST`, `LIVESYNC_USER`, `LIVESYNC_PASS`, `LIVESYNC_DB`, and `LIVESYNC_PASSPHRASE` for connection and encryption values.
|
||||
- Existing scripts remain supported: the previous names are used when the corresponding prefixed value is unset or blank.
|
||||
|
||||
### Miscellaneous
|
||||
|
||||
- Dependencies have been updated, including security fixes.
|
||||
|
||||
### Acknowledgements
|
||||
|
||||
Thank you for your contributions!
|
||||
|
||||
- @rwbru, for the original Setup URI environment-variable fix ([#817](https://github.com/vrtmrz/obsidian-livesync/pull/817)).
|
||||
- @junkerderprovinz, for adapting that fix to the current generator while preserving compatibility with existing scripts ([#1274](https://github.com/vrtmrz/obsidian-livesync/pull/1274)).
|
||||
- @s-qin, for reducing memory use during start-up checks and avoiding repeated content decoding in Commonlib ([#1283](https://github.com/vrtmrz/obsidian-livesync/pull/1283), [Commonlib #184](https://github.com/vrtmrz/livesync-commonlib/pull/184)).
|
||||
|
||||
## 1.0.40
|
||||
|
||||
|
||||
+2
-1
@@ -51,5 +51,6 @@
|
||||
"1.0.37": "1.7.2",
|
||||
"1.0.38": "1.7.2",
|
||||
"1.0.39": "1.7.2",
|
||||
"1.0.40": "1.7.2"
|
||||
"1.0.40": "1.7.2",
|
||||
"1.0.41": "1.7.2"
|
||||
}
|
||||
Reference in new issue
Block a user