Compare commits

...

757 commits
0.0.6 ... main

Author SHA1 Message Date
d99e249fa5 Durable rename
Some checks failed
Check / build (pull_request) Has been cancelled
E2E tests / build (pull_request) Has been cancelled
Publish CLI / publish-docker (pull_request) Has been cancelled
Publish server Docker image / publish-docker (pull_request) Has been cancelled
Check / build (push) Has been cancelled
E2E tests / build (push) Has been cancelled
Publish CLI / publish-docker (push) Has been cancelled
Publish server Docker image / publish-docker (push) Has been cancelled
2026-05-09 14:20:36 +01:00
6647a4e632 Improvements 2026-05-09 14:17:52 +01:00
201f9aeaee Remove clutter 2026-05-09 13:46:48 +01:00
682dc74497 Update local-client-cli and obsidian-plugin
Some checks failed
Check / build (pull_request) Has been cancelled
E2E tests / build (pull_request) Has been cancelled
Publish CLI / publish-docker (pull_request) Has been cancelled
Publish server Docker image / publish-docker (pull_request) Has been cancelled
Pulls the local-client-cli and obsidian-plugin changes from
asch/fix-everything onto a fresh branch off main.
2026-05-09 13:41:51 +01:00
40fbd42b92 Remove GH actions (#192)
Some checks are pending
Check / build (push) Waiting to run
E2E tests / build (push) Waiting to run
Publish CLI / publish-docker (push) Waiting to run
Publish server Docker image / publish-docker (push) Waiting to run
Reviewed-on: https://home.schmelczer.dev/git/git/andras/vault-link/pulls/192
Co-authored-by: Andras Schmelczer <andras@schmelczer.dev>
Co-committed-by: Andras Schmelczer <andras@schmelczer.dev>
2026-05-09 11:17:21 +01:00
0e3132f96c Add deterministic-tests (#190)
Some checks are pending
Check / build (push) Waiting to run
E2E tests / build (push) Waiting to run
Publish CLI / publish-docker (push) Waiting to run
Publish server Docker image / publish-docker (push) Waiting to run
Reviewed-on: https://home.schmelczer.dev/git/git/andras/vault-link/pulls/190
Co-authored-by: Andras Schmelczer <andras@schmelczer.dev>
Co-committed-by: Andras Schmelczer <andras@schmelczer.dev>
2026-05-09 10:15:21 +01:00
4482e0155f Migrate to forgejo & reformat (#189)
Some checks failed
Check / build (push) Waiting to run
E2E tests / build (push) Waiting to run
Publish CLI / publish-docker (push) Waiting to run
Publish server Docker image / publish-docker (push) Waiting to run
Deploy Documentation / build (push) Has been cancelled
- Migrate to forgejo
- Bump Rust & Node
- Reformat project
- Small script cleanup

Reviewed-on: https://home.schmelczer.dev/git/git/andras/vault-link/pulls/189
Co-authored-by: Andras Schmelczer <andras@schmelczer.dev>
Co-committed-by: Andras Schmelczer <andras@schmelczer.dev>
2026-05-08 21:53:33 +01:00
9a75569e83 Bump versions to 0.14.0 2025-12-14 23:31:40 +00:00
5efe30d9d6 Format & lint 2025-12-14 17:19:25 +00:00
0e0a85df82 Check node version 2025-12-14 17:19:25 +00:00
42a77a5cd5 Upload logs instead of printing them 2025-12-14 17:19:25 +00:00
4fb3839b3e Add lock tests 2025-12-14 17:19:25 +00:00
7daa363723 Unsubscribe in SyncClient 2025-12-14 17:19:25 +00:00
47f24e168b Wait for idle instead 2025-12-14 17:19:25 +00:00
b6ab01d56a Handle websocket race condition 2025-12-14 17:19:25 +00:00
580c993071 Reject pending locks on reset 2025-12-14 17:19:25 +00:00
299c3baea9 Don't publish PRs 2025-12-14 17:19:25 +00:00
1b71f3e780 Always kill server 2025-12-14 17:19:25 +00:00
8aba8ee44a Extract const 2025-12-14 17:19:25 +00:00
079cd26faa Bump versions to 0.13.1 2025-12-11 22:10:21 +00:00
f6dccc4492 Try fixing E2E tests more 2025-12-11 22:08:48 +00:00
387e7afd58 Allow-list error type 2025-12-10 23:14:50 +00:00
056fb96ce8 chmod +x 2025-12-10 22:35:44 +00:00
9ac7fdbeb7
Improve CI (#181) 2025-12-10 22:03:13 +00:00
8e4ac3a26a Fix manifests 2025-12-08 20:11:56 +00:00
e2b24725ef Bump versions to 0.13.0 2025-12-07 19:29:15 +00:00
2db49da654 Fix cron 2025-12-07 16:42:23 +00:00
dbc63fcecd Once an hour 2025-12-07 16:42:23 +00:00
ce6d44f26b Add log line 2025-12-07 16:42:23 +00:00
6608804d34 Refactor & lint 2025-12-07 16:42:23 +00:00
e47d8a8179 Fix file watching 2025-12-07 16:42:23 +00:00
e9252955b4 Align prettier & editorconfig 2025-12-07 16:42:23 +00:00
570c41299b Create vault dir if doesn't exist 2025-12-07 16:42:23 +00:00
78a706ab8d Move log level to config file 2025-12-07 16:42:23 +00:00
8439bd8b92 Delete temp folder before test 2025-12-07 16:42:23 +00:00
504ddb6ff6 Pick up new events API 2025-12-07 16:42:23 +00:00
0a5bbbf20e Fix and apply editorconfig 2025-12-07 16:42:23 +00:00
b05e415acf Apply editorconfig 2025-12-07 16:42:23 +00:00
ad3191957a Add event handler class 2025-12-07 16:42:23 +00:00
1ed22c72d7 Enforce editorconfig 2025-12-07 16:42:23 +00:00
e6bfefd2d5 Fix file creation deduplication 2025-12-07 16:42:23 +00:00
9e06d99512 Run all tests 2025-12-07 16:42:23 +00:00
3f2ecfb0b6 Use efficient filters 2025-12-07 16:42:23 +00:00
07cb8491e2 Bump versions to 0.12.0 2025-12-06 22:21:55 +00:00
aca1ca50a4 Update reconcile to 0.8.0 2025-12-06 22:20:31 +00:00
2885026d2f Remove serde_with and use human serde instead 2025-12-06 22:14:20 +00:00
e6f7543114 Fix broken endpoint 2025-12-06 22:01:01 +00:00
d979963f86 Fix http error handling in the client service 2025-12-06 22:00:54 +00:00
ea603f83fd Fix HTTP method of the server 2025-12-06 21:25:30 +00:00
66e2fb3768 Fix docs publishing 2025-12-06 21:16:12 +00:00
a1bda41646 Always fetch the right document version content 2025-12-06 11:44:57 +00:00
bfe3e9aeeb Merge branch 'asch/fix-tests' 2025-12-06 10:53:20 +00:00
5238d85181 Print more details 2025-12-06 10:52:46 +00:00
1646f74633 More frequent tests 2025-12-06 10:49:30 +00:00
7a13cb57ce
Investigate deadlock (#178) 2025-12-05 22:34:14 +00:00
77e0bb4caf Await all 2025-12-05 22:33:33 +00:00
e8d86c737b More logs 2025-12-05 22:29:46 +00:00
a5e128efcd Lint 2025-12-05 21:48:35 +00:00
8adb8841ef Investigate dead-lock 2025-12-05 21:42:34 +00:00
564d4a6c37 Lint 2025-12-03 23:24:53 +00:00
2607bc5213 Run E2E more often 2025-12-03 23:18:16 +00:00
8ef2f8c132 Escape vault name 2025-12-03 23:18:16 +00:00
dependabot[bot]
d39a91b447
Bump tsx from 4.20.5 to 4.20.6 in /frontend (#154)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 22:32:58 +00:00
dependabot[bot]
da2237fa68
Bump sass-loader from 16.0.5 to 16.0.6 in /frontend (#159)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 22:32:49 +00:00
dependabot[bot]
e98f7acefa
Bump log from 0.4.27 to 0.4.28 in /sync-server (#170)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 22:32:39 +00:00
8e336cb0f3 Bump versions to 0.11.1 2025-12-02 20:46:41 +00:00
f8d62f4416 Force install 2025-12-02 20:44:50 +00:00
89e3b61766 Fix release 2025-12-02 20:44:20 +00:00
215c024876 Bump versions to 0.11.0 2025-11-30 15:26:40 +00:00
bbf81d3111 Install set-version 2025-11-30 15:26:22 +00:00
9349afc00f Run lint & fmt 2025-11-30 15:24:52 +00:00
c7c96b787a Add log lines 2025-11-30 15:24:52 +00:00
7beda491e9 Rename method 2025-11-30 15:24:52 +00:00
515a8f2bf4 Install cargo machete 2025-11-30 15:24:52 +00:00
39860f7f04 Add lock on settings 2025-11-30 15:24:52 +00:00
3517af1461 Disallow changing settings while applying previous changes 2025-11-30 15:24:52 +00:00
89565e23f3 Log deduping 2025-11-30 15:24:52 +00:00
d07fa32ba3 Format 2025-11-30 15:24:52 +00:00
10bde4bc3a Fix race condition of client-side path deconflicting 2025-11-30 15:24:52 +00:00
952e89343a Don't broadcast without clients 2025-11-30 15:24:52 +00:00
2ce5faea92 Ignore ds store 2025-11-30 15:24:52 +00:00
b595a060a7 Await settings event handlers 2025-11-30 15:24:52 +00:00
5905aa37b9 Add copy to clipboard button 2025-11-30 15:24:52 +00:00
5417c1ddd0 Small clean up 2025-11-30 15:24:52 +00:00
84f077f36b Improve logging 2025-11-30 15:24:52 +00:00
4456767ec4 Clean up 2025-11-30 15:24:52 +00:00
e635e84aa4 Close unsued databases 2025-11-30 15:24:52 +00:00
10fdc938c5 Add error on duplicate plugin load 2025-11-30 15:24:52 +00:00
91f49d6997 Decrease parallelism 2025-11-30 15:24:52 +00:00
7a95d9f0a8 Use named group 2025-11-30 15:24:52 +00:00
e53482ced8 Make skipped file a warning 2025-11-30 15:24:52 +00:00
67c912ae4c Format 2025-11-30 15:24:52 +00:00
b0b5da7d37 Remove frequent popups 2025-11-30 15:24:52 +00:00
13f5456b39 Fix race condition 2025-11-30 15:24:52 +00:00
c10b6435d4 Don't download all documents when initial sync gets interrupted 2025-11-30 15:24:52 +00:00
476588a63b Don't print success twice 2025-11-30 15:24:52 +00:00
9d60ec14dd Improve API 2025-11-30 15:24:52 +00:00
d45d2c0be3 Fix E2E testing 2025-11-30 15:24:52 +00:00
82f11d8c86 Fix testing logic 2025-11-30 15:24:52 +00:00
c3cc678446 Stop leaking promises in ws manager 2025-11-30 15:24:52 +00:00
3ed2e4f666 Add api version check to client 2025-11-30 15:24:52 +00:00
b1826907e7 Add resetting tests 2025-11-30 15:24:52 +00:00
c3cbde052a Add server config for mergable extensions 2025-11-30 15:24:52 +00:00
7008c54e2e Run check.sh 2025-11-30 15:24:52 +00:00
18be9f4dd8 Fix lint 2025-11-30 15:24:52 +00:00
4b195b070d Expose new advanced settings 2025-11-30 15:24:52 +00:00
c4da1426b1 Fix compile 2025-11-30 15:24:52 +00:00
340c347841 Don't leak promises 2025-11-30 15:24:52 +00:00
c94d732f24 Fix resetting 2025-11-30 15:24:52 +00:00
d8058d396c Add awaitAll 2025-11-30 15:24:52 +00:00
ef4444afc2 Lint 2025-11-30 15:24:52 +00:00
fb2d82a06e Lint 2025-11-30 15:24:52 +00:00
5a0c64d39c Ban bad methods 2025-11-30 15:24:52 +00:00
17fa584ea1 use allSettled 2025-11-30 15:24:52 +00:00
83c15a77c3 Add 2 more settings from consts 2025-11-30 15:24:52 +00:00
3cdd2a4387 Use updated APIs 2025-11-30 15:24:52 +00:00
213a9e18fb Use new WS api 2025-11-30 15:24:52 +00:00
cb2a1c0df1 Fix reset logic for WS 2025-11-30 15:24:52 +00:00
9f1f4beae4 Renamce 2025-11-30 15:24:52 +00:00
12d8d15572 Add fetch controller tests 2025-11-30 15:24:52 +00:00
56c77dc3f6 Fix fetch controller 2025-11-30 15:24:52 +00:00
4186aa9e0c Formatting 2025-11-30 15:24:52 +00:00
71274d466c Extract function 2025-11-30 15:24:52 +00:00
9d645f43f8 Handle move on create 2025-11-30 15:24:52 +00:00
72ad82ab83 Fix dotfile handling 2025-11-30 15:24:52 +00:00
4fcd134e55 Extract consts 2025-11-30 15:24:52 +00:00
aa3c587002 Dedup paths on create document 2025-11-30 15:24:52 +00:00
10fd928459 Fix file operations 2025-11-30 15:24:52 +00:00
91675ea99c Add remove event listener methods 2025-11-30 15:24:52 +00:00
d4b68154df Export consts 2025-11-30 15:24:52 +00:00
c798d96009 Fix import 2025-11-30 15:24:52 +00:00
51baa4d8e0 Have the same error message for file not found 2025-11-30 15:24:52 +00:00
a57ed5c4ae Fix edge cases 2025-11-30 15:24:52 +00:00
c3c2cafde5 Fix +1 2025-11-30 15:24:52 +00:00
f11c8db6d2 Replace all instead of just replace 2025-11-30 15:24:52 +00:00
9c3dedad76 Rename param 2025-11-30 15:24:52 +00:00
d590a2c9c8 Extend 2025-11-30 15:24:52 +00:00
511ac78e6d Don't kill CI with E2E tests 2025-11-30 15:24:52 +00:00
aaeca588fb Enforce british english 2025-11-30 15:24:52 +00:00
1b1b72cb92 Configure dependabot for docs 2025-11-30 15:24:52 +00:00
fbf03c41e0 Refactor plugin setup and avoid dangling resources 2025-11-30 15:24:52 +00:00
a1a4610109 Simplify docs 2025-11-30 15:24:52 +00:00
00d2061627 Update docs 2025-11-30 15:24:52 +00:00
38810579ec Update type imports 2025-11-30 15:24:52 +00:00
ea189f3d09 All sync-client deps are devDeps 2025-11-30 15:24:52 +00:00
fccc66aaea Re-export type 2025-11-30 15:24:52 +00:00
50a95b114d Add docs 2025-11-30 15:24:52 +00:00
56c1f4d58b Restructure packages 2025-11-30 15:24:52 +00:00
dependabot[bot]
812eb7a644
Bump tracing-subscriber from 0.3.19 to 0.3.20 in /sync-server (#146)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-22 12:39:48 +00:00
72bae2d93e Bump versions to 0.10.1 2025-11-19 22:40:36 +00:00
e2189d4dbe Use stderr for logging 2025-11-19 22:39:06 +00:00
c08feba0ad
Improve settings (#168)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-19 19:53:10 +00:00
e75298c4f1 Bump versions to 0.10.0 2025-11-16 22:10:42 +00:00
be1635c26e
Improve network usage for small text changes (#166) 2025-11-16 22:10:22 +00:00
dependabot[bot]
1da17c462e
Bump anyhow from 1.0.98 to 1.0.100 in /sync-server (#150)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-02 17:54:12 +00:00
dependabot[bot]
29747d0829
Bump commander from 12.1.0 to 14.0.2 in /frontend (#149)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-02 17:53:58 +00:00
dependabot[bot]
f1c2c8f846
Bump obsidian from 1.8.7 to 1.10.2 in /frontend (#155)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-02 17:53:14 +00:00
dependabot[bot]
04034b85da
Bump regex from 1.11.1 to 1.12.2 in /sync-server (#152)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-02 17:53:01 +00:00
dependabot[bot]
c3773a2a7a
Bump tokio from 1.47.1 to 1.48.0 in /sync-server (#151)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-02 17:52:52 +00:00
dependabot[bot]
97a4494085
Bump @plausible-analytics/tracker from 0.4.0 to 0.4.3 in /frontend (#145)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-02 17:52:36 +00:00
dependabot[bot]
33a24c3a77
Bump serde_with from 3.15.0 to 3.15.1 in /sync-server (#153)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-02 17:52:27 +00:00
dependabot[bot]
0a7b8568e8
Bump rust from 1.90-slim-trixie to 1.91-slim-trixie in /sync-server (#156)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-02 17:52:18 +00:00
cd57ea6682
Add log rotation to server & UI improvements (#157) 2025-11-02 17:52:04 +00:00
dependabot[bot]
2b9d77d165
Bump eslint from 9.28.0 to 9.38.0 in /frontend (#142)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-26 11:02:41 +00:00
dependabot[bot]
b4ff4cbf25
Bump uuid from 11.1.0 to 13.0.0 in /frontend (#143)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-26 11:02:30 +00:00
4fce317dea Bump versions to 0.9.2 2025-10-21 22:47:34 +01:00
dd5334c538 Fix versioning 2025-10-21 22:47:29 +01:00
4704d258ea Bump versions to 0.9.1 2025-10-21 22:46:25 +01:00
90752e687a
Add local CLI (#144) 2025-10-21 22:45:47 +01:00
a31c2d87b5 Bump versions to 0.9.0 2025-10-20 20:26:14 +01:00
1ddba47b80
Fix folder deletion (#140) 2025-10-20 20:24:35 +01:00
aa73a5d718 Bump versions to 0.8.3 2025-10-19 15:03:45 +01:00
90abf5ab14 Fix lint 2025-10-19 14:57:49 +01:00
d97a177edf Bump versions to 0.8.2 2025-10-19 14:43:30 +01:00
3b018819aa Wrong crate 2025-10-19 14:43:11 +01:00
00fd7e2516 Bump versions to 0.8.1 2025-10-19 12:00:35 +01:00
215a05d84a Update instructions 2025-10-19 12:00:31 +01:00
5e3544f601 Allow running startup script 2025-10-19 11:59:05 +01:00
1b5f236674 Add migration docs 2025-10-19 11:53:58 +01:00
12aa457e3a Add has_been_merged to DB 2025-10-19 11:47:55 +01:00
de143f9033 Add lint fixer mode 2025-10-18 21:33:56 +01:00
dependabot[bot]
a3621b6d90
Bump serde_with from 3.12.0 to 3.15.0 in /sync-server (#133)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-18 21:32:16 +01:00
dependabot[bot]
7c48e27dbd
Bump rust from 1.89-slim-trixie to 1.90-slim-trixie in /sync-server (#126)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-18 21:32:05 +01:00
dependabot[bot]
59e02bcb4d
Bump npm-check-updates from 18.0.1 to 19.1.1 in /frontend (#137)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-18 21:31:53 +01:00
dependabot[bot]
4556cc6cec
Bump @types/node from 22.18.0 to 24.8.1 in /frontend (#138)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-18 21:31:43 +01:00
dependabot[bot]
0e6b2c4985
Bump concurrently from 9.1.2 to 9.2.1 in /frontend (#116)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-18 21:31:34 +01:00
088f474a2e Fix remote cursor duplication 2025-10-18 21:30:45 +01:00
c0171ad72f Format 2025-10-18 20:35:15 +01:00
b6c85f6370 Change dev setup 2025-10-18 20:35:04 +01:00
acdacf655d
Bump versions to 0.8.0 2025-08-31 10:44:19 +01:00
0f38d42212
Fix script 2025-08-31 10:44:08 +01:00
1d19ceabd3
Bump versions to 0.7.0 2025-08-30 22:24:16 +01:00
a919b04cf0
Add telemetry 2025-08-30 22:24:08 +01:00
4cdd0cbd40
Build server for multiple arch (#106) 2025-08-30 21:50:34 +01:00
9177984ff6
Move more logic into sync-client 2025-08-30 11:02:04 +01:00
dependabot[bot]
3f089bd37e
Bump prettier from 3.5.3 to 3.6.2 in /frontend (#108)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Andras Schmelczer <andras@schmelczer.dev>
2025-08-30 10:38:25 +01:00
0ff3bb5967
Migrate from Jest to node:test (#115) 2025-08-30 10:38:08 +01:00
d33f80cca6
Fix E2E tests (#114) 2025-08-30 10:19:31 +01:00
27e2082747
Bump versions to 0.6.3 2025-08-28 21:55:51 +01:00
376008de54
Improve editor sync status line 2025-08-28 21:55:43 +01:00
dependabot[bot]
47f4ddfc63
Bump ts-jest from 29.3.4 to 29.4.1 in /frontend (#107)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-28 19:18:29 +01:00
dependabot[bot]
524de60585
Bump ws from 8.18.2 to 8.18.3 in /frontend (#109)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-28 19:18:12 +01:00
dependabot[bot]
606b674a98
Bump @eslint/plugin-kit from 0.3.1 to 0.3.3 in /frontend in the npm_and_yarn group across 1 directory (#89)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-27 22:33:18 +01:00
dependabot[bot]
2f251f72fc
Bump alpine from 3.22.0 to 3.22.1 in /sync-server (#88)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-27 22:33:11 +01:00
36f2dc0d43
Bump versions to 0.6.2 2025-08-27 22:33:02 +01:00
37ca507ae4
Add Claude file 2025-08-27 22:32:52 +01:00
eb6200dd73
Merge branch 'main' of https://github.com/schmelczer/obsidian-shared-sync 2025-08-27 22:31:32 +01:00
b2f4e0c038
Show files open by other users 2025-08-27 22:31:29 +01:00
dependabot[bot]
2500378de0
Bump jest and @types/jest in /frontend (#71)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-27 21:49:17 +01:00
dependabot[bot]
6b12603915
Bump sass from 1.89.1 to 1.91.0 in /frontend (#104)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-27 20:20:13 +01:00
dependabot[bot]
79279df5b0
Bump typescript-eslint from 8.33.1 to 8.41.0 in /frontend (#105)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-27 20:19:57 +01:00
d513ad9824
Bump versions to 0.6.1 2025-08-26 22:23:05 +01:00
d6e4305588
Fix lint 2025-08-26 22:22:36 +01:00
2ff1384fde
Fix cursor moving perf 2025-08-26 21:17:57 +01:00
6afb828bd9
Fix CI 2025-08-26 21:06:22 +01:00
16fc3a8234
Bump versions to 0.6.0 2025-08-25 19:25:31 +01:00
de4763c2cd
Merge branch 'main' of https://github.com/schmelczer/obsidian-shared-sync 2025-08-25 19:25:24 +01:00
43311ed30b
Update e2e tests 2025-08-25 19:25:03 +01:00
dependabot[bot]
a27b039646
Bump rust from 1.87 to 1.89 in /sync-server (#99)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-25 17:17:18 +01:00
dependabot[bot]
8c6271cd0e
Bump tokio from 1.44.2 to 1.47.1 in /sync-server (#94)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-25 17:16:56 +01:00
a36a24effc
Fix main & improve cursor sync (#101) 2025-08-25 17:15:52 +01:00
81b81e30ff
Use unknown return type for callbacks 2025-08-17 15:12:31 +01:00
e73f147fbc
Add local prediction for remote cursor updates 2025-08-17 15:03:34 +01:00
b7e80c39f1
Fix tests 2025-08-17 15:01:45 +01:00
2d016c44bd
Add local selection update 2025-08-17 15:01:38 +01:00
a9ddd1032f
Lint flaky websocket factory 2025-08-17 14:59:41 +01:00
0916f54045
Small readme improvements 2025-08-17 14:59:21 +01:00
278fa912df
Rename field 2025-08-17 11:14:16 +01:00
cf8c9ebe00
Exclude target 2025-08-17 10:56:43 +01:00
a2cbcf0519
SLow down requests for development 2025-08-16 12:21:50 +01:00
6da107ff3a
Fix flaky websocket 2025-08-10 22:20:46 +01:00
bb07602c68
Send document versions with cursors 2025-08-10 14:55:40 +01:00
d9ffcfeb5c
Expose locks utils 2025-08-10 12:59:33 +01:00
b56e8f6c15
Rename 2025-08-10 12:59:14 +01:00
396d07be66
Bump versions to 0.5.1 2025-07-13 11:51:26 +01:00
e8a719f844
Fix docker 2025-07-13 11:51:20 +01:00
49dcc22982
Remove clutter 2025-07-13 11:51:15 +01:00
700903647e
Bump versions to 0.5.0 2025-07-13 11:07:55 +01:00
019858917e
Fix script 2025-07-13 11:07:52 +01:00
bb0e44f06f
Extract reconcile (#85) 2025-07-13 11:06:42 +01:00
dependabot[bot]
75b020146a
Bump alpine from 3.21.3 to 3.22.0 in /backend (#59)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-08 21:08:11 +01:00
dependabot[bot]
8602b1c9a3
Bump typescript-eslint from 8.32.1 to 8.33.1 in /frontend (#60)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-08 21:07:59 +01:00
433e8f390f
Bump versions to 0.4.0 2025-06-08 20:58:30 +01:00
e8b9bf40c5
Add API for propagating cursor locations (#61) 2025-06-08 20:20:52 +01:00
dependabot[bot]
f97193e287
Bump @types/node from 22.14.0 to 22.15.27 in /frontend (#55)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-30 19:15:53 +01:00
dependabot[bot]
8daecb9b09
Bump ws from 8.18.1 to 8.18.2 in /frontend (#52)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-30 19:15:42 +01:00
483e03e2de
Bump versions to 0.3.15 2025-05-25 11:58:00 +01:00
b72b3488d8
Don't show .md extensions 2025-05-25 11:35:42 +01:00
d59203b5b9
Improve vault history messages 2025-05-25 11:35:20 +01:00
a340039301
Remove double-delete notifications 2025-05-25 09:57:22 +01:00
f93ca447d8
Improve history view UX 2025-05-24 19:05:12 +01:00
5c8b02f69c
Lint 2025-05-24 19:02:50 +01:00
1b21e194cf
Update types 2025-05-24 19:02:27 +01:00
063d78fad5
Support more history entry types 2025-05-24 18:51:59 +01:00
0f5bfa3d5e
Validate user config 2025-05-24 18:45:33 +01:00
31833a9f47
Match dotfiles 2025-05-24 14:35:25 +01:00
76a17c4221
Better default ignores 2025-05-24 14:35:16 +01:00
5d33b3de79
Rename dev version of plugin 2025-05-24 14:05:38 +01:00
22a13e0152
Change file limit from slider to number 2025-05-24 13:56:36 +01:00
383e2868c2
Small improvements 2025-05-24 13:56:06 +01:00
e0b83bbc7a
Rename 2025-05-24 13:23:24 +01:00
4040c98754
Allow multiple E2E test iterations 2025-05-24 13:23:17 +01:00
b17f34d402
Fix ignore patterns 2025-05-24 13:21:37 +01:00
287a4e15b4
Merge branch 'main' of https://github.com/schmelczer/obsidian-shared-sync 2025-05-24 11:17:40 +01:00
ffeec19ca7
Include content size in response 2025-05-24 11:17:34 +01:00
dependabot[bot]
d73f2a0009
Bump sqlx from 0.8.5 to 0.8.6 in /backend (#44)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-23 23:08:31 +01:00
dependabot[bot]
88be6f93b2
Bump ts-jest from 29.3.1 to 29.3.4 in /frontend (#45)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-23 23:08:20 +01:00
dependabot[bot]
b5a6c6a993
Bump clap-verbosity-flag from 3.0.2 to 3.0.3 in /backend (#41)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-23 23:08:13 +01:00
dependabot[bot]
33455d24fc
Bump typescript from 5.8.2 to 5.8.3 in /frontend (#40)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-23 23:08:00 +01:00
dependabot[bot]
a9dc9f8fe3
Bump openapi-fetch from 0.13.5 to 0.14.0 in /frontend (#43)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-23 23:07:51 +01:00
dependabot[bot]
11dde0baa4
Bump sass from 1.86.1 to 1.89.0 in /frontend (#42)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-23 23:07:41 +01:00
08914a8f16
Merge branch 'main' of https://github.com/schmelczer/obsidian-shared-sync 2025-05-23 22:03:53 +01:00
ef00174538
Fix E2E test by not creating deleted files 2025-05-23 21:58:01 +01:00
0cd2e9175f
Print pending id 2025-05-23 21:57:41 +01:00
0295b5633f
Allow deleting non-existent files 2025-05-23 21:56:39 +01:00
dependabot[bot]
650e14b82c
Bump typescript-eslint from 8.29.0 to 8.32.1 in /frontend (#39)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-23 21:46:42 +01:00
3fe70b37ec
Bump versions to 0.3.14 2025-05-22 22:25:31 +01:00
70fe45a09d
Rename user-agent header to device-id 2025-05-22 22:24:30 +01:00
5448c1cf99
Update API types 2025-05-22 21:42:34 +01:00
ceb217cda8
Add simple glob ignore patterns 2025-05-22 21:41:59 +01:00
bbb2adce63
Update REAMDE 2025-05-22 21:40:40 +01:00
6292b01464
Hide sqlx folder 2025-05-22 21:39:54 +01:00
dependabot[bot]
2e2da3a46d
Bump chrono from 0.4.40 to 0.4.41 in /backend (#34)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-22 21:32:03 +01:00
dependabot[bot]
1ab93167de
Bump anyhow from 1.0.97 to 1.0.98 in /backend (#38)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-22 21:31:49 +01:00
8eba310e8b
Fix dependabot config 2025-05-22 21:10:03 +01:00
961032b24f
Keep content on delete 2025-05-22 21:08:18 +01:00
715bbc4d2e
Add user and device provenance colums 2025-05-22 21:05:26 +01:00
bfb522b2c7
Update dependabot 2025-05-22 20:25:21 +01:00
201d76da52
Merge branch 'main' of https://github.com/schmelczer/obsidian-shared-sync 2025-05-20 20:08:08 +01:00
ec610c77fb
Randomise slow file event length 2025-05-20 20:08:02 +01:00
dependabot[bot]
4001150414
Bump clap from 4.5.37 to 4.5.38 in /backend (#36)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-20 19:50:52 +01:00
dependabot[bot]
519be6e46d
Bump rust from 1.86 to 1.87 in /backend (#37)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-20 19:50:44 +01:00
dependabot[bot]
87ec86a3ad
Bump sqlx from 0.8.3 to 0.8.5 in /backend (#30)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 22:16:18 +01:00
7776b69a0b
Update API types 2025-05-14 22:15:57 +01:00
0093d6132b
Bump versions to 0.3.13 2025-05-14 22:14:50 +01:00
45f9f37d0f
Make startup deletion check more robust 2025-05-14 22:14:34 +01:00
8f97e8e656
Bump versions to 0.3.12 2025-05-11 22:25:35 +01:00
de346b9fcf
Add sync status inside editor 2025-05-11 22:25:19 +01:00
8b22549f5e
Expose sync status per file 2025-05-11 22:24:43 +01:00
c9bf983f95
Merge branch 'main' of https://github.com/schmelczer/obsidian-shared-sync 2025-05-11 21:04:32 +01:00
1598e3c4d5
Handle syncing with selections 2025-05-11 15:50:24 +01:00
dependabot[bot]
5baf92b2ee
Bump clap from 4.5.35 to 4.5.37 in /backend (#32)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-11 14:53:02 +01:00
35bb7f2405
Fix cursor movement on shortened deletes and refactor 2025-04-13 23:06:07 +01:00
e2edc076b9
Bump versions to 0.3.11 2025-04-13 22:23:53 +01:00
23684c982e
Add comment 2025-04-13 22:23:35 +01:00
438f0b4d97
Fix out of bounds cursor checking 2025-04-13 22:23:28 +01:00
7c081e8939
Fix out of bounds cursors in case of trailing delete 2025-04-13 22:21:38 +01:00
78525cef45
Fix tests ignoring overflowing cursors 2025-04-13 22:21:19 +01:00
535b76bb71
Fix E2E 2025-04-13 22:06:35 +01:00
a82ed701ef
Bump versions to 0.3.10 2025-04-13 21:27:16 +01:00
b26552fc1f
Update API types 2025-04-08 23:05:06 +01:00
96bf542b91
Keep updating the last seen id correctly 2025-04-08 23:02:41 +01:00
f63d3dd830
Add websocket message type 2025-04-08 23:01:08 +01:00
0abd50ac0c
Add force setting to MinCovered 2025-04-08 23:00:44 +01:00
dc124ace20
Sort latest updates ascending 2025-04-08 22:23:01 +01:00
33fd127cf6
Take last_seen_vault_update_id as a WS message instead of query parameter 2025-04-08 22:22:37 +01:00
bda5f37385
Add min covered 2025-04-08 22:21:22 +01:00
1bd9331bfa
Fix E2E error 2025-04-08 20:45:42 +01:00
82e41e5b4e
Check in package.json for dependabot 2025-04-08 20:42:06 +01:00
dependabot[bot]
32fec24e04
Bump clap from 4.5.32 to 4.5.35 in /backend (#26)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-08 20:34:28 +01:00
dependabot[bot]
3095ec6876
Bump thiserror from 1.0.69 to 2.0.12 in /backend (#27)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-08 20:34:17 +01:00
af0d05a5bc
Merge branch 'main' of https://github.com/schmelczer/obsidian-shared-sync 2025-04-07 23:14:23 +01:00
fd6f40d72c
Bump versions to 0.3.9 2025-04-07 23:14:00 +01:00
3ec6bd4d5b
Allow overriding WebSocket implementation and add flaky version for testing 2025-04-07 23:13:45 +01:00
dependabot[bot]
bf283bbe7c
Bump schemars from 0.8.21 to 0.8.22 in /backend (#25)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-07 22:34:23 +01:00
74a8060246
Always normalise vaultId and trim token 2025-04-07 22:29:23 +01:00
04a24d0b38
Normalise settings values 2025-04-07 22:28:39 +01:00
51f69a39af
Add utils module 2025-04-07 22:26:16 +01:00
ff02fee6a5
Random case vaultId 2025-04-07 22:23:53 +01:00
a86a056888
Fix history ordering 2025-04-07 20:22:30 +01:00
637aa523c6
Merge branch 'main' of https://github.com/schmelczer/obsidian-shared-sync 2025-04-06 16:46:30 +01:00
f6015a9c43
Fix healthcheck 2025-04-06 16:46:23 +01:00
dependabot[bot]
3238d7b819
Bump tokio from 1.44.1 to 1.44.2 in /backend (#23)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-06 16:28:25 +01:00
dependabot[bot]
4cdc11cd50
Bump aide from 0.13.4 to 0.13.5 in /backend (#24)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-06 16:27:56 +01:00
59c143199c
Bump versions to 0.3.8 2025-04-06 13:56:03 +01:00
f8b0501eea
Fix E2E tests 2025-04-06 13:55:27 +01:00
29ff601545
Bump versions to 0.3.7 2025-04-06 13:28:27 +01:00
ac4c7fb1df
Faster tests 2025-04-06 13:28:07 +01:00
c734d256be
Fix lint 2025-04-06 12:16:03 +01:00
09f20900cd
Fix build 2025-04-06 12:14:33 +01:00
aa78e258e7
Enable more lints 2025-04-06 11:43:08 +01:00
48f301d8ab
Lint & format 2025-04-06 11:39:40 +01:00
9da05c6ff6
Fix cursor moving 2025-04-06 11:39:30 +01:00
f509a60f0a
Split integration tests 2025-04-06 11:20:58 +01:00
f747634aac
Format tests 2025-04-06 11:20:45 +01:00
a839ff8fc5
Fix npm dependabot 2025-04-05 17:12:25 +01:00
9399a335c1
Fix utf-8 cursor test 2025-04-05 14:47:13 +01:00
123d03fbed
Better docs 2025-04-05 14:47:04 +01:00
a1b3b61c43
Move & add tests 2025-04-05 14:46:34 +01:00
2b78f0c76f
Remove break_up_raw_operations 2025-04-05 13:57:48 +01:00
b230d34b88
Add left/right joinability for tokens 2025-04-05 13:48:02 +01:00
b0c6c082a1
Fix tests and ignore expensive test 2025-04-05 13:45:28 +01:00
b15e0319a3
Add BLNS 2025-04-05 13:44:31 +01:00
6ec07feb27
Fix assert message order 2025-04-05 11:58:47 +01:00
edca6f1717
Move ordered operation 2025-04-05 10:51:51 +01:00
8e1123016b
Support multi-document test files 2025-04-05 10:24:44 +01:00
5bb420e162
Add diff tests 2025-04-05 09:45:44 +01:00
0c6d36041c
Bump versions to 0.3.6 2025-04-04 23:16:32 +01:00
28ab87bda0
Fix CI 2025-04-04 23:16:17 +01:00
a25027bc90
Send device id to server 2025-04-04 23:15:05 +01:00
69438a78c6
Fix bug where we didn't update hash on updates 2025-04-04 23:14:37 +01:00
648db73628
Add device id and use it to filter out updates coming from the same device 2025-04-04 23:13:50 +01:00
11e2d121b1
Update API types 2025-04-04 22:02:24 +01:00
e6601af93f
Merge branch 'main' of https://github.com/schmelczer/obsidian-shared-sync 2025-04-04 21:58:34 +01:00
9199915362
Update editorconfig 2025-04-04 21:58:32 +01:00
d9774f364f
Add machete to checks 2025-04-04 21:58:19 +01:00
3881f56b45
Bump rust deps 2025-04-04 21:58:05 +01:00
dependabot[bot]
5923eaa88f
Bump rust from 1.85 to 1.86 in /backend (#22)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-04 21:52:19 +01:00
0e53631cc8
Format 2025-04-04 21:48:27 +01:00
b5e528d8b8
Use middleware instead of manual auth checks 2025-04-04 21:47:19 +01:00
fb71460fc3
Turn auth into a middleware 2025-04-04 21:46:08 +01:00
297787739b
Don't apply empty edits 2025-04-04 21:20:04 +01:00
9a0b8a07bf
Add response_timeout_seconds config 2025-04-04 21:19:54 +01:00
181ea4faef
Fix logs view 2025-04-03 22:46:07 +01:00
e3295a38af
Bump versions to 0.3.5 2025-04-03 21:45:01 +01:00
5328e3b0f6
Fix cursor movement on Windows 2025-04-03 21:44:45 +01:00
a0044badf3
Update node deps 2025-04-02 22:19:10 +01:00
4e45888040
Bump versions to 0.3.4 2025-04-02 22:18:16 +01:00
1f9728d893
Add cursor moving (#19) 2025-04-02 22:06:38 +01:00
dependabot[bot]
29d8779786
Bump sqlx from 0.8.2 to 0.8.3 in /backend (#20)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-02 20:10:25 +01:00
dependabot[bot]
ecaa914879
Bump insta from 1.41.1 to 1.42.2 in /backend (#15)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 22:51:12 +01:00
86dead5266
Bump versions to 0.3.3 2025-03-29 14:04:36 +00:00
0311883a16
Fix lint 2025-03-29 14:04:06 +00:00
bb5a0bde3a
Bump versions to 0.3.2 2025-03-29 13:36:44 +00:00
52bda89764
Fix jumping cursor 2025-03-29 13:36:32 +00:00
3bbc5c61e9
Bump versions to 0.3.1 2025-03-29 12:29:36 +00:00
fa4da6eb14
Merge branch 'main' of https://github.com/schmelczer/obsidian-shared-sync 2025-03-29 12:28:20 +00:00
c5af0d40d8
Pick up changed ping API 2025-03-29 12:28:12 +00:00
7413299cec
Fix typo 2025-03-29 12:26:59 +00:00
3d8152f6f5
Rate-limit updates 2025-03-29 12:26:52 +00:00
1eec55b2d0
Update example config 2025-03-29 12:25:32 +00:00
81c4cc991c
Print init errors 2025-03-29 12:25:24 +00:00
b3e98d32b6
Add vault-level access control 2025-03-29 12:25:15 +00:00
dependabot[bot]
5356dc0eb9
Bump rust from 1.83 to 1.85 in /backend (#6)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-29 11:03:53 +00:00
a8c813b9a7
Bump versions to 0.3.0 2025-03-29 11:02:57 +00:00
6fb922f4ba
Rate-limit DB writes 2025-03-29 11:02:42 +00:00
dependabot[bot]
44ab720b1d
Bump tokio from 1.42.0 to 1.44.1 in /backend (#14)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-29 10:18:35 +00:00
dependabot[bot]
cb5e930399
Bump anyhow from 1.0.94 to 1.0.97 in /backend (#13)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-29 10:18:24 +00:00
dependabot[bot]
2ac3630a65
Bump serde from 1.0.215 to 1.0.219 in /backend (#5)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-29 10:18:12 +00:00
1aad0fce31
Add WebSocket support (#12) 2025-03-29 10:17:46 +00:00
dependabot[bot]
3d27b7f313
Bump alpine from 3.21.0 to 3.21.3 in /backend (#8)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-25 22:28:42 +00:00
dependabot[bot]
aec3cd9b2f
Bump chrono from 0.4.38 to 0.4.40 in /backend (#11)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-25 22:28:28 +00:00
dependabot[bot]
237b4b9f9d
Bump uuid from 1.11.0 to 1.16.0 in /backend (#9)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-25 22:28:09 +00:00
ccff1cfc7a
Fix parsing 2025-03-24 22:03:43 +00:00
baba8f82bf
Take config path as input 2025-03-24 21:57:56 +00:00
958af89116
Rename config.yml 2025-03-24 21:57:26 +00:00
a6fd0f1dd7
Add clap 2025-03-24 21:57:05 +00:00
3e4e7d38d8
Bump versions to 0.2.2 2025-03-24 21:16:32 +00:00
0ae05dda16
Remove todos 2025-03-24 21:16:23 +00:00
272cb2e958
Create dependabot.yml 2025-03-23 15:13:45 +00:00
468d0ac8cf
Fix history view 2025-03-23 15:00:20 +00:00
58a61d036b
Update branch name 2025-03-22 20:55:14 +00:00
a4ee946b93
Bump versions to 0.2.1 2025-03-22 20:52:26 +00:00
bb015209a2
Fix build 2025-03-22 20:52:16 +00:00
8c98ee6c65
Bump versions to 0.2.0 2025-03-22 20:50:58 +00:00
a8cadd1e53
Fix test 2025-03-22 20:50:43 +00:00
62427183fd
Print current size not just limit 2025-03-22 20:32:02 +00:00
e83539bb48
Inject deps 2025-03-22 20:31:48 +00:00
e9c6f99df2
Give unique names to path params 2025-03-22 20:30:24 +00:00
407c56040e
Improve settings 2025-03-22 20:24:19 +00:00
3dbeb54c54
Only show file name on history card 2025-03-22 20:23:34 +00:00
84adda96c1
Improve message 2025-03-22 18:45:37 +00:00
30ecf52dde
Improve history view 2025-03-22 18:41:30 +00:00
abe074202b
Bump versions to 0.1.8 2025-03-22 18:11:10 +00:00
7153c06c63
Lint 2025-03-22 18:10:50 +00:00
eb9fadf714
Fix crashes 2025-03-22 18:10:39 +00:00
1b57e277a2
Use consistent vault name 2025-03-22 17:22:09 +00:00
8cfbaa1bda
Fix reset flow 2025-03-22 17:21:59 +00:00
bd44fe9c74
Remove debug step 2025-03-22 17:21:30 +00:00
a937f64fa0
Extract reset error 2025-03-22 17:07:04 +00:00
8a27987798
Make logs view performant 2025-03-22 17:06:32 +00:00
21075cafb3
Bump versions to 0.1.7 2025-03-22 16:16:03 +00:00
092f9ad2bc
Update todos 2025-03-22 16:15:57 +00:00
c7e53bff26
Hoist retry logic 2025-03-22 16:15:33 +00:00
80ad81f872
Fix volumes 2025-03-22 15:38:28 +00:00
acbc0c0e65
Improve server config setting section 2025-03-22 15:38:23 +00:00
da369e61e7
Fix E2E 2025-03-22 14:49:39 +00:00
84566c1b55
Improve CI 2025-03-22 14:41:20 +00:00
792097060b
Add utils folder 2025-03-22 14:12:45 +00:00
ef9ec69204
Bump versions to 0.1.6 2025-03-22 14:07:11 +00:00
ab12b07ed8
Add todos 2025-03-22 14:06:59 +00:00
5edf8f37a6
Rename 2025-03-22 14:06:36 +00:00
3501394de5
Respect sync enabled on load 2025-03-22 14:06:30 +00:00
6906bc4f5e
Avoid duplication from initial sync 2025-03-22 14:06:17 +00:00
8723c8499b
Fix status bar disabled state 2025-03-22 14:05:54 +00:00
2722f7c7fc
Stop exposing Syncer from SyncClient 2025-03-22 13:48:01 +00:00
93b43f57b7
Fix E2E tests 2025-03-22 12:25:31 +00:00
ba90fc0b41
Lint & format 2025-03-22 12:09:07 +00:00
79eb4f6c7b
Add lineending support and clean up 2025-03-22 12:05:16 +00:00
1c904909af
Fix updates 2025-03-22 12:04:43 +00:00
d885646f39
Configure line-endings 2025-03-22 12:04:33 +00:00
087d38f570
Extract error 2025-03-22 12:01:15 +00:00
16aac488cc
Add docs 2025-03-22 12:01:06 +00:00
7dcdc98b60
Fix testing setup 2025-03-22 11:53:20 +00:00
60f859b984
Improve docs 2025-03-22 11:44:37 +00:00
149b8a1de5
Lint connection status 2025-03-22 11:12:53 +00:00
b6d0416807
Lint 2025-03-20 22:28:59 +00:00
8a9f87cc05
Clean up API 2025-03-20 22:27:07 +00:00
e7ec41eafe
Remove deleted files from DB 2025-03-20 22:26:19 +00:00
1b7ab8b038
Fix reset logic 2025-03-20 21:19:59 +00:00
136514d33a
Rename & make idempotent 2025-03-20 21:19:41 +00:00
9e05734c5f
Add todos 2025-03-20 21:18:42 +00:00
198ac93c8c
Change fetch implementation passing 2025-03-20 21:18:22 +00:00
03d0b7e025
Use inlined sync history 2025-03-20 21:00:54 +00:00
e6563c99b0
Remove minimum log level 2025-03-20 20:59:49 +00:00
b00b9521c6
Sync all file types 2025-03-20 20:49:23 +00:00
7e1aeb5a9f
Update token 2025-03-20 20:49:07 +00:00
a9223156a6
Inline fetch-retry with cancellation 2025-03-20 20:49:00 +00:00
d772cda164
Use new settings API exposed directly through SyncClient 2025-03-20 20:44:03 +00:00
a39e0886c7
Export LogLine 2025-03-18 21:20:43 +00:00
82345cf1bf
Only use 2 clients for E2E 2025-03-18 21:20:15 +00:00
c278e9d131
Log to console 2025-03-18 21:13:47 +00:00
f07f372bc5
Try fixing E2E 2025-03-18 20:48:49 +00:00
e35af96db6
Add debug logging 2025-03-18 20:48:29 +00:00
d8f9268042
Bump versions to 0.1.5 2025-03-16 21:50:09 +00:00
c4e4a2a0f6
Rever docker publishing 2025-03-16 21:49:52 +00:00
0935433cc1
Bump versions to 0.1.4 2025-03-16 21:42:15 +00:00
d0e9b16073
Add tag as trigger 2025-03-16 21:41:57 +00:00
47af8323cf
Change port 2025-03-16 21:35:41 +00:00
088cedae9a
Bump versions to 0.1.3 2025-03-16 21:24:38 +00:00
84ddcaad84
Print logs 2025-03-16 21:24:28 +00:00
0564885aa6
Fix e2e CI 2025-03-16 21:20:07 +00:00
4bd5dbb1e0
Check vars 2025-03-16 21:15:36 +00:00
ad0b72e524
Bump versions to 0.1.2 2025-03-16 21:09:37 +00:00
eb2a186d1d
Try fixing CI again 2025-03-16 21:09:25 +00:00
9c512da460
Fix path 2025-03-16 21:00:56 +00:00
8b8665c7ae
Bump versions to 0.1.1 2025-03-16 21:00:21 +00:00
f1dc849330
Fix docker publish fix 2025-03-16 21:00:07 +00:00
77dd086603
Bump versions to 0.1.0 2025-03-16 20:45:16 +00:00
577be484b8
Fix docker publishing & version bumping 2025-03-16 20:45:03 +00:00
535169a039
Try fixing CI 2025-03-16 20:37:40 +00:00
75583dedbe
Fix bump version 2025-03-16 20:15:45 +00:00
0688033ff3
Bump versions to 0.0.31 2025-03-16 20:15:23 +00:00
8b8f1d91d9
Fix syncing when network latency is present (#4)
* WIP

* Add debug

* Dedupe inserts

* Add deterministic ordering

* Fix whitespaces

* Update insta

* Add integration test script

* Rename

* Add test

* Working for non-deletes

* omg it mostly works for deletes

* Isdeleted fix

* remove created dates

* update api

* Take document id

* No max attempt

* works

* Use string uuids

* .

* working!!!! (hopefully)

* Improve bundling

* Add module

* lint

* .

* lint

* Fix CI

* use toolchain

* clean up

* Add useSlowFileEvents

* Delete fuzz

* Fix CI

* use docker

* fix script

* clean up

* Clean up

* change node version

* Build docker image on every commit

* fix ci

* 1 db per vault

* Add scritps folder

* Bump versions

* Lint

* .

* Fix tests for real

* Style

* .

* try

* Consistent ordering

* Fix tests

* hmm

* .

* Clean up diff

* Fixes

* .

* Fix version bump

* .

* .

* .
2025-03-16 20:13:49 +00:00
bcf48c428d
Log inside locks 2025-03-01 17:58:55 +00:00
ab5336f567
Purge no-op history events 2025-03-01 17:58:19 +00:00
3b88f98fd9
Update README 2025-02-27 22:27:33 +00:00
453f33817b
Add ensureConsistency to DB 2025-02-27 22:27:10 +00:00
dc31afd907
Update test 2025-02-26 23:12:11 +00:00
e493b98a24
Don't merge with existing document on create for correctness reasons 2025-02-26 23:11:46 +00:00
f8b6718a22
Set jitter 2025-02-25 22:54:04 +00:00
0b5f3f3921
Don't trigger delete 2025-02-25 22:53:25 +00:00
9cebf53707
Allow overriding fetch implementation 2025-02-25 22:52:47 +00:00
6999276af4
Format again 2025-02-25 22:51:03 +00:00
d19b1ff449
Remove chalk 2025-02-25 22:50:46 +00:00
d0302a72c3
Fix correctness issues 2025-02-25 22:18:47 +00:00
91af4dc143
Update lock 2025-02-25 22:05:43 +00:00
6b9f1e6c12
Format 2025-02-25 21:56:34 +00:00
f6ee0b727b
Change DB move API 2025-02-25 20:38:20 +00:00
a5bcaec9fe
Fix inifinite loop at end of test 2025-02-25 20:33:10 +00:00
a7b518d7ea
Fix deconflicting 2025-02-24 23:01:24 +00:00
becd7c222e
Improve agent action logic with forbidden renames 2025-02-24 22:47:09 +00:00
e186a593ff
Extract helper and lint 2025-02-24 22:46:45 +00:00
5844e282e2
Add test 2025-02-24 22:25:03 +00:00
5bde266c84
Fix wording 2025-02-23 21:35:33 +00:00
4daa8ce7c7
Deconflict local renames 2025-02-23 21:35:07 +00:00
0111afd296
Extract & add test 2025-02-23 21:34:19 +00:00
b194bc94d2
Format 2025-02-23 17:23:19 +00:00
41c7ebcd87
Clean up deps 2025-02-23 17:23:04 +00:00
c69f84edf5
Fix agent behaviour 2025-02-23 16:45:52 +00:00
b4783d1007
Bump deps & fix compile 2025-02-23 16:19:19 +00:00
ff5b987688
Improve debugging 2025-02-23 14:47:30 +00:00
abba023c20
Gate settings updates 2025-02-23 14:47:20 +00:00
cb3ffde342
Improve testing 2025-02-23 14:14:33 +00:00
36633dfbcb
Add comments 2025-02-23 14:13:40 +00:00
c5a89f5205
Fix path deduping again 2025-02-23 14:12:42 +00:00
c5075b6bea
Fix comments 2025-02-23 13:56:46 +00:00
0ab89ccf01
Fix dedup logic 2025-02-23 13:56:37 +00:00
1f4ea3091a
Add regex crate 2025-02-23 13:56:29 +00:00
4ed2095fd6
Dedup renamed files 2025-02-23 12:30:59 +00:00
eeb7999d76
Reset locks 2025-02-23 11:31:03 +00:00
78266267c6
Format 2025-02-23 10:48:30 +00:00
df0fae74ec
Don't ignore lints 2025-02-23 10:48:22 +00:00
d33d49baa2
Fix eslint ignores 2025-02-23 10:48:06 +00:00
74cb30b5ec
Pick up document locks 2025-02-23 10:45:10 +00:00
cd70f8b426
Lint 2025-02-23 10:44:51 +00:00
9f46af4a65
Fix persistence provider types 2025-02-23 10:43:28 +00:00
5ba898df7d
Set up linting for test-client 2025-02-23 10:42:32 +00:00
b6547f9e29
Make tests pass 2025-02-23 10:42:10 +00:00
3d8067e947
Stop using global file locks 2025-02-23 10:41:59 +00:00
f8dcd33d3e
Lint & format 2025-02-23 10:13:34 +00:00
0bf5f024ea
Format 2025-02-23 10:12:52 +00:00
a2b7ad2a19
Split syncer logic 2025-02-23 10:12:22 +00:00
80ba346d46
Make less verbose 2025-02-23 10:11:30 +00:00
67ad7d8fef
Fix logical error 2025-02-23 10:11:20 +00:00
9c61927c1b
Improve test 2025-02-23 10:04:27 +00:00
0612f15aad
Add FileNotFoundError error 2025-02-23 10:04:20 +00:00
5bd92c8412
Remove global files & store data as field 2025-02-23 09:59:57 +00:00
d76b0444bc
Put back +1s 2025-02-22 20:51:52 +00:00
1226bdd9cb
Add filename collisions 2025-02-22 19:23:45 +00:00
ca225a71be
Lint & format 2025-02-22 17:25:26 +00:00
27423bf3cd
Improve testing 2025-02-22 17:17:22 +00:00
f73b5ecb71
Fixes & refactor 2025-02-22 17:17:07 +00:00
e6eedab87d
Rename onunload 2025-02-22 15:18:30 +00:00
39561d386e
Add test-client build files 2025-02-22 15:13:01 +00:00
1be1764db6
Colour agent logs 2025-02-22 15:12:17 +00:00
3471a9c498
Stop Logger being a singleton 2025-02-22 15:11:59 +00:00
d965265709
Improve logger API 2025-02-22 15:09:05 +00:00
8b07507090
Refactor and improve Syncer API 2025-02-22 13:03:11 +00:00
b0192aae23
Lint & add comments 2025-02-22 12:57:09 +00:00
5abbd5d8ee
Fix typo 2025-02-22 12:56:33 +00:00
4872f6d3b3
WIP test client 2025-02-22 12:56:23 +00:00
fde1fecbb6
Move file handling logic inside of client 2025-02-22 11:09:28 +00:00
db8e4bc2e7
Hide dist 2025-02-20 22:26:54 +00:00
bb9acaf656
Pick up simple client 2025-02-20 22:26:39 +00:00
eb1ad9921a
Simplify API 2025-02-20 22:22:20 +00:00
010b3d61e9
Make strict 2025-02-20 22:21:07 +00:00
0e0700821d
Lenient linting 2025-02-19 22:00:27 +00:00
b69ab30ea4
Format 2025-02-19 21:51:13 +00:00
6f05fc2b93
Fix lint 2025-02-19 21:43:21 +00:00
450bddf900
Fix CI 2025-02-19 21:34:42 +00:00
614e4a780a
Extract settings from database 2025-02-19 21:32:40 +00:00
aef5952c4d
Remove clutter 2025-02-19 21:29:53 +00:00
e97d97905e
Enable deving 2025-02-19 21:29:43 +00:00
00fa7b1c8b
Lint 2025-02-19 20:52:05 +00:00
dd6f63f357
Move files 2025-02-19 20:47:52 +00:00
6bb051460e
Update dependencies 2025-02-18 22:46:02 +00:00
61269ca1e5
Remove clutter 2025-02-18 22:44:42 +00:00
eb88d35c2e
Set up monorepo workspace 2025-02-18 22:36:53 +00:00
ae3acb9e1e
Extract library from plugin 2025-02-18 22:32:41 +00:00
8374c971ee
Bump versions to 0.0.30 2025-01-19 21:34:38 +00:00
b03e829450
Fix CI 2025-01-19 21:34:28 +00:00
bc76942047
Bump versions to 0.0.29 2025-01-19 13:00:18 +00:00
b4b4680422
Double check before delete 2025-01-19 13:00:07 +00:00
6e9558f13e
Bump versions to 0.0.28 2025-01-14 21:45:30 +00:00
60181ae53f
Add fetch doc version endpoints 2025-01-14 21:41:11 +00:00
be591072f4
Fix lint 2025-01-12 13:51:29 +00:00
1795d33986
Bump versions to 0.0.27 2025-01-12 12:00:20 +00:00
0e7acc3b7a
Change virtual scroller 2025-01-12 12:00:09 +00:00
b61c08041e
Enable merging 2025-01-12 11:36:45 +00:00
eee78fd1a7
Fix CI 2025-01-12 11:34:45 +00:00
e6ffe8cbdc
Fix file moves 2025-01-12 11:34:40 +00:00
2acd02b67e
Bump versions to 0.0.26 2025-01-11 11:10:12 +00:00
b567ae37df
Add hyperlist 2025-01-11 11:09:59 +00:00
09ab15fb0f
Only sync eligible files 2025-01-11 10:53:58 +00:00
49cced2de6
Refactor, normalize reads, and add isFileEligibleForSync 2025-01-11 10:53:50 +00:00
eb6f7d5a58
Reorder initialization 2025-01-11 10:53:15 +00:00
a9227fa5bb
Merge based on file type 2025-01-11 10:53:05 +00:00
47a34ed7e2
Format 2025-01-09 21:53:48 +00:00
cc658b8ca7
Bump versions to 0.0.25 2025-01-09 21:50:34 +00:00
9a321121af
Fix file writing 2025-01-09 21:50:24 +00:00
bdbfe2d33c
Bump versions to 0.0.24 2025-01-08 22:55:40 +00:00
d8098740b3
Lint 2025-01-08 22:55:27 +00:00
5582691cf7
Remove debug logging and add file size limit 2025-01-08 22:25:04 +00:00
92765f7a8a
Bump versions to 0.0.23 2025-01-08 21:55:36 +00:00
1566197b60
Try with not using adapter 2025-01-08 21:55:27 +00:00
b93e2c7c83
Bump versions to 0.0.22 2025-01-08 21:47:01 +00:00
bef4d4e08a
. 2025-01-08 21:46:51 +00:00
784ed12384
Bump versions to 0.0.21 2025-01-08 20:36:15 +00:00
954dc1ecad
Use forms 2025-01-08 20:36:03 +00:00
59403d8a52
Render types 2025-01-07 22:33:33 +00:00
f8dcca5367
Don't read same file multiple times 2025-01-07 22:32:15 +00:00
72be6ba18b
Implement multipart upload endpoints 2025-01-07 22:29:13 +00:00
f4a87d073a
Bump rust edition & reformat 2025-01-07 22:25:59 +00:00
13d5b35d1a
Add formdata deps 2025-01-07 22:24:45 +00:00
924d682263
Inherit metadata 2025-01-07 22:23:28 +00:00
55a801ba5e
Bump versions to 0.0.20 2025-01-06 23:10:16 +00:00
0721b2ecb6
. 2025-01-06 23:10:05 +00:00
c9d7a86a27
Merge pull request #3 from schmelczer/asch/test
Asch/test
2025-01-06 22:43:48 +00:00
ce722b495c
Bump versions to 0.0.19 2025-01-06 22:41:55 +00:00
ac0296570f
Try 2025-01-06 22:41:39 +00:00
16fc104a79
Merge pull request #2 from schmelczer/asch/test
Asch/test
2025-01-06 22:27:26 +00:00
f9bdf61532
Bump versions to 0.0.18 2025-01-06 22:26:07 +00:00
4fc628ecef
Add more logs 2025-01-06 22:25:51 +00:00
d123087322
Merge pull request #1 from schmelczer/asch/test
Asch/test
2025-01-06 22:00:13 +00:00
d5c2d1ecbe
Bump versions to 0.0.17 2025-01-06 21:57:15 +00:00
a751646d2a
Fix script 2025-01-06 21:57:09 +00:00
13ac3bb140
Fix script 2025-01-06 21:56:20 +00:00
5c6c3652ae
Add log lines 2025-01-06 21:55:20 +00:00
b713a83c3f
Fix CI 2025-01-05 22:01:43 +00:00
f09af5be8f
Bump versions to 0.0.16 2025-01-05 21:56:31 +00:00
5e8a6e50cd
Fix base64 on mobile 2025-01-05 21:56:22 +00:00
76683f9b0a
Fix badges 2025-01-05 21:19:49 +00:00
a57dd6237a
Update deps 2025-01-05 21:19:15 +00:00
1af24ccddd
Bump versions to 0.0.15 2025-01-05 21:12:58 +00:00
509f214e62
Fix CI 2025-01-05 21:12:51 +00:00
2e1ed1fd94
Bump versions to 0.0.14 2025-01-05 21:06:31 +00:00
e74e11e69f
Change docker work dir 2025-01-05 21:05:05 +00:00
540f06efd6
Fix lint 2025-01-05 21:03:50 +00:00
e2164874dd
Remove comment 2025-01-05 21:02:52 +00:00
a57fd5c9a6
Add comment 2025-01-05 21:01:11 +00:00
8f3947deec
Ignore tests 2025-01-05 21:01:09 +00:00
68cb76e6ff
Set value before onChange 2025-01-05 20:58:14 +00:00
f1cc7441a4
Fix wasm tests 2025-01-05 20:58:12 +00:00
e43a13648b
Move ser/deser logic to JS 2025-01-05 20:49:38 +00:00
7e045caab1
Fix Jest WASM tests 2025-01-05 20:49:26 +00:00
438caa96a6
Format files 2025-01-05 15:35:51 +00:00
02486d671e
Migrate to webpack 2025-01-05 15:30:38 +00:00
4d59ec927c
Remove clutter 2025-01-05 13:32:35 +00:00
9db478bc23
Change encoding/decoding 2025-01-05 13:32:21 +00:00
b549bb96b6
Add .vscode 2025-01-05 13:08:17 +00:00
d91da39884
Rename config.yaml to config.yml 2025-01-05 11:33:05 +00:00
55f92398c4
Bump versions to 0.0.13 2025-01-04 17:38:15 +00:00
067f4aea2c
Add todos 2025-01-04 17:38:08 +00:00
9973542ba4
Formatting 2025-01-04 17:38:02 +00:00
508377c005
Fix local testing 2025-01-04 17:37:37 +00:00
f87352a9e6
Make API more intuitive 2025-01-04 17:37:28 +00:00
d069939c6b
Fix logs UX 2025-01-04 17:04:50 +00:00
64274f4de5
Fix splitting logic 2025-01-04 17:04:35 +00:00
388b7bfabb
Revert self-hosted docker 2025-01-04 16:42:21 +00:00
51d524cc77
Lint 2025-01-04 16:17:00 +00:00
6d5b183a3c
Sanitize relative paths server-side 2025-01-04 16:16:54 +00:00
0943681702
Move app state 2025-01-04 16:15:15 +00:00
cd7fe5fe39
Update variable name 2025-01-04 15:22:53 +00:00
e14fe4240e
Misc 2025-01-04 15:16:44 +00:00
0e45b5da61
Make logs view prettier 2025-01-04 15:03:10 +00:00
b3dec9f7cc
Refactor settings and add view settings sub-page 2025-01-04 15:02:48 +00:00
46faa954b6
Fix log level precedence 2025-01-04 15:02:07 +00:00
560e640b1b
Fix settings change printing 2025-01-04 14:55:34 +00:00
b17e69edda
Rename plugin 2025-01-04 14:48:42 +00:00
dd86a507d1
Fix file delete & create order being different when fetching latest changes 2025-01-04 12:39:53 +00:00
cacd0243de
Remove extra hashing and only update changed files when syncing 2025-01-04 12:34:48 +00:00
c41ce7ef68
Don't return content in response if it's unchanged 2025-01-04 12:32:18 +00:00
3ae0e7b896
Add server healthcheck 2025-01-04 10:34:34 +00:00
b2ecb98ec6
Add sync_lib crate docs 2025-01-04 10:34:24 +00:00
70c4846c73
Try runnig self-hsoted docker build 2025-01-04 10:21:09 +00:00
319dabfbbc
Always render settings description 2025-01-03 23:30:22 +00:00
8ab64b170a
Improve history view UX 2025-01-03 23:26:55 +00:00
b9cf16be18
Test on firefox instead 2025-01-03 22:38:34 +00:00
183e8eee5b
Bump versions to 0.0.12 2025-01-03 22:30:56 +00:00
752efd7a27
Use new API 2025-01-03 22:30:46 +00:00
44cb7a5b7c
Fix up is binary & sync_lib docs & tests 2025-01-03 22:30:37 +00:00
825d398dec
Fix tests 2025-01-03 20:07:40 +00:00
6796d43430
Bump versions to 0.0.11 2025-01-03 20:05:44 +00:00
c1bc2def4f
Bump versions to 0.0.10 2025-01-03 18:26:17 +00:00
51d7306489
Format 2025-01-03 18:26:09 +00:00
f9390c98c5
Dev updates 2025-01-03 18:25:39 +00:00
20031b3c28
Fix syncing renamed files 2025-01-03 18:25:29 +00:00
b074202ed8
No need to merge if the contents are equal 2025-01-03 18:25:00 +00:00
727b60c672
Fix duplicated documents 2025-01-03 18:24:42 +00:00
41ffba8ec2
Only make last seen update go forwards 2025-01-03 18:22:37 +00:00
875592deda
Remove wee_alloc as it's unsuitable for this use case and OOMs 2025-01-03 18:22:06 +00:00
c0b824796f
Remove trying to fix CI 2025-01-03 17:47:20 +00:00
8965477e2f
Try fixing CI 2025-01-03 15:15:14 +00:00
861eda38cf
try 2025-01-03 15:12:49 +00:00
9b7d37dd8a
More emojis 2025-01-03 15:10:12 +00:00
db21f70612
Remove rustup 2025-01-03 15:10:03 +00:00
ed162b2ee9
Use self-hosted runner 2025-01-03 15:09:56 +00:00
aeae75b541
Typo 2025-01-03 14:45:32 +00:00
5f5bdf75ea
Refactor syncer and add internal, non-concurrency limited methods 2025-01-03 14:45:26 +00:00
c733448a02
Lint tests 2025-01-03 14:44:54 +00:00
2911b195f4
Don't create new doc if one already exists with the same content 2025-01-03 14:40:52 +00:00
88f65a20f0
Make sure that there're no silent failures 2025-01-03 14:40:26 +00:00
0f8ce08cf5
Fix rust up 2025-01-03 11:36:09 +00:00
eee1d8db1b
Fix error 2025-01-03 11:35:12 +00:00
d9c2c5b2a1
Add a few tests 2025-01-03 11:35:00 +00:00
5178cb6381
Smaller font 2025-01-03 11:16:58 +00:00
594884d054
Add rustup 2025-01-03 11:16:42 +00:00
62c41e6ecd
Refactor and remove extra sync step 2025-01-03 10:45:56 +00:00
fb8badae44
Try self-hosted github runner 2025-01-02 22:58:11 +00:00
9e4302354a
Bump versions to 0.0.9 2025-01-02 22:45:31 +00:00
cd5917c5f3
Push all 2025-01-02 22:45:22 +00:00
19cb616eb9
Fix mobile sync issue 2025-01-02 22:45:17 +00:00
5fc67c7b92
Bump versions to 0.0.8 2025-01-02 22:07:28 +00:00
4503aa1915
Fix path 2025-01-02 22:07:23 +00:00
c8b4d6c0ee
Lint 2025-01-02 22:06:23 +00:00
a628b1f8ce
Improve settings 2025-01-02 22:06:19 +00:00
5870636210
Don't retry pings 2025-01-02 20:56:46 +00:00
c3781a432e
Refactor for better API 2025-01-02 20:56:35 +00:00
c391aede1f
Refactor sync history 2025-01-02 20:46:11 +00:00
60b6d90b6c
Rename syncServer to syncService 2025-01-02 20:46:00 +00:00
ea1c73a3c9
Add empty state for status bar 2025-01-02 20:45:13 +00:00
e15b9e2498
Lint retried fetch 2025-01-02 20:44:04 +00:00
b53cc5beb4
Add retried fetch and connection check 2025-01-02 20:37:18 +00:00
b73c26ffd8
Add CI badges 2025-01-02 20:13:15 +00:00
e15fc63e79
Add open settings 2025-01-02 17:44:03 +00:00
4fd74e0d9f
Fix CI failing with rust warning 2025-01-02 17:43:37 +00:00
991def9a65
Fix for BRAT 2025-01-02 17:43:23 +00:00
013daf34af
Improve version bump script 2025-01-02 17:24:01 +00:00
22317bea37
Fix CI 2025-01-02 17:23:39 +00:00
00f9bcf638
Update readme 2025-01-02 15:49:37 +00:00
c738b96b62
Update metadata 2025-01-02 15:44:15 +00:00
19d868fe4d
Clean up version bumping 2025-01-02 15:44:06 +00:00
7778ed894f
Rename & clean up 2025-01-02 15:43:20 +00:00
08da52cce8
Lint plugin in CI 2025-01-02 15:43:07 +00:00
111f14cec1
Harmonise backend & frontend versions 2025-01-02 15:42:38 +00:00
b6d94bce0b
Use new Rust bindings 2025-01-02 11:58:28 +00:00
b2a8db14b6
Update files atomically in Obsidian 2025-01-02 11:58:06 +00:00
a2b1a83663
Lint plugin 2025-01-02 11:57:40 +00:00
4af8f3b23f
Add merge_text and rename JS bindings 2025-01-02 11:56:33 +00:00
c5c548c5d4
Bump and print server version 2025-01-02 11:37:40 +00:00
3560febc3f
Add description and default schema 2025-01-02 11:35:45 +00:00
6c2c363561
Refactor errors 2025-01-02 11:33:53 +00:00
dae8a9cc89
Display formatted HTTP errors 2025-01-02 11:24:22 +00:00
fb729b7d89
Pick up API changes 2025-01-02 10:58:47 +00:00
6d32e51c3e
Make noop updates hidable 2025-01-02 10:58:22 +00:00
55c07f3b82
Add better log view 2025-01-02 10:54:29 +00:00
fe66c0751d
Remove HTTP queuing 2025-01-02 10:53:04 +00:00
2983357946
Change concurrency setting 2025-01-02 10:52:41 +00:00
07d6a75c96
Add blocked count into status bar 2025-01-02 10:52:24 +00:00
0d2b0e6de0
Merge sync functions into class 2025-01-02 10:52:05 +00:00
cfdad5f608
Rename locks 2025-01-02 09:16:32 +00:00
9e9ee06f15
Update for brat 2024-12-20 20:50:11 +00:00
c47e76b434
0.0.7 2024-12-20 20:32:32 +00:00
90bc893007
Performance improvements 2024-12-20 20:32:14 +00:00
831e6f7651
Bump anifest for brat 2024-12-20 18:50:26 +00:00
74ef46c1f7
Fix lint 2024-12-20 18:47:45 +00:00
470 changed files with 41782 additions and 46961 deletions

16
.editorconfig Normal file
View file

@ -0,0 +1,16 @@
# https://editorconfig.org
root = true
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
indent_style = space
indent_size = 4
tab_width = 4
[*.{yml,yaml,md}]
indent_size = 2
tab_width = 2

View file

@ -0,0 +1,35 @@
name: Check
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-Dwarnings"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: "25.x"
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.92.0"
components: clippy, rustfmt
- name: Lint & test
run: scripts/check.sh

View file

@ -0,0 +1,38 @@
name: Deploy Documentation
on:
push:
branches:
- main
paths:
- "docs/**"
- ".forgejo/workflows/deploy-docs.yml"
workflow_dispatch:
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: "25.x"
- name: Build docs
run: scripts/build-docs.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: docs
path: docs/.vitepress/dist

View file

@ -0,0 +1,71 @@
name: E2E tests
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
schedule:
- cron: "0 * * * *"
workflow_dispatch:
concurrency:
group: e2e-tests
cancel-in-progress: false
env:
RUSTFLAGS: "-Dwarnings"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: "25.x"
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.92.0"
components: clippy, rustfmt
- name: Setup rust
run: |
which sqlx || cargo install sqlx-cli
cd sync-server
sqlx database create --database-url sqlite://db.sqlite3
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3
- name: E2E tests
run: |
cd sync-server
cargo run config-e2e.yml --color never &
SERVER_PID=$!
cd ..
scripts/e2e.sh 8
EXIT_CODE=$?
kill $SERVER_PID 2>/dev/null || true
wait $SERVER_PID 2>/dev/null || true
exit $EXIT_CODE
- name: Upload e2e logs
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-logs
path: logs/
retention-days: 30
- name: Cleanup
if: always()
run: scripts/clean-up.sh

View file

@ -0,0 +1,51 @@
name: Publish CLI
on:
push:
branches: ["main"]
tags: ["*"]
pull_request:
branches: ["main"]
jobs:
publish-docker:
runs-on: ubuntu-docker
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract registry hostname
id: registry
run: echo "host=$(echo '${{ github.server_url }}' | sed 's|https\?://||')" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log into container registry
uses: docker/login-action@v3
with:
registry: ${{ steps.registry.outputs.host }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ steps.registry.outputs.host }}/${{ github.repository }}-cli
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v5
with:
context: frontend
file: frontend/local-client-cli/Dockerfile
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}-cli:buildcache
cache-to: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}-cli:buildcache,mode=max

View file

@ -0,0 +1,71 @@
name: Publish Obsidian plugin
on:
push:
tags: ["*"]
env:
CARGO_TERM_COLOR: always
jobs:
publish-plugin:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: "25.x"
- name: Build plugin
run: |
cd frontend
npm ci
npm run build
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.92.0"
components: clippy, rustfmt
- name: Install cross-compilation tools
run: |
apt update
apt install -y gcc-aarch64-linux-gnu musl-tools gcc-mingw-w64-x86-64 jq
- name: Build Linux and Windows binaries
run: ./scripts/build-sync-server-binaries.sh
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
run: |
tag="${GITHUB_REF#refs/tags/}"
mkdir -p release
cp frontend/obsidian-plugin/dist/* release/
cp sync-server/artifacts/sync-server-* release/
# Create draft release via Forgejo API
RELEASE_ID=$(curl -s -X POST \
"${SERVER_URL}/api/v1/repos/${REPO}/releases" \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${tag}\", \"name\": \"${tag}\", \"draft\": true}" \
| jq -r '.id')
# Upload release assets
for file in release/*; do
filename=$(basename "$file")
curl -s -X POST \
"${SERVER_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}" \
-H "Authorization: token ${GITHUB_TOKEN}" \
-F "attachment=@${file}"
done

View file

@ -0,0 +1,51 @@
name: Publish server Docker image
on:
push:
branches: ["main"]
tags: ["*"]
pull_request:
branches: ["main"]
jobs:
publish-docker:
runs-on: ubuntu-docker
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract registry hostname
id: registry
run: echo "host=$(echo '${{ github.server_url }}' | sed 's|https\?://||')" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log into container registry
if: github.ref_type == 'tag'
uses: docker/login-action@v3
with:
registry: ${{ steps.registry.outputs.host }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ steps.registry.outputs.host }}/${{ github.repository }}
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v5
with:
context: sync-server
platforms: linux/amd64,linux/arm64
push: ${{ github.ref_type == 'tag' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}:buildcache
cache-to: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}:buildcache,mode=max

View file

@ -1,91 +0,0 @@
name: Docker
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
on:
push:
tags:
- "*"
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs.
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0
with:
cosign-release: "v2.2.4"
# Set up BuildKit Docker container builder to be able to build
# multi-platform images and export cache
# https://github.com/docker/setup-buildx-action
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
with:
context: backend
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
# transparency data even for private images, pass --force to cosign below.
# https://github.com/sigstore/cosign
- name: Sign the published Docker image
if: ${{ github.event_name != 'pull_request' }}
env:
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}

View file

@ -1,52 +0,0 @@
name: Release Obsidian plugin
# on:
# push:
# tags:
# - "*"
on:
push:
branches: ["master"]
pull_request:
branches: ["master"]
env:
CARGO_TERM_COLOR: always
jobs:
build-plugin:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: "18.x"
- name: Build wasm
run: |
cd backend
cargo install wasm-pack
wasm-pack build --target web sync_lib --features wee_alloc
- name: Build plugin
run: |
pwd
cd plugin
pwd
npm install
npm run build
- name: Create release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${GITHUB_REF#refs/tags/}"
cd plugin/build
gh release create "$tag" \
--title="$tag" \
--draft \
main.js manifest.json styles.css

View file

@ -1,39 +0,0 @@
name: Rust
on:
push:
branches: ["master"]
pull_request:
branches: ["master"]
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-Dwarnings"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup
run: |
rustup install nightly
rustup default nightly
rustup component add clippy rustfmt
cargo install sqlx-cli
cd backend
sqlx database create --database-url sqlite://db.sqlite3
sqlx migrate run --source sync_server/src/database/migrations --database-url sqlite://db.sqlite3
- name: Lint
run: |
cd backend
cargo clippy --all-targets --all-features
cargo fmt --all -- --check
- name: Test
run: |
cd backend
cargo test --verbose

23
.gitignore vendored
View file

@ -1,19 +1,24 @@
# vscode
.vscode
# npm # npm
node_modules node_modules
# Exclude macOS Finder (System Explorer) View States # Exclude macOS Finder (System Explorer) View States
.DS_Store .DS_Store
# Rust build folder # Frontend build folders
backend/target frontend/*/dist
# Obsidian plugin build folder # Rust build folders
plugin/build sync-server/target
sync-server/artifacts
sync-server/bindings/*.ts
backend/db.sqlite3* # build folders
backend/config.yaml sync-server/db.sqlite3*
**/databases
*.log *.log
*.sqlx
target
.task

10
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,10 @@
{
"jest.jestCommandLine": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" npx jest",
"jest.rootPath": "plugin",
"files.exclude": {
"**/dist": true,
"**/node_modules": true,
"**/.sqlx": true,
"**/target": true
}
}

155
CLAUDE.md Normal file
View file

@ -0,0 +1,155 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project shape
VaultLink is a self-hosted Obsidian file-sync system. Two halves of one repo:
- `sync-server/` — Rust (axum + sqlx/SQLite). Source of truth for vault state, broadcasts changes via WebSocket.
- `frontend/` — npm workspaces. The sync engine (`sync-client`) is consumed by an Obsidian plugin, a standalone CLI, a fuzz E2E harness, a scripted determinism harness, and a history UI.
The HTTP/WS API types are generated from Rust (`ts-rs`) and mirrored into the TS workspaces. **Never hand-edit files in `frontend/sync-client/src/services/types/` or `frontend/history-ui/src/lib/types/`** — run `scripts/update-api-types.sh` after changing anything Serde-derived in the server.
### Frontend workspaces
- `sync-client` — the sync engine; published to consumers via `dist/`. All other TS workspaces depend on it via `file:../sync-client`.
- `obsidian-plugin` — Obsidian plugin built from `sync-client`.
- `local-client-cli` — same engine wrapped as a standalone CLI.
- `history-ui` — vault-history web UI.
- `test-client` — fuzz E2E harness (random ops across N processes).
- `deterministic-tests` — scripted multi-client tests with an in-memory FS, run against a real server.
## Common commands
Pre-push hygiene (formats, lints, runs tests, requires clean git state):
```sh
scripts/check.sh --fix
```
Run the fuzz E2E (N parallel processes):
```sh
scripts/e2e.sh 12
# Logs land in logs/log_<i>.log. Clean with scripts/clean-up.sh
```
Run deterministic tests (require a release-built server in `sync-server/target/release/sync_server` — they spawn it themselves):
```sh
cd sync-server && cargo build --release && cd ..
cd frontend
npm run build -w sync-client -w deterministic-tests
node deterministic-tests/dist/cli.js # all
node deterministic-tests/dist/cli.js --filter=rename # subset
node deterministic-tests/dist/cli.js --filter=… -j 4 # cap parallelism
```
Run a single sync-client unit test by file:
```sh
cd frontend/sync-client && npx tsx --test 'src/**/sync-event-queue.test.ts'
```
Server: dev runs from `sync-server/` against `config-e2e.yml`:
```sh
cd sync-server
cargo run config-e2e.yml # dev
cargo build --release # used by both e2e harnesses
cargo test # unit + ts-rs binding export tests
```
Frontend dev (sync-client + obsidian-plugin watch in parallel):
```sh
cd frontend && npm install && npm run dev
```
Regenerate TS bindings from Rust types (touches `frontend/{sync-client,history-ui}/src/.../types/`):
```sh
scripts/update-api-types.sh
```
## SQLite / sqlx
The server uses `sqlx::query!` macros that need a prepared `.sqlx` cache to compile offline. Touching any SQL means regenerating it:
```sh
cd sync-server
sqlx database create --database-url sqlite://db.sqlite3
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3
cargo sqlx prepare --workspace
```
New migrations: `sqlx migrate add --source src/app_state/database/migrations <name>`.
## Sync engine architecture
Read `frontend/sync-client/src/sync-operations/` to follow the sync engine; the rest of `sync-client` is plumbing (filesystem ops, persistence, services, telemetry).
The engine is **two independent loops with separate invariants**:
- **Wire loop** (`syncer.ts`) — drains the single-consumer FIFO queue. HTTP and WS handlers update record fields (`remoteRelativePath`, `parentVersionId`, `remoteHash`) and write content to the file at `record.localPath`. They never move files for path placement.
- **Path reconciler** (`reconciler.ts`) — runs after every drained event. Best-effort pass that moves files to make `localPath === remoteRelativePath`. The move graph is topologically sorted; cycles are resolved by reading every file in the cycle into memory and writing each back to its new slot (no tmp files). Records with pending local events are skipped on each pass — the reconciler operates only on settled records. Failures (slot occupied by an untracked file, etc.) are silent skips; the next pass retries.
**`SyncEventQueue`** (`sync-event-queue.ts`) holds:
- `byDocId: Map<DocumentId, DocumentRecord>` — primary record store.
- `byLocalPath: Map<RelativePath, DocumentRecord>` — derived index for path lookups, maintained at every mutation point.
- `events: SyncEvent[]` — pending wire ops in FIFO drain order.
```ts
DocumentRecord = {
documentId,
parentVersionId,
remoteHash?,
remoteRelativePath,
localPath: RelativePath | undefined
}
```
`localPath === undefined` means the doc has no local file yet — typically a remote create whose target slot was occupied at receive time; the reconciler will fetch and place when the slot frees (the bytes wait in `pendingPlacementContent`).
Local FS events from the watcher update `localPath` synchronously at enqueue time via `setLocalPath` / `upsertRecord`. The wire loop never updates it for path placement; only the reconciler does. A user rename onto a tracked slot enqueues a `LocalDelete` for the displaced doc (the OS rename clobbered its content) and clears that doc's `localPath`.
**Pending creates** use a `Promise<DocumentId>` chain to serialize dependent ops (`LocalUpdate`, `LocalDelete`) behind the still-in-flight `LocalCreate`. `resolveCreate` resolves the promise once the server returns a docId, and `replacePendingDocumentId` swaps the resolved id across already-queued events. `findLatestCreateForPath` is the lookup the watcher uses to attach dependents; `updatePendingCreatePath` rewrites a pending create's `event.path` in place when the user renames the file before its create has acked.
**Watermark.** `lastSeenUpdateId` uses a `MinCovered` (a contiguous-prefix tracker over a stream of integers): we only advance the published min when the next consecutive id has been processed, so out-of-order RemoteChange ids don't fool the WebSocket handshake into requesting a too-recent catch-up.
**Server catch-up.** The server's WS handshake replays events newer than the client's `last_seen_vault_update_id` from the `latest_document_versions` view (one row per doc, the latest). On those replayed rows `is_new_file` means _new to this client_ (`creation_vault_update_id > last_seen_vault_update_id`), not "this row is the doc's first version" — necessary because the catch-up only carries the latest version; if a doc was created and updated past the watermark, the client never sees its create otherwise.
## Edge-case patterns the sync engine has to survive
The two-loop split defuses most of the old race catalogue (slot-collision stashes, conflict-uuid divergence, `MoveOnConflict.NEW`/`EXISTING` policy choices) by separating wire transport from path placement. What's left:
**Pending-create docId is a `Promise`, not a string, until the create acks.** Any `LocalUpdate` / `LocalDelete` queued behind a still-in-flight `LocalCreate` carries the create's `resolvers.promise` as its `documentId`. `replacePendingDocumentId` swaps the resolved id across queued events when the create resolves; `===` comparisons against the resolved string elsewhere will silently fail until that swap runs. Anything that walks `events[]` looking for a docId match must either run after the swap or be tolerant of `Promise`-typed ids.
**`processCreate` reads `event.path` live, not `event.originalPath`.** The watcher rewrites `event.path` in place via `updatePendingCreatePath` when the user renames a pending-create file. `originalPath` was removed from `LocalCreate` events specifically because reading it would send the stale pre-rename path to the server.
**`record.localPath` mutates in place across awaits.** When the watcher renames a doc while a drain handler is awaiting an HTTP roundtrip, the queue mutates the in-flight event's record so subsequent reads see the new path. Snapshotting `record.localPath` into a local at function entry and using it after an `await` reads/writes a now-vacated slot. Read `record.localPath` live; only snapshot for the deliberate "did it change while I was awaiting" comparison.
**Reconciler-defer is the wire-loop's contract with the reconciler.** The reconciler skips records where `hasPendingLocalEventsForDocumentId` returns true. Wire-loop handlers can therefore freely write `remoteRelativePath` to whatever the server returned — even if it disagrees with `localPath` — knowing the reconciler won't move the file out from under a queued user rename.
**Watermark advancement is load-bearing both ways.** Branches that _skip_ a remote event without advancing `lastSeenUpdateId` create permanent gaps that re-deliver forever. Branches that _advance_ without applying the content lose data: the server has no further event to re-deliver, the catch-up only carries the latest version, and any state in between is gone. Don't advance unless the event was actually applied (or deliberately discarded after weighing both halves).
**`isNewFile` semantics differ between catch-up and real-time.** On WS handshake replay it means _new to this client_ (`creation_vault_update_id > last_seen_vault_update_id`); on real-time broadcasts it means _this version is the create_ (`creation_vault_update_id == vault_update_id`). A handler that decides based on one interpretation will be wrong on the other channel; reasoning about fetch-and-treat-as-new vs. ignore needs to know which channel delivered the event.
**Pause / disable-sync mid-flight** is the one race the new model doesn't structurally fix. An HTTP that committed server-side but whose response was discarded leaves the server holding a doc the client has no record of. Resume → offline scan → server-side dedupe handles it (the server merges the duplicate create into the existing doc), but if the merge produces a deconflict, the client picks up an extra file. Out of scope for the two-loop split.
**Cycle reconciliation uses in-memory content swap.** When the move graph contains a cycle, the reconciler reads every file in the cycle into memory and writes each back to its new slot, with no tmp files. A write-ahead marker at `.vaultlink/swap-<uuid>.json` lists each leg; on startup the reconciler reads the marker, hashes each `from` to determine which legs ran, and replays the rest. The `.vaultlink/**` glob is hard-coded as an internal ignore pattern so swap markers don't get sync'd.
## Two complementary E2E harnesses
- **`test-client` (fuzz):** random ops across N parallel processes for many minutes. Used by `scripts/e2e.sh`. Catches bugs nobody thought to write a test for, but reproductions are noisy.
- **`deterministic-tests`:** scripted scenarios with an in-memory FS pinned to a real server. Used to _capture_ a fuzz-discovered bug as a minimal repro before fixing it. See `frontend/deterministic-tests/README.md` for the step grammar (`pause-server`, `pause-websocket`, `barrier`, `assert-consistent`, etc.).
When a fuzz failure surfaces, the workflow is: root-cause from logs → write a deterministic test that fails on the bug → fix → confirm both the deterministic test and `e2e.sh` pass.
## Style
- TS: 4-space indent, no tabs, LF, prettier (`trailingComma: "none"`). YAML/MD use 2-space indent.
- Rust: `rustfmt.toml` enforces 4-space spaces, LF.
- Lint: ESLint for TS, Clippy for Rust, `cargo machete` for unused deps. All wired into `scripts/check.sh`.

111
README.md
View file

@ -1,74 +1,79 @@
# VaultLink self-hosted Obsidian plugin for file syncing
[![Check](https://github.com/schmelczer/vault-link/actions/workflows/check.yml/badge.svg)](https://github.com/schmelczer/vault-link/actions/workflows/check.yml)
[![E2E tests](https://github.com/schmelczer/vault-link/actions/workflows/e2e.yml/badge.svg)](https://github.com/schmelczer/vault-link/actions/workflows/e2e.yml)
[![Publish server Docker image](https://github.com/schmelczer/vault-link/actions/workflows/publish-server-docker.yml/badge.svg)](https://github.com/schmelczer/vault-link/actions/workflows/publish-server-docker.yml)
[![Publish CLI](https://github.com/schmelczer/vault-link/actions/workflows/publish-cli-docker.yml/badge.svg)](https://github.com/schmelczer/vault-link/actions/workflows/publish-cli-docker.yml)
[![Publish Obsidian plugin](https://github.com/schmelczer/vault-link/actions/workflows/publish-plugin.yml/badge.svg)](https://github.com/schmelczer/vault-link/actions/workflows/publish-plugin.yml)
## Install [nvm](https://github.com/nvm-sh/nvm) ## Develop
### Set up Node.JS 25 with [nvm](https://github.com/nvm-sh/nvm)
- `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash` - `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash`
- `nvm install 20` - `nvm install 25`
- `nvm use 20` - `nvm use 25`
- Optionally set the system-wide default: `nvm alias default 20` - Optionally, set the system-wide default: `nvm alias default 25`
### Set up Rust
## Set up Rust
- Install [`rustup`](https://rustup.rs): `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` - Install [`rustup`](https://rustup.rs): `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`
- `sudo apt install llvm -y`
- `rustup self update`
- `rustup update`
- `rustup install nightly`
- `rustup default nightly`
- `rustup component add llvm-tools-preview`
- `cargo install cargo-generate cargo-fuzz cargo-insta rustfilt cargo-binutils`
- Install [`wasm-pack`](https://rustwasm.github.io/wasm-pack/installer): `curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh` - Install [`wasm-pack`](https://rustwasm.github.io/wasm-pack/installer): `curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh`
- `cargo install cargo-insta` - `cargo install cargo-insta sqlx-cli`
- `cargo install sqlx-cli`
### Install Obsidian on Linux
## cut new version
```sh ```sh
cd plugin apt install flatpak
npm version patch flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
git tag -a 0.0.2 -m "0.0.2" flatpak install flathub md.obsidian.Obsidian
git push origin 0.0.2 flatpak run md.obsidian.Obsidian
``` ```
#### Run in development mode
Start the server:
```sh
cargo install sqlx-cli
cd sync-server
cargo run config-e2e.yml
```
## Todos ```sh
cd frontend
npm install
npm run dev
```
- Add users to vaults ### Scripts
- Websocket for db updates
- async read body
- e2e tests
- add clap
- add auth middleware
- run eslint in ci
- CI for: #### Before pushing
- publish reconcile
- cross-platform build server
- run load test on server
- build and publish plugin with openapi types
- build docker image
todo: enable ```sh
[workspace.lints.clippy] scripts/check.sh --fix
single_call_fn = { level = "allow", priority = 1 } ```
absolute_paths = { level = "allow", priority = 1 }
arithmetic_side_effects = { level = "allow", priority = 1 }
similar_names = { level = "allow", priority = 1 }
self_named_module_files = { level = "allow", priority = 1 }
single_char_lifetime_names = { level = "allow", priority = 1 }
missing_docs_in_private_items = { level = "allow", priority = 1 }
question_mark_used = { level = "allow", priority = 1 }
implicit_return = { level = "allow", priority = 1 }
pedantic = { level = "warn", priority = 0 }
cargo = { level = "warn", priority = 0 }
#### Update HTTP API TS bindings
update card title max width ```sh
reset should reset counters scripts/update-api-types.sh
access logs ```
retry
mem usage #### Publish new version
```sh
scripts/bump-version.sh patch
```
#### Run E2E tests
```sh
scripts/e2e.sh 8
```
And to clean up the logs & database files, run `scripts/clean-up.sh`
## Projects
- [Sync server](./sync-server/README.md)

View file

@ -1,54 +0,0 @@
[workspace]
resolver = "2"
members = [
"reconcile",
"fuzz",
"sync_server",
"sync_lib"
]
[workspace.package]
rust-version = "1.83"
[workspace.dependencies]
serde = { version = "1.0.214", default-features = false, features = ["derive"] }
thiserror = { version = "1.0.66", default-features = false }
[profile.release]
codegen-units = 1
lto = true
opt-level = 3
[workspace.lints.rust]
unsafe_code = "forbid"
rust_2018_idioms = { level = "warn", priority = -1 }
missing_debug_implementations = "warn"
[workspace.lints.clippy]
await_holding_lock = "warn"
dbg_macro = "warn"
empty_enum = "warn"
enum_glob_use = "warn"
exit = "warn"
filter_map_next = "warn"
fn_params_excessive_bools = "warn"
if_let_mutex = "warn"
imprecise_flops = "warn"
inefficient_to_string = "warn"
linkedlist = "warn"
lossy_float_literal = "warn"
macro_use_imports = "warn"
match_on_vec_items = "warn"
match_wildcard_for_single_variants = "warn"
mem_forget = "warn"
needless_borrow = "warn"
needless_continue = "warn"
option_option = "warn"
rest_pat_in_fully_bound_structs = "warn"
str_to_string = "warn"
suboptimal_flops = "warn"
todo = "warn"
uninlined_format_args = "warn"
unnested_or_patterns = "warn"
unused_self = "warn"
verbose_file_reads = "warn"

View file

@ -1,23 +0,0 @@
FROM rust:1.83 AS builder
WORKDIR /usr/src/backend
RUN apt update && apt install -y musl-tools
RUN rustup install nightly && rustup default nightly
RUN rustup target add x86_64-unknown-linux-musl
RUN cargo install sqlx-cli
COPY . .
RUN sqlx database create --database-url sqlite://db.sqlite3
RUN sqlx migrate run --source sync_server/src/database/migrations --database-url sqlite://db.sqlite3
RUN cargo build --package sync_server --release --target x86_64-unknown-linux-musl
FROM alpine:3.21.0
COPY --from=builder /usr/src/backend/target/x86_64-unknown-linux-musl/release/sync_server /app/sync_server
WORKDIR /data
ENTRYPOINT ["/app/sync_server"]

View file

@ -1,4 +0,0 @@
target
corpus
artifacts
coverage

View file

@ -1,22 +0,0 @@
[package]
name = "reconcile-fuzz"
version = "0.0.0"
publish = false
edition = "2021"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
reconcile = { path = "../reconcile" }
[[bin]]
name = "reconcile"
path = "fuzz_targets/reconcile.rs"
test = false
doc = false
bench = false
[lints]
workspace = true

View file

@ -1,8 +0,0 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|texts: (String, String, String)| {
let (original, left, right) = texts;
let _ = reconcile::reconcile(&original, &left, &right);
});

View file

@ -1,18 +0,0 @@
[package]
name = "reconcile"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1.0.215", optional = true }
[features]
serde = [ "dep:serde" ]
[dev-dependencies]
insta = "1.41.1"
pretty_assertions = "1.4.1"
test-case = "3.3.1"
[lints]
workspace = true

View file

@ -1,2 +0,0 @@
pub mod myers;
pub mod raw_operation;

View file

@ -1,291 +0,0 @@
//! Taken from <https://github.com/mitsuhiko/similar/blob/7e15c44de11a1cd61e1149189929e189ef977fd8/src/algorithms/myers.rs>
//! Myers' diff algorithm.
//!
//! * time: `O((N+M)D)`
//! * space `O(N+M)`
//!
//! See [the original article by Eugene W. Myers](http://www.xmailserver.org/diff2.pdf)
//! describing it.
//!
//! The implementation of this algorithm is based on the implementation by
//! Brandon Williams.
//!
//! # Heuristics
//!
//! At present this implementation of Myers' does not implement any more
//! advanced heuristics that would solve some pathological cases. For instance
//! passing two large and completely distinct sequences to the algorithm will
//! make it spin without making reasonable progress.
//! For potential improvements here see [similar#15](https://github.com/mitsuhiko/similar/issues/15).
use std::{
ops::{Index, IndexMut, Range},
vec,
};
use super::raw_operation::RawOperation;
use crate::{
tokenizer::token::Token,
utils::{common_prefix_len::common_prefix_len, common_suffix_len::common_suffix_len},
};
/// Myers' diff algorithm with deadline.
///
/// Diff `old`, between indices `old_range` and `new` between indices
/// `new_range`.
///
/// This diff is done with an optional deadline that defines the maximal
/// execution time permitted before it bails and falls back to an approximation.
pub fn diff<T>(old: &[Token<T>], new: &[Token<T>]) -> Vec<RawOperation<T>>
where
T: PartialEq + Clone,
{
let max_d = max_d(old.len(), new.len());
let mut vb = V::new(max_d);
let mut vf = V::new(max_d);
let mut result: Vec<RawOperation<T>> = vec![];
conquer(
old,
0..old.len(),
new,
0..new.len(),
&mut vf,
&mut vb,
&mut result,
);
result
}
// A D-path is a path which starts at (0,0) that has exactly D non-diagonal
// edges. All D-paths consist of a (D - 1)-path followed by a non-diagonal edge
// and then a possibly empty sequence of diagonal edges called a snake.
/// `V` contains the endpoints of the furthest reaching `D-paths`. For each
/// recorded endpoint `(x,y)` in diagonal `k`, we only need to retain `x`
/// because `y` can be computed from `x - k`. In other words, `V` is an array of
/// integers where `V[k]` contains the row index of the endpoint of the furthest
/// reaching path in diagonal `k`.
///
/// We can't use a traditional Vec to represent `V` since we use `k` as an index
/// and it can take on negative values. So instead `V` is represented as a
/// light-weight wrapper around a Vec plus an `offset` which is the maximum
/// value `k` can take on in order to map negative `k`'s back to a value >= 0.
#[derive(Debug)]
struct V {
offset: isize,
v: Vec<usize>, // Look into initializing this to -1 and storing isize
}
impl V {
fn new(max_d: usize) -> Self {
Self {
offset: max_d as isize,
v: vec![0; 2 * max_d],
}
}
fn len(&self) -> usize { self.v.len() }
}
impl Index<isize> for V {
type Output = usize;
fn index(&self, index: isize) -> &Self::Output { &self.v[(index + self.offset) as usize] }
}
impl IndexMut<isize> for V {
fn index_mut(&mut self, index: isize) -> &mut Self::Output {
&mut self.v[(index + self.offset) as usize]
}
}
fn max_d(len1: usize, len2: usize) -> usize {
// XXX look into reducing the need to have the additional '+ 1'
(len1 + len2 + 1) / 2 + 1
}
#[inline(always)]
fn split_at(range: Range<usize>, at: usize) -> (Range<usize>, Range<usize>) {
(range.start..at, at..range.end)
}
/// A `Snake` is a sequence of diagonal edges in the edit graph. Normally
/// a snake has a start end end point (and it is possible for a snake to have
/// a length of zero, meaning the start and end points are the same) however
/// we do not need the end point which is why it's not implemented here.
///
/// The divide part of a divide-and-conquer strategy. A D-path has D+1 snakes
/// some of which may be empty. The divide step requires finding the ceil(D/2) +
/// 1 or middle snake of an optimal D-path. The idea for doing so is to
/// simultaneously run the basic algorithm in both the forward and reverse
/// directions until furthest reaching forward and reverse paths starting at
/// opposing corners 'overlap'.
fn find_middle_snake<T>(
old: &[Token<T>],
old_range: Range<usize>,
new: &[Token<T>],
new_range: Range<usize>,
vf: &mut V,
vb: &mut V,
) -> Option<(usize, usize)>
where
T: PartialEq + Clone,
{
let n = old_range.len();
let m = new_range.len();
// By Lemma 1 in the paper, the optimal edit script length is odd or even as
// `delta` is odd or even.
let delta = n as isize - m as isize;
let odd = delta & 1 == 1;
// The initial point at (0, -1)
vf[1] = 0;
// The initial point at (N, M+1)
vb[1] = 0;
// We only need to explore ceil(D/2) + 1
let d_max = max_d(n, m);
assert!(vf.len() >= d_max);
assert!(vb.len() >= d_max);
for d in 0..d_max as isize {
// Forward path
for k in (-d..=d).rev().step_by(2) {
let mut x = if k == -d || (k != d && vf[k - 1] < vf[k + 1]) {
vf[k + 1]
} else {
vf[k - 1] + 1
};
let y = (x as isize - k) as usize;
// The coordinate of the start of a snake
let (x0, y0) = (x, y);
// While these sequences are identical, keep moving through the
// graph with no cost
if x < old_range.len() && y < new_range.len() {
let advance = common_prefix_len(
old,
old_range.start + x..old_range.end,
new,
new_range.start + y..new_range.end,
);
x += advance;
}
// This is the new best x value
vf[k] = x;
// Only check for connections from the forward search when N - M is
// odd and when there is a reciprocal k line coming from the other
// direction.
if odd && (k - delta).abs() <= (d - 1) {
// TODO optimize this so we don't have to compare against n
if vf[k] + vb[-(k - delta)] >= n {
// Return the snake
return Some((x0 + old_range.start, y0 + new_range.start));
}
}
}
// Backward path
for k in (-d..=d).rev().step_by(2) {
let mut x = if k == -d || (k != d && vb[k - 1] < vb[k + 1]) {
vb[k + 1]
} else {
vb[k - 1] + 1
};
let mut y = (x as isize - k) as usize;
// The coordinate of the start of a snake
if x < n && y < m {
let advance = common_suffix_len(
old,
old_range.start..old_range.start + n - x,
new,
new_range.start..new_range.start + m - y,
);
x += advance;
y += advance;
}
// This is the new best x value
vb[k] = x;
if !odd && (k - delta).abs() <= d {
// TODO optimize this so we don't have to compare against n
if vb[k] + vf[-(k - delta)] >= n {
// Return the snake
return Some((n - x + old_range.start, m - y + new_range.start));
}
}
}
// TODO: Maybe there's an opportunity to optimize and bail early?
}
None
}
fn conquer<T>(
old: &[Token<T>],
mut old_range: Range<usize>,
new: &[Token<T>],
mut new_range: Range<usize>,
vf: &mut V,
vb: &mut V,
result: &mut Vec<RawOperation<T>>,
) where
T: PartialEq + Clone,
{
// Check for common prefix
let common_prefix_len = common_prefix_len(old, old_range.clone(), new, new_range.clone());
if common_prefix_len > 0 {
result.push(RawOperation::Equal(
old[old_range.start..old_range.start + common_prefix_len].to_vec(),
));
}
old_range.start += common_prefix_len;
new_range.start += common_prefix_len;
// Check for common suffix
let common_suffix_len = common_suffix_len(old, old_range.clone(), new, new_range.clone());
let common_suffix = (
old_range.end - common_suffix_len,
new_range.end - common_suffix_len,
);
old_range.end -= common_suffix_len;
new_range.end -= common_suffix_len;
if old_range.is_empty() && new_range.is_empty() {
// Do nothing
} else if new_range.is_empty() {
result.push(RawOperation::Delete(
old[old_range.start..old_range.start + old_range.len()].to_vec(),
));
} else if old_range.is_empty() {
result.push(RawOperation::Insert(
new[new_range.start..new_range.start + new_range.len()].to_vec(),
));
} else if let Some((x_start, y_start)) =
find_middle_snake(old, old_range.clone(), new, new_range.clone(), vf, vb)
{
let (old_a, old_b) = split_at(old_range, x_start);
let (new_a, new_b) = split_at(new_range, y_start);
conquer(old, old_a, new, new_a, vf, vb, result);
conquer(old, old_b, new, new_b, vf, vb, result);
} else {
result.push(RawOperation::Delete(
old[old_range.start..old_range.end].to_vec(),
));
result.push(RawOperation::Insert(
new[new_range.start..new_range.end].to_vec(),
));
}
if common_suffix_len > 0 {
result.push(RawOperation::Equal(
old[common_suffix.0..common_suffix.0 + common_suffix_len].to_vec(),
));
}
}

View file

@ -1,48 +0,0 @@
use crate::tokenizer::token::Token;
#[derive(Debug, Clone, PartialEq)]
pub enum RawOperation<T>
where
T: PartialEq + Clone,
{
Insert(Vec<Token<T>>),
Delete(Vec<Token<T>>),
Equal(Vec<Token<T>>),
}
impl<T> RawOperation<T>
where
T: PartialEq + Clone,
{
pub fn tokens(&self) -> &Vec<Token<T>> {
match self {
RawOperation::Insert(tokens) => tokens,
RawOperation::Delete(tokens) => tokens,
RawOperation::Equal(tokens) => tokens,
}
}
pub fn original_text_length(&self) -> usize {
self.tokens().iter().map(Token::get_original_length).sum()
}
pub fn get_original_text(self) -> String { self.tokens().iter().map(Token::original).collect() }
/// Extends the operation with another operation if returning the new
/// operation. Only operations of the same type can be used to extend.
/// If the operations are of different types, returns None.
pub fn extend(self, other: RawOperation<T>) -> Option<RawOperation<T>> {
match (self, other) {
(RawOperation::Insert(tokens1), RawOperation::Insert(tokens2)) => Some(
RawOperation::Insert(tokens1.into_iter().chain(tokens2).collect()),
),
(RawOperation::Delete(tokens1), RawOperation::Delete(tokens2)) => Some(
RawOperation::Delete(tokens1.into_iter().chain(tokens2).collect()),
),
(RawOperation::Equal(tokens1), RawOperation::Equal(tokens2)) => Some(
RawOperation::Equal(tokens1.into_iter().chain(tokens2).collect()),
),
_ => None,
}
}
}

View file

@ -1,7 +0,0 @@
mod diffs;
mod operation_transformation;
mod tokenizer;
mod utils;
pub use operation_transformation::{reconcile, reconcile_with_tokenizer, EditedText};
pub use tokenizer::token::Token;

View file

@ -1,200 +0,0 @@
mod edited_text;
mod merge_context;
mod operation;
pub use edited_text::EditedText;
pub use operation::Operation;
use crate::tokenizer::Tokenizer;
#[must_use]
pub fn reconcile(original: &str, left: &str, right: &str) -> String {
if left == right {
return left.to_owned();
}
if original == left {
return right.to_owned();
}
if original == right {
return left.to_owned();
}
let left_operations = EditedText::from_strings(original, left);
let right_operations = EditedText::from_strings(original, right);
let merged_operations = left_operations.merge(right_operations);
merged_operations.apply()
}
pub fn reconcile_with_tokenizer<F, T>(
original: &str,
left: &str,
right: &str,
tokenizer: &Tokenizer<T>,
) -> String
where
T: PartialEq + Clone,
{
let left_operations = EditedText::from_strings_with_tokenizer(original, left, tokenizer);
let right_operations = EditedText::from_strings_with_tokenizer(original, right, tokenizer);
let merged_operations = left_operations.merge(right_operations);
merged_operations.apply()
}
#[cfg(test)]
mod test {
use std::{fs, ops::Range, path::Path};
use pretty_assertions::assert_eq;
use test_case::test_matrix;
use super::*;
#[test]
fn test_merges() {
// Both replaced one token but different
test_merge_both_ways(
"original_1 original_2 original_3",
"original_1 edit_1 original_3",
"original_1 original_2 edit_2",
"original_1 edit_1 edit_2",
);
// Both replaced the same one token
test_merge_both_ways(
"original_1 original_2 original_3",
"original_1 edit_1 original_3",
"original_1 edit_1 original_3",
"original_1 edit_1 original_3",
);
// One deleted a large range, the other deleted subranges and inserted as well
test_merge_both_ways(
"original_1 original_2 original_3 original_4 original_5",
"original_1 original_5",
"original_1 edit_1 original_3 edit_2 original_5",
"original_1 edit_1 edit_2 original_5",
);
// One deleted a large range, the other inserted and deleted a partially
// overlapping range
test_merge_both_ways(
"original_1 original_2 original_3 original_4 original_5",
"original_1 original_5",
"original_1 edit_1 original_3 edit_2",
"original_1 edit_1 edit_2",
);
// Merge a replace and an append
test_merge_both_ways("a b ", "c d ", "a b c d ", "c d c d ");
test_merge_both_ways("a b c d e", "a e", "a c e", "a e");
test_merge_both_ways("a 0 1 2 b", "a b", "a E 1 F b", "a E F b");
test_merge_both_ways(
"a this one delete b",
"a b",
"a my one change b",
"a my change b",
);
test_merge_both_ways(
"this stays, this is one big delete, don't touch this",
"this stays, don't touch this",
"this stays, my one change, don't touch this",
"this stays, my change, don't touch this",
);
test_merge_both_ways("1 2 3 4 5 6", "1 6", "1 2 4 ", "1 ");
test_merge_both_ways(
"hello world",
"hi, world",
"hello my friend!",
"hi, my friend!",
);
// test_merge_both_ways("hello world", "world !", "hi hello world", "hi world
// !");
test_merge_both_ways(
"both delete the same word",
"both the same word",
"both the same word",
"both the same word",
);
test_merge_both_ways(" ", "its utf-8!", " ", "its utf-8!");
test_merge_both_ways(
"both delete the same word but one a bit more",
"both the same word",
"both same word",
"both same word",
);
test_merge_both_ways(
"long text with one big delete and many small",
"long small",
"long with big and small",
"long small",
);
}
#[ignore = "it's too slow"]
#[test_matrix( [
"pride_and_prejudice.txt",
"romeo_and_juliet.txt",
"room_with_a_view.txt",
"kun_lu.txt",
], [
"pride_and_prejudice.txt",
"romeo_and_juliet.txt",
"room_with_a_view.txt",
"kun_lu.txt"
], [
"pride_and_prejudice.txt",
"romeo_and_juliet.txt",
"room_with_a_view.txt",
"kun_lu.txt"
], [0..10000, 10000..20000], [0..10000, 10000..20000], [0..10000, 10000..20000])]
fn test_merge_files_without_panic(
file_name_1: &str,
file_name_2: &str,
file_name_3: &str,
range_1: Range<usize>,
range_2: Range<usize>,
range_3: Range<usize>,
) {
let files = [file_name_1, file_name_2, file_name_3];
let permutations = [range_1, range_2, range_3];
let root = Path::new("test/resources/");
let contents = files
.iter()
.zip(permutations.iter())
.map(|(file, range)| {
let path = root.join(file);
fs::read_to_string(&path)
.unwrap()
.chars()
.skip(range.start)
.take(range.end)
.collect::<String>()
})
.collect::<Vec<_>>();
let _ = reconcile(&contents[0], &contents[1], &contents[2]);
}
fn test_merge_both_ways(original: &str, edit_1: &str, edit_2: &str, expected: &str) {
assert_eq!(reconcile(original, edit_1, edit_2), expected);
assert_eq!(reconcile(original, edit_2, edit_1), expected);
}
}

View file

@ -1,299 +0,0 @@
use core::iter;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use super::Operation;
use crate::{
diffs::{myers::diff, raw_operation::RawOperation},
operation_transformation::merge_context::MergeContext,
tokenizer::{word_tokenizer::word_tokenizer, Tokenizer},
utils::{
merge_iters::MergeSorted as _, ordered_operation::OrderedOperation, side::Side,
string_builder::StringBuilder,
},
};
/// A sequence of operations that can be applied to a text document.
/// `EditedText` supports merging two sequences of operations using the
/// principle of Operational Transformation.
///
/// It's mainly created through the `from_strings` method, then merged with
/// another `EditedText` derived from the same original text and then applied to
/// the original text to get the reconciled text of concurrent edits.
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct EditedText<'a, T>
where
T: PartialEq + Clone,
{
text: &'a str,
operations: Vec<OrderedOperation<T>>,
}
impl<'a> EditedText<'a, String> {
/// Create an `EditedText` from the given original (old) and updated (new)
/// strings. The returned `EditedText` represents the changes from the
/// original to the updated text. When the return value is applied to
/// the original text, it will result in the updated text. The default
/// word tokenizer is used to tokenize the text which splits the text on
/// whitespaces.
#[must_use]
pub fn from_strings(original: &'a str, updated: &str) -> Self {
Self::from_strings_with_tokenizer(original, updated, &word_tokenizer)
}
}
impl<'a, T> EditedText<'a, T>
where
T: PartialEq + Clone,
{
/// Create an `EditedText` from the given original (old) and updated (new)
/// strings. The returned `EditedText` represents the changes from the
/// original to the updated text. When the return value is applied to
/// the original text, it will result in the updated text. The tokenizer
/// function is used to tokenize the text.
pub fn from_strings_with_tokenizer(
original: &'a str,
updated: &str,
tokenizer: &Tokenizer<T>,
) -> Self {
let original_tokens = (tokenizer)(original);
let updated_tokens = (tokenizer)(updated);
let diff: Vec<RawOperation<T>> = diff(&original_tokens, &updated_tokens);
Self::new(
original,
// Self::cook_operations(diff),
Self::cook_operations(Self::elongate_operations(diff)).collect(),
)
}
// Turn raw operations into ordered operations while keeping track of old & new
// indexes.
fn cook_operations<I>(raw_operations: I) -> impl Iterator<Item = OrderedOperation<T>>
where
I: IntoIterator<Item = RawOperation<T>>,
{
let mut new_index = 0; // this is the start index of the operation on the new text
let mut order = 0; // this is the start index of the operation on the original text
raw_operations.into_iter().flat_map(move |raw_operation| {
let length = raw_operation.original_text_length();
let operation = match raw_operation {
RawOperation::Equal(..) => {
new_index += length;
order += length;
None
}
RawOperation::Insert(tokens) => {
let op = Operation::create_insert(new_index, tokens)
.map(|operation| OrderedOperation { order, operation });
new_index += length;
op
}
RawOperation::Delete(..) => {
let op = if cfg!(debug_assertions) {
Operation::create_delete_with_text(
new_index,
raw_operation.get_original_text(),
)
} else {
Operation::create_delete(new_index, length)
}
.map(|operation| OrderedOperation { order, operation });
order += length;
op
}
};
operation.into_iter()
})
}
fn elongate_operations<I>(raw_operations: I) -> Vec<RawOperation<T>>
where
I: IntoIterator<Item = RawOperation<T>>,
{
let mut maybe_previous_insert: Option<RawOperation<T>> = None;
let mut maybe_previous_delete: Option<RawOperation<T>> = None;
let mut result: Vec<RawOperation<T>> = raw_operations
.into_iter()
.flat_map(|next| match next {
RawOperation::Insert(..) => {
if let Some(prev) = maybe_previous_insert.take() {
maybe_previous_insert = prev.extend(next);
} else {
maybe_previous_insert = Some(next);
}
Box::new(iter::empty()) as Box<dyn Iterator<Item = RawOperation<T>>>
}
RawOperation::Delete(..) => {
if let Some(prev) = maybe_previous_delete.take() {
maybe_previous_delete = prev.extend(next);
} else {
maybe_previous_delete = Some(next);
}
Box::new(iter::empty()) as Box<dyn Iterator<Item = RawOperation<T>>>
}
RawOperation::Equal(..) => Box::new(
maybe_previous_insert
.take()
.into_iter()
.chain(maybe_previous_delete.take())
.chain(iter::once(next)),
)
as Box<dyn Iterator<Item = RawOperation<T>>>,
})
.collect();
if let Some(prev) = maybe_previous_insert {
result.push(prev);
}
if let Some(prev) = maybe_previous_delete {
result.push(prev);
}
result
}
/// Create a new `EditedText` with the given operations.
/// The operations must be in the order in which they are meant to be
/// applied. The operations must not overlap.
fn new(text: &'a str, operations: Vec<OrderedOperation<T>>) -> Self {
operations
.iter()
.zip(operations.iter().skip(1))
.for_each(|(previous, next)| {
debug_assert!(
previous.operation.start_index() <= next.operation.start_index(),
"{} must not come before {} yet it does",
previous.operation,
next.operation
);
});
Self { text, operations }
}
#[must_use]
pub fn merge(self, other: Self) -> Self {
debug_assert_eq!(
self.text, other.text,
"EditedTexts must be derived from the same text to be mergable"
);
let mut left_merge_context = MergeContext::default();
let mut right_merge_context = MergeContext::default();
Self::new(
self.text,
self.operations
.into_iter()
.map(|op| (op, Side::Left))
.merge_sorted_by_key(
other.operations.into_iter().map(|op| (op, Side::Right)),
|(operation, _)| {
(
operation.order,
// Operations on left and right must come in the same order so that
// inserts can be merged with other inserts and deletes with deletes.
usize::from(matches!(operation.operation, Operation::Delete { .. })),
)
},
)
.flat_map(|(OrderedOperation { order, operation }, side)| {
match side {
Side::Left => operation.merge_operations_with_context(
&mut right_merge_context,
&mut left_merge_context,
),
Side::Right => operation.merge_operations_with_context(
&mut left_merge_context,
&mut right_merge_context,
),
}
.map(|operation| OrderedOperation { order, operation })
.into_iter()
})
.collect(),
)
}
/// Apply the operations to the text and return the resulting text.
///
/// # Errors
///
/// Returns an `SyncLibError::OperationError` if the operations cannot be
/// applied to the text.
#[must_use]
pub fn apply(&self) -> String {
let mut builder: StringBuilder<'_> = StringBuilder::new(self.text);
for OrderedOperation { operation, .. } in &self.operations {
builder = operation.apply(builder);
}
builder.build()
}
}
#[cfg(test)]
mod tests {
use std::env;
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_calculate_operations() {
let left = "hello world! How are you? Adam";
let right = "Hello, my friend! How are you doing? Albert";
let operations = EditedText::from_strings(left, right);
insta::assert_debug_snapshot!(operations);
let new_right = operations.apply();
assert_eq!(new_right.to_string(), right);
}
#[test]
fn test_calculate_operations_with_no_diff() {
let text = "hello world!";
let operations = EditedText::from_strings(text, text);
assert_eq!(operations.operations.len(), 0);
let new_right = operations.apply();
assert_eq!(new_right.to_string(), text);
}
#[test]
fn test_calculate_operations_with_insert() {
let original = "hello world! ...";
let left = "Hello world! I'm Andras.";
let right = "Hello world! How are you?";
let expected = "Hello world! I'm Andras.How are you?";
let operations_1 = EditedText::from_strings(original, left);
let operations_2 = EditedText::from_strings(original, right);
let operations = operations_1.merge(operations_2);
assert_eq!(operations.apply(), expected);
}
}

View file

@ -1,91 +0,0 @@
use core::fmt::Debug;
use crate::operation_transformation::Operation;
#[derive(Clone)]
pub struct MergeContext<T>
where
T: PartialEq + Clone,
{
last_operation: Option<Operation<T>>,
pub shift: i64,
}
impl<T> Default for MergeContext<T>
where
T: PartialEq + Clone,
{
fn default() -> Self {
MergeContext {
last_operation: None,
shift: 0,
}
}
}
impl<T> Debug for MergeContext<T>
where
T: PartialEq + Clone,
{
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("MergeContext")
.field("last_operation", &self.last_operation)
.field("shift", &self.shift)
.finish()
}
}
impl<T> MergeContext<T>
where
T: PartialEq + Clone,
{
pub fn last_operation(&self) -> Option<&Operation<T>> { self.last_operation.as_ref() }
/// Replace the last delete operation (if there was one) with a new one
/// while applying it to the shift.
pub fn consume_and_replace_last_operation(&mut self, operation: Option<Operation<T>>) {
if let Some(Operation::Delete {
deleted_character_count,
..
}) = self.last_operation.take()
{
self.shift -= deleted_character_count as i64;
}
self.last_operation = operation;
}
pub fn replace_last_operation(&mut self, operation: Option<Operation<T>>) {
self.last_operation = operation;
}
/// Remove the last operation (if there was one) in case it is behind the
/// threshold operation. This changes the shift in case the last operation
/// was a delete.
pub fn consume_last_operation_if_it_is_too_behind(
&mut self,
threshold_operation: &Operation<T>,
) {
if let Some(last_operation) = self.last_operation.as_ref() {
if let Operation::Delete {
deleted_character_count,
..
} = last_operation
{
if threshold_operation.start_index() as i64 + self.shift
> last_operation.end_index() as i64
{
self.shift -= *deleted_character_count as i64;
self.last_operation = None;
}
} else if let Operation::Insert { .. } = last_operation {
if threshold_operation.start_index() as i64 + self.shift
- last_operation.len() as i64
> last_operation.end_index() as i64
{
self.last_operation = None;
}
}
}
}
}

View file

@ -1,378 +0,0 @@
use core::{
fmt::{Debug, Display},
ops::Range,
};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use super::merge_context::MergeContext;
use crate::{
utils::{find_common_overlap::find_common_overlap, string_builder::StringBuilder},
Token,
};
/// Represents a change that can be applied to a text document.
/// Operation is tied to a `ropey::Rope` and is mainly expected to be
/// created by `EditedText`.
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Clone, PartialEq)]
pub enum Operation<T>
where
T: PartialEq + Clone,
{
Insert {
index: usize,
text: Vec<Token<T>>,
},
Delete {
index: usize,
deleted_character_count: usize,
#[cfg(debug_assertions)]
deleted_text: Option<String>,
},
}
impl<T> Operation<T>
where
T: PartialEq + Clone,
{
/// Creates an insert operation with the given index and text.
/// If the text is empty (meaning that the operation would be a no-op),
/// returns None.
pub fn create_insert(index: usize, text: Vec<Token<T>>) -> Option<Self> {
if text.is_empty() {
return None;
}
Some(Operation::Insert { index, text })
}
/// Creates a delete operation with the given index and number of
/// to-be-deleted characters. If the operation would delete 0 (meaning
/// that the operation would be a no-op), returns None.
pub fn create_delete(index: usize, deleted_character_count: usize) -> Option<Self> {
if deleted_character_count == 0 {
return None;
}
Some(Operation::Delete {
index,
deleted_character_count,
#[cfg(debug_assertions)]
deleted_text: None,
})
}
pub fn create_delete_with_text(index: usize, text: String) -> Option<Self> {
if text.is_empty() {
return None;
}
Some(Operation::Delete {
index,
deleted_character_count: text.chars().count(),
#[cfg(debug_assertions)]
deleted_text: Some(text),
})
}
/// Tries to apply the operation to the given `ropey::Rope` text, returning
/// the modified text.
///
/// # Errors
///
/// Returns a `SyncLibError::OperationApplicationError` if the operation
/// cannot be applied.
///
/// # Panics
///
/// When compiled in debug mode, panics if a delete operation is attempted
/// on a range of text that does not match the text to be deleted.
pub fn apply<'a>(&self, mut builder: StringBuilder<'a>) -> StringBuilder<'a> {
match self {
Operation::Insert { text, .. } => builder.insert(
self.start_index(),
&text.iter().map(Token::original).collect::<String>(),
),
Operation::Delete {
#[cfg(debug_assertions)]
deleted_text,
..
} => {
#[cfg(debug_assertions)]
debug_assert!(
deleted_text
.as_ref()
.is_none_or(|text| builder.get_slice(self.range()) == *text),
"Text to delete does not match the text in the range"
);
builder.delete(self.range());
}
};
builder
}
/// Returns the index of the first character that the operation affects.
pub fn start_index(&self) -> usize {
match self {
Operation::Insert { index, .. } => *index,
Operation::Delete { index, .. } => *index,
}
}
/// Returns the index of the last character that the operation affects.
pub fn end_index(&self) -> usize {
debug_assert!(
self.len() > 0,
" len() must be greater than 0 because operations must be non-empty"
);
self.start_index() + self.len() - 1
}
/// Returns the range of indices of characters that the operation affects.
pub fn range(&self) -> Range<usize> { self.start_index()..self.end_index() + 1 }
/// Returns the number of affected characters. It is always greater than 0
/// because empty operations cannot be created.
pub fn len(&self) -> usize {
match self {
Operation::Insert { text, .. } => text.iter().map(Token::get_original_length).sum(),
Operation::Delete {
deleted_character_count,
..
} => *deleted_character_count,
}
}
/// Creates a new operation with the same type and text but with the given
/// index.
pub fn with_index(self, index: usize) -> Self {
match self {
Operation::Insert { text, .. } => Operation::Insert { index, text },
Operation::Delete {
deleted_character_count,
#[cfg(debug_assertions)]
deleted_text,
..
} => Operation::Delete {
index,
deleted_character_count,
#[cfg(debug_assertions)]
deleted_text,
},
}
}
/// Creates a new operation with the same type and text but with the index
/// shifted by the given offset. The offset can be negative but the
/// resulting index must be non-negative.
///
/// # Panics
///
/// In debug mode, panics if the resulting index is negative.
pub fn with_shifted_index(self, offset: i64) -> Self {
let index = self.start_index() as i64 + offset;
debug_assert!(index >= 0, "Shifted index must be non-negative");
self.with_index(index as usize)
}
/// Merges the operation with the given context, producing a new operation
/// and updating the context. This implements a comples FSM that handles
/// the merging of operations in a way that is consistent with the text.
/// The contexts are updated in-place.
pub fn merge_operations_with_context(
self,
affecting_context: &mut MergeContext<T>,
produced_context: &mut MergeContext<T>,
) -> Option<Operation<T>> {
affecting_context.consume_last_operation_if_it_is_too_behind(&self);
let operation = self.with_shifted_index(affecting_context.shift);
match (operation, affecting_context.last_operation()) {
(operation @ Operation::Insert { .. }, None) => {
produced_context.shift += operation.len() as i64;
produced_context.consume_and_replace_last_operation(Some(operation.clone()));
Some(operation)
}
(
Operation::Insert { text, index },
Some(Operation::Insert {
text: previous_inserted_text,
..
}),
) => {
let offset_in_tokens = find_common_overlap(previous_inserted_text, &text);
let trimmed_length_in_tokens = previous_inserted_text.len() - offset_in_tokens;
let trimmed_length = previous_inserted_text
.iter()
.skip(offset_in_tokens)
.map(Token::get_original_length)
.sum::<usize>();
let trimmed_operation =
Operation::create_insert(index, text[trimmed_length_in_tokens..].to_vec());
affecting_context.shift -= trimmed_length as i64;
produced_context.shift += trimmed_operation
.as_ref()
.map(Operation::len)
.unwrap_or_default() as i64;
produced_context.consume_and_replace_last_operation(trimmed_operation.clone());
trimmed_operation
}
(operation @ Operation::Delete { .. }, None | Some(Operation::Insert { .. })) => {
produced_context.consume_and_replace_last_operation(Some(operation.clone()));
Some(operation)
}
(
operation @ Operation::Insert { .. },
Some(last_delete @ Operation::Delete { .. }),
) => {
produced_context.shift += operation.len() as i64;
debug_assert!(
last_delete.range().contains(&operation.start_index()),
"There is a last delete ({last_delete}) but the operation ({operation}) is \
not contained in it"
);
let difference = operation.start_index() as i64 - last_delete.start_index() as i64;
let moved_operation = operation.with_index(last_delete.start_index());
affecting_context.replace_last_operation(Operation::create_delete(
moved_operation.end_index() + 1,
(last_delete.len() as i64 - difference) as usize,
));
affecting_context.shift -= difference;
produced_context.consume_and_replace_last_operation(Some(moved_operation.clone()));
Some(moved_operation)
}
(
operation @ Operation::Delete { .. },
Some(last_delete @ Operation::Delete { .. }),
) => {
debug_assert!(
last_delete.range().contains(&operation.start_index()),
"There is a last delete ({last_delete}) but the operation ({operation}) is \
not contained in it"
);
let difference = operation.start_index() as i64 - last_delete.start_index() as i64;
let updated_delete = Operation::create_delete(
last_delete.start_index(),
0.max(operation.end_index() as i64 - last_delete.end_index() as i64) as usize,
);
affecting_context.replace_last_operation(Operation::create_delete(
last_delete.start_index(),
0.max(last_delete.end_index() as i64 - operation.end_index() as i64) as usize,
));
affecting_context.shift -= difference;
produced_context.consume_and_replace_last_operation(updated_delete.clone());
updated_delete
}
}
}
}
impl<T> Display for Operation<T>
where
T: PartialEq + Clone,
{
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Operation::Insert { index, text } => {
write!(
f,
"<insert '{}' from index {}>",
text.iter().map(Token::original).collect::<String>(),
index
)
}
Operation::Delete {
index,
deleted_character_count,
#[cfg(debug_assertions)]
deleted_text,
} => {
#[cfg(debug_assertions)]
write!(
f,
"<delete {} from index {}>",
deleted_text
.as_ref()
.map(|text| format!("'{text}'"))
.unwrap_or(format!("{deleted_character_count} characters")),
index
)?;
#[cfg(not(debug_assertions))]
write!(
f,
"<delete {deleted_character_count} characters from index {index}>",
)?;
Ok(())
}
}
}
}
impl<T> Debug for Operation<T>
where
T: PartialEq + Clone,
{
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!(f, "{self}") }
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
#[should_panic]
fn test_shifting_error() {
insta::assert_debug_snapshot!(Operation::create_insert(1, vec!["hi".into()])
.unwrap()
.with_shifted_index(-2));
}
#[test]
fn test_apply_delete_with_create() {
let builder = StringBuilder::new("hello world");
let operation = Operation::<()>::create_delete_with_text(5, " world".to_owned()).unwrap();
assert_eq!(operation.apply(builder).build(), "hello");
}
#[test]
fn test_apply_insert() {
let builder = StringBuilder::new("hello");
let operation = Operation::create_insert(5, vec![" my friend".into()]).unwrap();
assert_eq!(operation.apply(builder).build(), "hello my friend");
}
}

View file

@ -1,26 +0,0 @@
---
source: reconcile/src/operation_transformation/edited_text.rs
expression: operations
snapshot_kind: text
---
EditedText {
text: "hello world! How are you? Adam",
operations: [
OrderedOperation {
order: 0,
operation: <insert 'Hello, my friend! ' from index 0>,
},
OrderedOperation {
order: 0,
operation: <delete 'hello world! ' from index 18>,
},
OrderedOperation {
order: 21,
operation: <insert 'you doing? Albert' from index 26>,
},
OrderedOperation {
order: 21,
operation: <delete 'you? Adam' from index 43>,
},
],
}

View file

@ -1,61 +0,0 @@
---
source: reconcile/src/operations/edited_text.rs
expression: operations
snapshot_kind: text
---
EditedText {
text: "hello world! How are you? Adam",
operations: [
OrderedOperation {
order: 0,
operation: Insert {
index: 0,
text: "Hello, my friend! ",
},
},
OrderedOperation {
order: 0,
operation: Delete {
index: 18,
deleted_character_count: 13,
deleted_text: Some(
"hello world! ",
),
},
},
OrderedOperation {
order: 21,
operation: Delete {
index: 26,
deleted_character_count: 5,
deleted_text: Some(
"you? ",
),
},
},
OrderedOperation {
order: 26,
operation: Delete {
index: 26,
deleted_character_count: 5,
deleted_text: Some(
" Adam",
),
},
},
OrderedOperation {
order: 31,
operation: Insert {
index: 26,
text: "you ",
},
},
OrderedOperation {
order: 31,
operation: Insert {
index: 30,
text: "doing? Albert",
},
},
],
}

View file

@ -1,60 +0,0 @@
---
source: reconcile/src/operations/operation_sequence.rs
expression: operations
snapshot_kind: text
---
EditedText {
operations: [
OrderedOperation {
order: 0,
operation: Insert {
index: 0,
text: "Hello, my friend! ",
},
},
OrderedOperation {
order: 0,
operation: Delete {
index: 18,
deleted_character_count: 13,
deleted_text: Some(
"hello world! ",
),
},
},
OrderedOperation {
order: 21,
operation: Delete {
index: 26,
deleted_character_count: 5,
deleted_text: Some(
"you? ",
),
},
},
OrderedOperation {
order: 26,
operation: Delete {
index: 26,
deleted_character_count: 5,
deleted_text: Some(
" Adam",
),
},
},
OrderedOperation {
order: 31,
operation: Insert {
index: 26,
text: "you ",
},
},
OrderedOperation {
order: 31,
operation: Insert {
index: 30,
text: "doing? Albert",
},
},
],
}

View file

@ -1,6 +0,0 @@
use token::Token;
pub mod token;
pub mod word_tokenizer;
pub type Tokenizer<T> = dyn Fn(&str) -> Vec<Token<T>>;

View file

@ -1,49 +0,0 @@
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
/// A token is a string that has been normalised in some way.
/// The normalised form is used for comparison, while the original form is used
/// for applying Operations.
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Clone)]
pub struct Token<T>
where
T: PartialEq + Clone,
{
normalised: T,
original: String,
}
impl From<&str> for Token<String> {
fn from(s: &str) -> Self {
Token {
normalised: s.to_owned(),
original: s.to_owned(),
}
}
}
impl<T> Token<T>
where
T: PartialEq + Clone,
{
pub fn new(normalised: T, original: String) -> Self {
Token {
normalised,
original,
}
}
pub fn original(&self) -> &str { &self.original }
pub fn normalised(&self) -> &T { &self.normalised }
pub fn get_original_length(&self) -> usize { self.original.chars().count() }
}
impl<T> PartialEq for Token<T>
where
T: PartialEq + Clone,
{
fn eq(&self, other: &Self) -> bool { self.normalised == other.normalised }
}

View file

@ -1,7 +0,0 @@
use super::token::Token;
pub fn word_tokenizer(text: &str) -> Vec<Token<String>> {
text.split_inclusive(char::is_whitespace)
.map(|s| Token::new(s.to_owned(), s.to_owned()))
.collect()
}

View file

@ -1,7 +0,0 @@
pub mod common_prefix_len;
pub mod common_suffix_len;
pub mod find_common_overlap;
pub mod merge_iters;
pub mod ordered_operation;
pub mod side;
pub mod string_builder;

View file

@ -1,47 +0,0 @@
use core::ops::{Index, Range};
/// Given two lookups and ranges calculates the length of the common prefix.
/// Copied from <https://github.com/mitsuhiko/similar/blob/7e15c44de11a1cd61e1149189929e189ef977fd8/src/algorithms/utils.rs>
pub fn common_prefix_len<Old, New>(
old: &Old,
old_range: Range<usize>,
new: &New,
new_range: Range<usize>,
) -> usize
where
Old: Index<usize> + ?Sized,
New: Index<usize> + ?Sized,
New::Output: PartialEq<Old::Output>,
{
new_range
.zip(old_range)
.take_while(|x| new[x.0] == old[x.1])
.count()
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_common_prefix_len() {
assert_eq!(
common_prefix_len("".as_bytes(), 0..0, "".as_bytes(), 0..0),
0
);
assert_eq!(
common_prefix_len("foobarbaz".as_bytes(), 0..9, "foobarblah".as_bytes(), 0..10),
7
);
assert_eq!(
common_prefix_len("foobarbaz".as_bytes(), 0..9, "blablabla".as_bytes(), 0..9),
0
);
assert_eq!(
common_prefix_len("foobarbaz".as_bytes(), 3..9, "foobarblah".as_bytes(), 3..10),
4
);
}
}

View file

@ -1,48 +0,0 @@
use core::ops::{Index, Range};
/// Given two lookups and ranges calculates the length of common suffix.
/// Copied from <https://github.com/mitsuhiko/similar/blob/7e15c44de11a1cd61e1149189929e189ef977fd8/src/algorithms/utils.rs>
pub fn common_suffix_len<Old, New>(
old: &Old,
old_range: Range<usize>,
new: &New,
new_range: Range<usize>,
) -> usize
where
Old: Index<usize> + ?Sized,
New: Index<usize> + ?Sized,
New::Output: PartialEq<Old::Output>,
{
new_range
.rev()
.zip(old_range.rev())
.take_while(|x| new[x.0] == old[x.1])
.count()
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_common_suffix_len() {
assert_eq!(
common_suffix_len("".as_bytes(), 0..0, "".as_bytes(), 0..0),
0
);
assert_eq!(
common_suffix_len("1234".as_bytes(), 0..4, "X0001234".as_bytes(), 0..8),
4
);
assert_eq!(
common_suffix_len("1234".as_bytes(), 0..4, "Xxxx".as_bytes(), 0..4),
0
);
assert_eq!(
common_suffix_len("1234".as_bytes(), 2..4, "01234".as_bytes(), 2..5),
2
);
}
}

View file

@ -1,71 +0,0 @@
use crate::Token;
/// Given two lists of tokens, returns the offset in the first (old) list from
/// which the two lists have the same tokens until the end of the first list.
/// Thus, the suffix of the old list from the offset to the end is equal to a
/// prefix of the new list.
///
/// If there is no overlap, the function returns the maxmium offset, the length
/// of the old list.
///
/// ## Example
///
/// ```not_rust
/// old: [0, 1, 9, 0, 2, 5]
/// new: [9, 0, 2, 5, 1]
/// ```
/// > results in an offset of 2
pub fn find_common_overlap<T>(old: &[Token<T>], new: &[Token<T>]) -> usize
where
T: PartialEq + Clone,
{
let minimum_offset = old.len().saturating_sub(new.len());
for offset in minimum_offset..old.len() {
if old.iter().skip(offset).zip(new.iter()).all(|(a, b)| a == b) {
return offset;
}
}
old.len()
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_common_overlap() {
assert_eq!(find_common_overlap(&["".into()], &["".into()]), 0);
assert_eq!(
find_common_overlap(
&["a".into(), "b".into(), "c".into()],
&["b".into(), "c".into(), "a".into()]
),
1
);
assert_eq!(
find_common_overlap(
&["a".into(), "a".into(), "a".into()],
&["a".into(), "b".into(), "c".into()]
),
2
);
assert_eq!(
find_common_overlap(
&["a".into(), "b".into(), "c".into()],
&["d".into(), "e".into(), "a".into()]
),
3
);
assert_eq!(
find_common_overlap(&["a".into(), "a".into()], &["a".into()]),
1
);
}
}

View file

@ -1,87 +0,0 @@
use core::{cmp::Ordering, iter::Peekable};
pub struct MergeAscending<L, R, F, O>
where
L: Iterator<Item = R::Item>,
R: Iterator,
F: Fn(&R::Item) -> O,
O: PartialOrd,
{
left: Peekable<L>,
right: Peekable<R>,
get_key: F,
}
impl<L, R, F, O> MergeAscending<L, R, F, O>
where
L: Iterator<Item = R::Item>,
R: Iterator,
F: Fn(&R::Item) -> O,
O: PartialOrd,
{
fn new(left: L, right: R, get_key: F) -> Self {
MergeAscending {
left: left.peekable(),
right: right.peekable(),
get_key,
}
}
}
impl<L, R, F, O> Iterator for MergeAscending<L, R, F, O>
where
L: Iterator<Item = R::Item>,
R: Iterator,
F: Fn(&R::Item) -> O,
O: PartialOrd,
{
type Item = L::Item;
fn next(&mut self) -> Option<L::Item> {
let order = match (self.left.peek(), self.right.peek()) {
(Some(l), Some(r)) => (self.get_key)(l).partial_cmp(&(self.get_key)(r)),
(Some(_), None) => Some(Ordering::Less),
(None, Some(_)) => Some(Ordering::Greater),
(None, None) => return None,
};
match order {
Some(Ordering::Less) | None => self.left.next(),
Some(Ordering::Equal) => self.left.next(),
Some(Ordering::Greater) => self.right.next(),
}
}
}
pub trait MergeSorted: Iterator {
fn merge_sorted_by_key<R, F, O>(self, other: R, get_key: F) -> MergeAscending<Self, R, F, O>
where
Self: Sized,
R: Iterator<Item = Self::Item>,
F: Fn(&Self::Item) -> O,
O: PartialOrd,
{
MergeAscending::new(self, other, get_key)
}
}
impl<T: ?Sized> MergeSorted for T where T: Iterator {}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_merge_sorted_by_key() {
let left = [9, 7, 5, 3, 1];
let right = [7, 6, 5, 4, 3];
let result: Vec<i32> = left
.into_iter()
.merge_sorted_by_key(right.into_iter(), |x| -1 * x)
.collect();
assert_eq!(result, vec![9, 7, 7, 6, 5, 5, 4, 3, 3, 1]);
}
}

View file

@ -1,14 +0,0 @@
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::operation_transformation::Operation;
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub struct OrderedOperation<T>
where
T: PartialEq + Clone,
{
pub order: usize,
pub operation: Operation<T>,
}

View file

@ -1,4 +0,0 @@
pub enum Side {
Left,
Right,
}

View file

@ -1,110 +0,0 @@
use core::ops::Range;
/// A helper for building a string in order based on an original string and a
/// series of insertions and deletions applied to it. It is safe to use with
/// UTF-8 strings as all operations are based on character indices.
#[derive(Debug, Clone)]
pub struct StringBuilder<'a> {
original: &'a str,
last_old_char_index: usize,
buffer: String,
}
impl StringBuilder<'_> {
pub fn new(original: &str) -> StringBuilder<'_> {
StringBuilder {
original,
last_old_char_index: 0,
buffer: String::with_capacity(original.len()),
}
}
/// Insert a string at the given index after copying the original string up
/// to that index from the last insertion or deletion.
pub fn insert(&mut self, from: usize, text: &str) {
self.copy_until(from);
self.buffer.push_str(text);
}
/// Delete a string at the given index after copying the original string up
/// to that index from the last insertion or deletion.
pub fn delete(&mut self, range: core::ops::Range<usize>) {
self.copy_until(range.start);
self.last_old_char_index += range.len();
}
fn copy_until(&mut self, index: usize) {
let current_char_count = self.buffer.chars().count();
debug_assert!(
index >= current_char_count,
"String builder only support building in order"
);
let jump = index - current_char_count;
self.buffer.push_str(
&self
.original
.chars()
.skip(self.last_old_char_index)
.take(jump)
.collect::<String>(),
);
self.last_old_char_index += jump;
}
/// Finish building the string after copying the remaining original string
/// since the last insertion or deletion.
pub fn build(mut self) -> String {
self.buffer.push_str(
&self
.original
.chars()
.skip(self.last_old_char_index)
.collect::<String>(),
);
self.buffer
}
pub fn get_slice(&self, range: Range<usize>) -> String {
let result = self
.buffer
.chars()
.chain(self.original.chars().skip(self.last_old_char_index))
.skip(range.start)
.take(range.end - range.start)
.collect::<String>();
debug_assert_eq!(result.chars().count(), range.len(), "Range out of bounds",);
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_string_builder() {
let original = "aaa bbb ccc";
let mut builder = StringBuilder::new(original);
builder.insert(0, "ddd ");
builder.delete(4..8);
builder.insert(11, " eee");
assert_eq!(builder.build(), "ddd bbb ccc eee");
}
#[test]
fn test_string_builder2() {
let original = "abcde";
let mut builder = StringBuilder::new(original);
builder.delete(1..4);
assert_eq!(builder.build(), "ae");
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,8 +0,0 @@
imports_granularity = "crate"
condense_wildcard_suffixes = true
fn_single_line = true
format_strings = true
reorder_impl_items = true
group_imports = "StdExternalCrate"
use_field_init_shorthand = true
wrap_comments=true

View file

@ -1,32 +0,0 @@
[package]
name = "sync_lib"
version = "0.1.0"
authors = ["Andras Schmelczer <andras@schmelczer.dev>"]
edition = "2018"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
base64 = "0.22.1"
reconcile = { path = "../reconcile" }
wasm-bindgen = "0.2.84"
getrandom = { version = "0.2.3", features = ["js"] }
thiserror = { workspace = true }
# The `console_error_panic_hook` crate provides better debugging of panics by
# logging them with `console.error`. This is great for development, but requires
# all the `std::fmt` and `std::panicking` infrastructure, so isn't great for
# code size when deploying.
console_error_panic_hook = { version = "0.1.7", optional = true }
wee_alloc = { version = "0.4.5", optional = true }
[dev-dependencies]
wasm-bindgen-test = "0.3.34"
[features]
default = ["console_error_panic_hook"]
[lints]
workspace = true

View file

@ -1,84 +0,0 @@
<div align="center">
<h1><code>wasm-pack-template</code></h1>
<strong>A template for kick starting a Rust and WebAssembly project using <a href="https://github.com/rustwasm/wasm-pack">wasm-pack</a>.</strong>
<p>
<a href="https://travis-ci.org/rustwasm/wasm-pack-template"><img src="https://img.shields.io/travis/rustwasm/wasm-pack-template.svg?style=flat-square" alt="Build Status" /></a>
</p>
<h3>
<a href="https://rustwasm.github.io/docs/wasm-pack/tutorials/npm-browser-packages/index.html">Tutorial</a>
<span> | </span>
<a href="https://discordapp.com/channels/442252698964721669/443151097398296587">Chat</a>
</h3>
<sub>Built with 🦀🕸 by <a href="https://rustwasm.github.io/">The Rust and WebAssembly Working Group</a></sub>
</div>
## About
[**📚 Read this template tutorial! 📚**][template-docs]
This template is designed for compiling Rust libraries into WebAssembly and
publishing the resulting package to NPM.
Be sure to check out [other `wasm-pack` tutorials online][tutorials] for other
templates and usages of `wasm-pack`.
[tutorials]: https://rustwasm.github.io/docs/wasm-pack/tutorials/index.html
[template-docs]: https://rustwasm.github.io/docs/wasm-pack/tutorials/npm-browser-packages/index.html
## 🚴 Usage
### 🐑 Use `cargo generate` to Clone this Template
[Learn more about `cargo generate` here.](https://github.com/ashleygwilliams/cargo-generate)
```
cargo generate --git https://github.com/rustwasm/wasm-pack-template.git --name my-project
cd my-project
```
### 🛠️ Build with `wasm-pack build`
```
wasm-pack build
```
### 🔬 Test in Headless Browsers with `wasm-pack test`
```
wasm-pack test --headless --firefox
```
### 🎁 Publish to NPM with `wasm-pack publish`
```
wasm-pack publish
```
## 🔋 Batteries Included
* [`wasm-bindgen`](https://github.com/rustwasm/wasm-bindgen) for communicating
between WebAssembly and JavaScript.
* [`console_error_panic_hook`](https://github.com/rustwasm/console_error_panic_hook)
for logging panic messages to the developer console.
* `LICENSE-APACHE` and `LICENSE-MIT`: most Rust projects are licensed this way, so these are included for you
## License
Licensed under either of
* Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
* MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
at your option.
### Contribution
Unless you explicitly state otherwise, any contribution intentionally
submitted for inclusion in the work by you, as defined in the Apache-2.0
license, shall be dual licensed as above, without any additional terms or
conditions.

View file

@ -1,42 +0,0 @@
use std::str::Utf8Error;
use base64::DecodeError;
use thiserror::Error;
use wasm_bindgen::JsValue;
#[derive(Error, Debug)]
pub enum SyncLibError {
#[error("Base64 decoding error because of {}", .reason)]
Base64DecodingError { reason: String },
#[error("Bytes cannot be decoded as UTF-8 string because of {}", .reason)]
StringDecodingError { reason: String },
}
impl From<DecodeError> for SyncLibError {
fn from(e: DecodeError) -> Self {
SyncLibError::Base64DecodingError {
reason: e.to_string(),
}
}
}
impl From<Utf8Error> for SyncLibError {
fn from(e: Utf8Error) -> Self {
SyncLibError::StringDecodingError {
reason: e.to_string(),
}
}
}
impl From<std::string::FromUtf8Error> for SyncLibError {
fn from(e: std::string::FromUtf8Error) -> Self {
SyncLibError::Base64DecodingError {
reason: e.to_string(),
}
}
}
impl From<SyncLibError> for JsValue {
fn from(val: SyncLibError) -> Self { JsValue::from_str(&val.to_string()) }
}

View file

@ -1,58 +0,0 @@
use core::str;
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _};
use errors::SyncLibError;
use wasm_bindgen::prelude::*;
pub mod errors;
// When the `wee_alloc` feature is enabled, use `wee_alloc` as the global
// allocator.
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
#[wasm_bindgen]
pub fn bytes_to_base64(input: &[u8]) -> String { STANDARD_NO_PAD.encode(input) }
#[wasm_bindgen]
pub fn string_to_base64(input: &str) -> String { bytes_to_base64(input.as_bytes()) }
#[wasm_bindgen]
pub fn base64_to_bytes(input: &str) -> Result<Vec<u8>, SyncLibError> {
STANDARD_NO_PAD.decode(input).map_err(SyncLibError::from)
}
#[wasm_bindgen]
pub fn base64_to_string(input: &str) -> Result<String, SyncLibError> {
let bytes = base64_to_bytes(input)?;
String::from_utf8(bytes).map_err(SyncLibError::from)
}
#[wasm_bindgen]
pub fn merge(parent: &[u8], left: &[u8], right: &[u8]) -> Result<Vec<u8>, SyncLibError> {
Ok(if is_binary(right) {
right.to_vec()
} else {
reconcile::reconcile(
str::from_utf8(parent).map_err(SyncLibError::from)?,
str::from_utf8(left).map_err(SyncLibError::from)?,
str::from_utf8(right).map_err(SyncLibError::from)?,
)
.into_bytes()
})
}
#[wasm_bindgen]
pub fn is_binary(data: &[u8]) -> bool { data.iter().any(|&b| b == 0) }
pub fn set_panic_hook() {
// When the `console_error_panic_hook` feature is enabled, we can call the
// `set_panic_hook` function at least once during initialization, and then
// we will get better error messages if our code ever panics.
//
// For more details see
// https://github.com/rustwasm/console_error_panic_hook#readme
#[cfg(feature = "console_error_panic_hook")]
console_error_panic_hook::set_once();
}

View file

@ -1,13 +0,0 @@
//! Test suite for the Web and headless browsers.
#![cfg(target_arch = "wasm32")]
extern crate wasm_bindgen_test;
use wasm_bindgen_test::*;
wasm_bindgen_test_configure!(run_in_browser);
#[wasm_bindgen_test]
fn pass() {
assert_eq!(1 + 1, 2);
}

View file

@ -1,30 +0,0 @@
[package]
name = "sync_server"
version = "0.1.0"
edition = "2021"
[dependencies]
reconcile = { path = "../reconcile" }
sync_lib = { path = "../sync_lib" }
serde = { workspace = true }
thiserror = { workspace = true }
tokio = { version = "1.42.0", features = ["full"]}
uuid = { version = "1.11.0", features = ["v4", "serde"] }
log = { version = "0.4.22" }
anyhow = { version = "1.0.94", features = ["backtrace"] }
axum = { version = "0.7.9", features = ["ws", "macros", "tracing"]}
axum-extra = { version = "0.9.6", features = ["typed-header"] }
tower-http = { version = "0.6.1", features = ["cors", "trace"] }
tracing-subscriber = { version = "0.3.19", features = ["fmt", "env-filter"]}
serde_yaml = "0.9.34"
sqlx = { version = "0.8.2", features = ["sqlite", "runtime-tokio", "uuid", "chrono"] }
chrono = { version = "0.4.38", features = ["serde"] }
aide = { version = "0.13.4", features = ["axum", "axum-ws", "scalar", "axum-headers"] }
schemars = { version = "0.8.21", features = ["chrono", "uuid1"] }
tracing = "0.1"
rand = "0.8.5"
[lints]
workspace = true

View file

@ -1,4 +0,0 @@
cargo install sqlx-cli
rm db.sqlite3; sqlx database create --database-url sqlite://db.sqlite3
sqlx migrate run --source sync_server/src/database/migrations --database-url sqlite://db.sqlite3

View file

@ -1,20 +0,0 @@
use anyhow::Result;
use crate::{config::Config, consts::CONFIG_PATH, database::Database};
#[derive(Clone, Debug)]
pub struct AppState {
pub config: Config,
pub database: Database,
}
impl AppState {
pub async fn try_new() -> Result<Self> {
let path = std::path::Path::new(CONFIG_PATH);
let config = Config::read_or_create(path).await?;
let database = Database::try_new(&config.database).await?;
Ok(Self { config, database })
}
}

View file

@ -1,32 +0,0 @@
use log::debug;
use serde::{Deserialize, Serialize};
use crate::consts::{DEFAULT_MAX_CONNECTIONS, DEFAULT_SQLITE_URL};
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct DatabaseConfig {
#[serde(default = "default_sqlite_url")]
pub sqlite_url: String,
#[serde(default = "default_max_connections")]
pub max_connections: u32,
}
fn default_sqlite_url() -> String {
debug!("Using default sqlite url: {}", DEFAULT_SQLITE_URL);
DEFAULT_SQLITE_URL.to_owned()
}
fn default_max_connections() -> u32 {
debug!("Using default max connections: {}", DEFAULT_MAX_CONNECTIONS);
DEFAULT_MAX_CONNECTIONS
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
sqlite_url: default_sqlite_url(),
max_connections: default_max_connections(),
}
}
}

View file

@ -1,43 +0,0 @@
use log::debug;
use serde::{Deserialize, Serialize};
use crate::consts::{DEFAULT_HOST, DEFAULT_MAX_BODY_SIZE_MB, DEFAULT_PORT};
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ServerConfig {
#[serde(default = "default_host")]
pub host: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default = "default_max_body_size_mb")]
pub max_body_size_mb: usize,
}
fn default_host() -> String {
debug!("Using default server host: {}", DEFAULT_HOST);
DEFAULT_HOST.to_owned()
}
fn default_port() -> u16 {
debug!("Using default server port: {}", DEFAULT_PORT);
DEFAULT_PORT
}
fn default_max_body_size_mb() -> usize {
debug!(
"Using default max body size (MB): {}",
DEFAULT_MAX_BODY_SIZE_MB
);
DEFAULT_MAX_BODY_SIZE_MB
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: default_host(),
port: default_port(),
max_body_size_mb: default_max_body_size_mb(),
}
}
}

View file

@ -1,43 +0,0 @@
use rand::{distributions::Alphanumeric, thread_rng, Rng as _};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct UserConfig {
#[serde(default = "default_users")]
pub user_tokens: Vec<User>,
}
impl UserConfig {
pub fn get_user(&self, token: &str) -> Option<&User> {
self.user_tokens.iter().find(|u| u.token == token)
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct User {
pub name: String,
pub token: String,
}
impl Default for UserConfig {
fn default() -> Self {
Self {
user_tokens: default_users(),
}
}
}
fn default_users() -> Vec<User> {
vec![User {
name: "admin".to_owned(),
token: get_random_token(),
}]
}
pub fn get_random_token() -> String {
thread_rng()
.sample_iter(&Alphanumeric)
.take(64)
.map(char::from)
.collect()
}

View file

@ -1,6 +0,0 @@
pub const CONFIG_PATH: &str = "config.yaml";
pub const DEFAULT_SQLITE_URL: &str = "db.sqlite3";
pub const DEFAULT_HOST: &str = "127.0.0.1";
pub const DEFAULT_PORT: u16 = 3000;
pub const DEFAULT_MAX_CONNECTIONS: u32 = 12;
pub const DEFAULT_MAX_BODY_SIZE_MB: usize = 4096;

View file

@ -1,299 +0,0 @@
use core::{str::FromStr as _, time::Duration};
use anyhow::{Context as _, Result};
use models::{
DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId, VaultUpdateId,
};
use sqlx::{sqlite::SqliteConnectOptions, types::chrono::Utc};
pub mod models;
use sqlx::{sqlite::SqlitePoolOptions, Pool, Sqlite};
use crate::config::database_config::DatabaseConfig;
#[derive(Clone, Debug)]
pub struct Database {
connection_pool: Pool<Sqlite>,
}
pub type Transaction<'a> = sqlx::Transaction<'a, Sqlite>;
impl Database {
pub async fn try_new(config: &DatabaseConfig) -> Result<Self> {
let connection_options = SqliteConnectOptions::from_str(&config.sqlite_url)?
.create_if_missing(true)
.busy_timeout(Duration::from_secs(3600))
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
let pool = SqlitePoolOptions::new()
.max_connections(config.max_connections)
.test_before_acquire(true)
.connect_with(connection_options)
.await
.with_context(|| {
format!(
"Cannot connect to database with url: {}",
&config.sqlite_url
)
})?;
Self::run_migrations(&pool).await?;
Ok(Self {
connection_pool: pool,
})
}
async fn run_migrations(pool: &Pool<Sqlite>) -> Result<()> {
sqlx::migrate!("src/database/migrations")
.run(pool)
.await
.context("Cannot check for pending migrations")
}
/// Attempting to write from this transaction might result in a
/// database locked error. Use this transaction for read-only operations.
pub async fn create_readonly_transaction(&self) -> Result<Transaction<'_>> {
self.connection_pool
.begin()
.await
.context("Cannot create transaction")
}
pub async fn create_write_transaction(&self) -> Result<Transaction<'_>> {
let mut transaction = self.create_readonly_transaction().await?;
// sqlx doesn't support immediate transactions for sqlite: https://github.com/launchbadge/sqlx/issues/481
sqlx::query!("END; BEGIN IMMEDIATE;")
.execute(&mut *transaction)
.await?;
Ok(transaction)
}
/// Return the latest state of all non-deleted documents in the vault
pub async fn get_latest_documents(
&self,
vault: &VaultId,
transaction: Option<&mut Transaction<'_>>,
) -> Result<Vec<DocumentVersionWithoutContent>> {
let query = sqlx::query_as!(
DocumentVersionWithoutContent,
r#"
select
vault_id,
vault_update_id,
document_id as "document_id: uuid::Uuid",
relative_path,
created_date as "created_date: chrono::DateTime<Utc>",
updated_date as "updated_date: chrono::DateTime<Utc>",
is_deleted
from latest_document_versions
where is_deleted = false and vault_id = ?
"#,
vault,
);
if let Some(transaction) = transaction {
query.fetch_all(&mut **transaction).await
} else {
query.fetch_all(&self.connection_pool).await
}
.context("Cannot fetch latest documents")
}
/// Return the latest state of all documents (including deleted) in the
/// vault which have changed since the given update id
pub async fn get_latest_documents_since(
&self,
vault: &VaultId,
vault_update_id: VaultUpdateId,
transaction: Option<&mut Transaction<'_>>,
) -> Result<Vec<DocumentVersionWithoutContent>> {
let query = sqlx::query_as!(
DocumentVersionWithoutContent,
r#"
select
vault_id,
vault_update_id,
document_id as "document_id: uuid::Uuid",
relative_path,
created_date as "created_date: chrono::DateTime<Utc>",
updated_date as "updated_date: chrono::DateTime<Utc>",
is_deleted
from latest_document_versions
where vault_id = ? and vault_update_id > ?
"#,
vault,
vault_update_id
);
if let Some(transaction) = transaction {
query.fetch_all(&mut **transaction).await
} else {
query.fetch_all(&self.connection_pool).await
}
.with_context(|| {
format!("Cannot fetch latest documents since vault_update_id {vault_update_id}")
})
}
pub async fn get_max_update_id_in_vault(
&self,
vault: &VaultId,
transaction: Option<&mut Transaction<'_>>,
) -> Result<i64> {
let query = sqlx::query!(
r#"
select coalesce(max(vault_update_id), 0) as max_vault_update_id
from documents
where vault_id = ?
"#,
vault
);
if let Some(transaction) = transaction {
query.fetch_one(&mut **transaction).await
} else {
query.fetch_one(&self.connection_pool).await
}
.map(|row| row.max_vault_update_id)
.context("Cannot fetch max update id in vault")
}
pub async fn get_latest_document_by_path(
&self,
vault: &VaultId,
relative_path: &str,
transaction: Option<&mut Transaction<'_>>,
) -> Result<Option<StoredDocumentVersion>> {
let query = sqlx::query_as!(
StoredDocumentVersion,
r#"
select
vault_id,
vault_update_id,
document_id as "document_id: uuid::Uuid",
relative_path,
created_date as "created_date: chrono::DateTime<Utc>",
updated_date as "updated_date: chrono::DateTime<Utc>",
content,
is_deleted
from latest_document_versions
where vault_id = ? and relative_path = ?
"#,
vault,
relative_path
);
if let Some(transaction) = transaction {
query.fetch_optional(&mut **transaction).await
} else {
query.fetch_optional(&self.connection_pool).await
}
.context("Cannot fetch latest document version")
}
pub async fn get_latest_document(
&self,
vault: &VaultId,
document_id: &DocumentId,
transaction: Option<&mut Transaction<'_>>,
) -> Result<Option<StoredDocumentVersion>> {
let query = sqlx::query_as!(
StoredDocumentVersion,
r#"
select
vault_id,
vault_update_id,
document_id as "document_id: uuid::Uuid",
relative_path,
created_date as "created_date: chrono::DateTime<Utc>",
updated_date as "updated_date: chrono::DateTime<Utc>",
content,
is_deleted
from latest_document_versions
where vault_id = ? and document_id = ?
"#,
vault,
document_id
);
if let Some(transaction) = transaction {
query.fetch_optional(&mut **transaction).await
} else {
query.fetch_optional(&self.connection_pool).await
}
.context("Cannot fetch latest document version")
}
pub async fn get_document_version(
&self,
vault: &VaultId,
vault_update_id: VaultUpdateId,
transaction: Option<&mut Transaction<'_>>,
) -> Result<Option<StoredDocumentVersion>> {
let query = sqlx::query_as!(
StoredDocumentVersion,
r#"
select
vault_id,
vault_update_id,
document_id as "document_id: uuid::Uuid",
relative_path,
created_date as "created_date: chrono::DateTime<Utc>",
updated_date as "updated_date: chrono::DateTime<Utc>",
content,
is_deleted
from documents
where vault_id = ? and vault_update_id = ?"#,
vault,
vault_update_id
);
if let Some(transaction) = transaction {
query.fetch_optional(&mut **transaction).await
} else {
query.fetch_optional(&self.connection_pool).await
}
.context("Cannot fetch document version")
}
pub async fn insert_document_version(
&self,
version: &StoredDocumentVersion,
transaction: Option<&mut Transaction<'_>>,
) -> Result<()> {
let query = sqlx::query!(
r#"
insert into documents (
vault_id,
vault_update_id,
document_id,
relative_path,
created_date,
updated_date,
content,
is_deleted
)
values (?, ?, ?, ?, ?, ?, ?, ?)
"#,
version.vault_id,
version.vault_update_id,
version.document_id,
version.relative_path,
version.created_date,
version.updated_date,
version.content,
version.is_deleted
);
if let Some(transaction) = transaction {
query.execute(&mut **transaction).await
} else {
query.execute(&self.connection_pool).await
}
.context("Cannot insert document version")?;
Ok(())
}
}

View file

@ -1,25 +0,0 @@
CREATE TABLE IF NOT EXISTS documents (
vault_id TEXT NOT NULL,
vault_update_id INTEGER NOT NULL,
document_id TEXT NOT NULL,
relative_path TEXT NOT NULL,
created_date TIMESTAMP NOT NULL,
updated_date TIMESTAMP NOT NULL,
content BLOB NOT NULL,
is_deleted BOOLEAN NOT NULL,
PRIMARY KEY (vault_id, vault_update_id)
);
CREATE VIEW IF NOT EXISTS latest_document_versions AS
SELECT d.*
FROM documents d
INNER JOIN (
SELECT vault_id, MAX(vault_update_id) AS max_version_id
FROM documents
GROUP BY vault_id, document_id
) max_versions
ON d.vault_id = max_versions.vault_id
AND d.vault_update_id = max_versions.max_version_id;
CREATE INDEX IF NOT EXISTS idx_documents_vault_id_relative_path
ON documents (vault_id, relative_path);

View file

@ -1,113 +0,0 @@
use aide::OperationOutput;
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use log::{info, warn};
use schemars::JsonSchema;
use serde::Serialize;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum SyncServerError {
#[error("Initialisation error: {0}")]
InitError(#[source] anyhow::Error),
#[error("Client error: {0:?}")]
ClientError(#[source] anyhow::Error),
#[error("Server error: {0:?}")]
ServerError(#[source] anyhow::Error),
#[error("Not found: {0}")]
NotFound(#[source] anyhow::Error),
#[error("Unauthorized: {0}")]
Unauthorized(#[source] anyhow::Error),
#[error("Permission denied error: {0}")]
PermissionDeniedError(#[source] anyhow::Error),
}
impl SyncServerError {
pub fn serialize(&self) -> SerializedError {
match self {
Self::InitError(error) => format_anyhow_error(error),
Self::ClientError(error) => format_anyhow_error(error),
Self::ServerError(error) => format_anyhow_error(error),
Self::NotFound(error) => format_anyhow_error(error),
Self::Unauthorized(error) => format_anyhow_error(error),
Self::PermissionDeniedError(error) => format_anyhow_error(error),
}
}
}
impl IntoResponse for SyncServerError {
fn into_response(self) -> Response {
let body = Json(self.serialize());
match self {
Self::InitError(_) => (StatusCode::INTERNAL_SERVER_ERROR, body).into_response(),
Self::ClientError(_) => (StatusCode::BAD_REQUEST, body).into_response(),
Self::ServerError(_) => (StatusCode::INTERNAL_SERVER_ERROR, body).into_response(),
Self::NotFound(_) => (StatusCode::NOT_FOUND, body).into_response(),
Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, body).into_response(),
Self::PermissionDeniedError(_) => (StatusCode::FORBIDDEN, body).into_response(),
}
}
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct SerializedError {
pub message: String,
pub causes: Vec<String>,
}
fn format_anyhow_error(error: &anyhow::Error) -> SerializedError {
let mut causes = vec![];
let mut current_error = error.source();
while let Some(error) = current_error {
causes.push(error.to_string());
current_error = error.source();
}
SerializedError {
message: error.to_string(),
causes,
}
}
impl OperationOutput for SyncServerError {
type Inner = Self;
}
pub const fn init_error(error: anyhow::Error) -> SyncServerError {
SyncServerError::InitError(error)
}
pub fn server_error(error: anyhow::Error) -> SyncServerError {
warn!("Server error: {:?}", error);
SyncServerError::ServerError(error)
}
pub fn client_error(error: anyhow::Error) -> SyncServerError {
info!("Client error: {:?}", error);
SyncServerError::ClientError(error)
}
pub fn not_found_error(error: anyhow::Error) -> SyncServerError {
info!("Not found error: {:?}", error);
SyncServerError::NotFound(error)
}
pub fn unauthorized_error(error: anyhow::Error) -> SyncServerError {
info!("Unauthorized error: {:?}", error);
SyncServerError::Unauthorized(error)
}
#[allow(dead_code)]
pub fn permission_denied_error(error: anyhow::Error) -> SyncServerError {
info!("Permission denied error: {:?}", error);
SyncServerError::PermissionDeniedError(error)
}

View file

@ -1,40 +0,0 @@
mod app_state;
mod config;
mod consts;
mod database;
mod errors;
mod server;
use anyhow::{Context as _, Result};
use app_state::AppState;
use errors::{init_error, SyncServerError};
use server::create_server;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[tokio::main]
async fn main() -> Result<(), SyncServerError> {
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
format!(
"{}=debug,tower_http=debug,axum::rejection=trace",
env!("CARGO_CRATE_NAME")
)
.into()
}),
)
.with(tracing_subscriber::fmt::layer())
.try_init()
.context("Failed to initialise tracing")
.map_err(init_error)?;
let app_state = AppState::try_new()
.await
.context("Failed to initialise app state")
.map_err(init_error)?;
create_server(app_state)
.await
.context("Failed to start server")
.map_err(init_error)
}

View file

@ -1,14 +0,0 @@
use crate::{
app_state::AppState,
config::user_config::User,
errors::{unauthorized_error, SyncServerError},
};
pub fn auth(app_state: &AppState, token: &str) -> Result<User, SyncServerError> {
app_state
.config
.users
.get_user(token)
.cloned()
.ok_or_else(|| unauthorized_error(anyhow::anyhow!("Invalid token")))
}

View file

@ -1,109 +0,0 @@
use anyhow::Context as _;
use axum::{
extract::{Path, State},
Json,
};
use axum_extra::{
headers::{authorization::Bearer, Authorization},
TypedHeader,
};
use schemars::JsonSchema;
use serde::Deserialize;
use sync_lib::{base64_to_bytes, merge};
use super::{auth::auth, requests::CreateDocumentVersion};
use crate::{
app_state::AppState,
database::models::{DocumentVersion, StoredDocumentVersion, VaultId},
errors::{client_error, server_error, SyncServerError},
};
// This is required for aide to infer the path parameter types and names
#[derive(Deserialize, JsonSchema)]
pub struct PathParams {
vault_id: VaultId,
}
/// Create a new document in case a document with the same doesn't exist
/// already. If a document with the same path exists, a new version is created
/// with their content merged.
#[axum::debug_handler]
pub async fn create_document(
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
Path(PathParams { vault_id }): Path<PathParams>,
State(state): State<AppState>,
Json(request): Json<CreateDocumentVersion>,
) -> Result<Json<DocumentVersion>, SyncServerError> {
auth(&state, auth_header.token())?;
let mut transaction = state
.database
.create_write_transaction()
.await
.map_err(server_error)?;
let last_update_id = state
.database
.get_max_update_id_in_vault(&vault_id, Some(&mut transaction))
.await
.map_err(server_error)?;
let maybe_existing_version = state
.database
.get_latest_document_by_path(&vault_id, &request.relative_path, Some(&mut transaction))
.await
.map_err(server_error)?
.and_then(|doc| if doc.is_deleted { None } else { Some(doc) });
let new_version = if let Some(existing_version) = maybe_existing_version {
let content_bytes = base64_to_bytes(&request.content_base64)
.context("Failed to decode base64 content in request")
.map_err(client_error)?;
let merged_content = merge(
&[], // the empty string is the first common parent of the two documents,
&existing_version.content,
&content_bytes,
)
.context("Failed to decode bytes as UTF-8")
.map_err(client_error)?;
StoredDocumentVersion {
vault_id,
vault_update_id: last_update_id + 1,
relative_path: request.relative_path,
document_id: existing_version.document_id,
content: merged_content,
created_date: request.created_date,
updated_date: chrono::Utc::now(),
is_deleted: false,
}
} else {
StoredDocumentVersion {
vault_id,
vault_update_id: last_update_id + 1,
document_id: uuid::Uuid::new_v4(),
relative_path: request.relative_path,
content: base64_to_bytes(&request.content_base64)
.context("Cannot convert base64 encoded content to bytes")
.map_err(client_error)?,
created_date: request.created_date,
updated_date: chrono::Utc::now(),
is_deleted: false,
}
};
state
.database
.insert_document_version(&new_version, Some(&mut transaction))
.await
.map_err(server_error)?;
transaction
.commit()
.await
.context("Failed to commit successful transaction")
.map_err(server_error)?;
Ok(Json(new_version.into()))
}

View file

@ -1,75 +0,0 @@
use anyhow::Context as _;
use axum::{
extract::{Path, State},
Json,
};
use axum_extra::{
headers::{authorization::Bearer, Authorization},
TypedHeader,
};
use schemars::JsonSchema;
use serde::Deserialize;
use super::{auth::auth, requests::DeleteDocumentVersion};
use crate::{
app_state::AppState,
database::models::{DocumentId, StoredDocumentVersion, VaultId},
errors::{server_error, SyncServerError},
};
// This is required for aide to infer the path parameter types and names
#[derive(Deserialize, JsonSchema)]
pub struct PathParams {
vault_id: VaultId,
document_id: DocumentId,
}
#[axum::debug_handler]
pub async fn delete_document(
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
Path(PathParams {
vault_id,
document_id,
}): Path<PathParams>,
State(state): State<AppState>,
Json(request): Json<DeleteDocumentVersion>,
) -> Result<(), SyncServerError> {
auth(&state, auth_header.token())?;
let mut transaction = state
.database
.create_write_transaction()
.await
.map_err(server_error)?;
let last_update_id = state
.database
.get_max_update_id_in_vault(&vault_id, Some(&mut transaction))
.await
.map_err(server_error)?;
let new_version = StoredDocumentVersion {
vault_id,
vault_update_id: last_update_id + 1,
document_id,
relative_path: request.relative_path,
content: vec![],
created_date: request.created_date,
updated_date: chrono::Utc::now(),
is_deleted: true,
};
state
.database
.insert_document_version(&new_version, Some(&mut transaction))
.await
.map_err(server_error)?;
transaction
.commit()
.await
.context("Failed to commit successful transaction")
.map_err(server_error)?;
Ok(())
}

View file

@ -1,51 +0,0 @@
use anyhow::anyhow;
use axum::{
extract::{Path, State},
Json,
};
use axum_extra::{
headers::{authorization::Bearer, Authorization},
TypedHeader,
};
use schemars::JsonSchema;
use serde::Deserialize;
use super::auth::auth;
use crate::{
app_state::AppState,
database::models::{DocumentId, DocumentVersion, VaultId},
errors::{not_found_error, server_error, SyncServerError},
};
// This is required for aide to infer the path parameter types and names
#[derive(Deserialize, JsonSchema)]
pub struct PathParams {
vault_id: VaultId,
document_id: DocumentId,
}
#[axum::debug_handler]
pub async fn fetch_latest_document_version(
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
Path(PathParams {
vault_id,
document_id,
}): Path<PathParams>,
State(state): State<AppState>,
) -> Result<Json<DocumentVersion>, SyncServerError> {
auth(&state, auth_header.token())?;
let latest_version = state
.database
.get_latest_document(&vault_id, &document_id, None)
.await
.map_err(server_error)?
.map(Ok)
.unwrap_or_else(|| {
Err(not_found_error(anyhow!(
"Document with id `{document_id}` not found",
)))
})?;
Ok(Json(latest_version.into()))
}

View file

@ -1,22 +0,0 @@
use axum::{extract::State, Json};
use axum_extra::{
headers::{authorization::Bearer, Authorization},
TypedHeader,
};
use super::{auth::auth, responses::PingResponse};
use crate::{app_state::AppState, errors::SyncServerError};
#[axum::debug_handler]
pub async fn ping(
maybe_auth_header: Option<TypedHeader<Authorization<Bearer>>>,
State(state): State<AppState>,
) -> Result<Json<PingResponse>, SyncServerError> {
let is_authenticated =
maybe_auth_header.is_some_and(|auth_header| auth(&state, auth_header.token()).is_ok());
Ok(Json(PingResponse {
server_version: env!("CARGO_PKG_VERSION").to_owned(),
is_authenticated,
}))
}

View file

@ -1,29 +0,0 @@
use chrono::{DateTime, Utc};
use schemars::JsonSchema;
use serde::{self, Deserialize};
use crate::database::models::VaultUpdateId;
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct CreateDocumentVersion {
pub relative_path: String,
pub created_date: DateTime<Utc>,
pub content_base64: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct UpdateDocumentVersion {
pub parent_version_id: VaultUpdateId,
pub relative_path: String,
pub created_date: DateTime<Utc>,
pub content_base64: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct DeleteDocumentVersion {
pub relative_path: String,
pub created_date: DateTime<Utc>,
}

View file

@ -1,18 +0,0 @@
use schemars::JsonSchema;
use serde::{self, Serialize};
use crate::database::models::{DocumentVersionWithoutContent, VaultUpdateId};
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct PingResponse {
pub server_version: String,
pub is_authenticated: bool,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct FetchLatestDocumentsResponse {
pub latest_documents: Vec<DocumentVersionWithoutContent>,
pub last_update_id: VaultUpdateId,
}

View file

@ -1,141 +0,0 @@
use anyhow::{anyhow, Context as _};
use axum::{
extract::{Path, State},
Json,
};
use axum_extra::{
headers::{authorization::Bearer, Authorization},
TypedHeader,
};
use log::info;
use schemars::JsonSchema;
use serde::Deserialize;
use sync_lib::{base64_to_bytes, merge};
use super::{auth::auth, requests::UpdateDocumentVersion};
use crate::{
app_state::AppState,
database::models::{DocumentId, DocumentVersion, StoredDocumentVersion, VaultId},
errors::{client_error, not_found_error, server_error, SyncServerError},
};
// This is required for aide to infer the path parameter types and names
#[derive(Deserialize, JsonSchema)]
pub struct PathParams {
vault_id: VaultId,
document_id: DocumentId,
}
#[axum::debug_handler]
pub async fn update_document(
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
Path(PathParams {
vault_id,
document_id,
}): Path<PathParams>,
State(state): State<AppState>,
Json(request): Json<UpdateDocumentVersion>,
) -> Result<Json<DocumentVersion>, SyncServerError> {
auth(&state, auth_header.token())?;
// No need for a transaction as document versions are immutable
let parent_document = state
.database
.get_document_version(&vault_id, request.parent_version_id, None)
.await
.map_err(server_error)?
.map_or_else(
|| {
Err(not_found_error(anyhow!(
"Parent version with id `{}` not found",
request.parent_version_id
)))
},
Ok,
)?;
let mut transaction = state
.database
.create_write_transaction()
.await
.map_err(server_error)?;
let last_update_id = state
.database
.get_max_update_id_in_vault(&vault_id, Some(&mut transaction))
.await
.map_err(server_error)?;
let latest_version = state
.database
.get_latest_document(&vault_id, &document_id, Some(&mut transaction))
.await
.map_err(server_error)?
.map_or_else(
|| {
Err(not_found_error(anyhow!(
"Document with id `{document_id}` not found",
)))
},
Ok,
)?;
let content_bytes = base64_to_bytes(&request.content_base64)
.context("Failed to decode base64 content in request")
.map_err(client_error)?;
// Return the latest version if the content and path are the same as the latest
// version
if content_bytes == latest_version.content
&& request.relative_path == latest_version.relative_path
{
info!("Document content is the same as the latest version, skipping update");
transaction
.rollback()
.await
.context("Failed to rollback transaction")
.map_err(server_error)?;
return Ok(Json(latest_version.into()));
}
let merged_content = merge(
&parent_document.content,
&latest_version.content,
&content_bytes,
)
.context("Failed to decode bytes as UTF-8")
.map_err(client_error)?;
// We can only update the relative path if we're the first one to do so
let new_relative_path = if parent_document.relative_path == latest_version.relative_path {
request.relative_path.clone()
} else {
latest_version.relative_path.clone()
};
let new_version = StoredDocumentVersion {
vault_id,
document_id,
vault_update_id: last_update_id + 1,
relative_path: new_relative_path,
content: merged_content,
created_date: request.created_date,
updated_date: chrono::Utc::now(),
is_deleted: latest_version.is_deleted,
};
state
.database
.insert_document_version(&new_version, Some(&mut transaction))
.await
.map_err(server_error)?;
transaction
.commit()
.await
.context("Failed to commit successful transaction")
.map_err(server_error)?;
Ok(Json(new_version.into()))
}

87
docs/.cspell.json Normal file
View file

@ -0,0 +1,87 @@
{
"version": "0.2",
"language": "en-GB",
"dictionaries": ["en-gb"],
"ignorePaths": ["node_modules", ".vitepress/dist", ".vitepress/cache", "package-lock.json"],
"words": [
"VaultLink",
"Obsidian",
"WebSocket",
"SQLite",
"codebase",
"CRDT",
"CRDTs",
"YAML",
"nginx",
"Caddy",
"Traefik",
"systemd",
"localhost",
"vaultlink",
"Axum",
"Tokio",
"SQLx",
"reconcile",
"postgresql",
"VitePress",
"markdownlint",
"filesystem",
"backend",
"frontend",
"macOS",
"CLI",
"API",
"JSON",
"HTTP",
"HTTPS",
"SSL",
"TLS",
"WSS",
"TCP",
"VPS",
"Docker",
"Github",
"Dockerfile",
"dockerignore",
"Rustup",
"PostgreSQL",
"UUID",
"CORS",
"HSTS",
"CI",
"CD",
"OpenSSL",
"README",
"config",
"submodule",
"repo",
"autocomplete",
"autoformat",
"dedupe",
"diff",
"grep",
"stdout",
"stderr",
"chmod",
"mkdir",
"rclone",
"uuidgen",
"letsencrypt",
"fullchain",
"privkey",
"schmelczer",
"Schmelczer",
"ghcr",
"keepalive",
"healthcheck",
"writable",
"Cloudant",
"Syncthing",
"cadvisor",
"Caddyfile",
"nodelay",
"websecure",
"certresolver",
"rootfs"
]
}

2
docs/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.vitepress/dist/
.vitepress/cache/

4
docs/.prettierignore Normal file
View file

@ -0,0 +1,4 @@
node_modules/
.vitepress/dist/
.vitepress/cache/
package-lock.json

19
docs/.prettierrc Normal file
View file

@ -0,0 +1,19 @@
{
"printWidth": 120,
"tabWidth": 4,
"useTabs": false,
"semi": false,
"singleQuote": false,
"trailingComma": "none",
"endOfLine": "lf",
"proseWrap": "preserve",
"overrides": [
{
"files": "*.md",
"options": {
"proseWrap": "preserve",
"printWidth": 120
}
}
]
}

View file

@ -0,0 +1,60 @@
import { defineConfig } from "vitepress"
export default defineConfig({
title: "VaultLink",
description: "Self-hosted real-time synchronisation for Obsidian",
base: "/vault-link/",
themeConfig: {
logo: "/logo.svg",
nav: [
{ text: "Home", link: "/" },
{ text: "Guide", link: "/guide/getting-started" },
{ text: "Architecture", link: "/architecture/" },
{ text: "GitHub", link: "https://github.com/schmelczer/vault-link" }
],
sidebar: [
{
text: "Introduction",
items: [
{ text: "What is VaultLink?", link: "/guide/what-is-vaultlink" },
{ text: "Getting Started", link: "/guide/getting-started" },
{ text: "Limitations", link: "/guide/limitations" },
{ text: "Comparison with Alternatives", link: "/guide/alternatives" }
]
},
{
text: "Setup",
items: [
{ text: "Server Setup", link: "/guide/server-setup" },
{ text: "Obsidian Plugin", link: "/guide/obsidian-plugin" },
{ text: "CLI Client", link: "/guide/cli-client" }
]
},
{
text: "Configuration",
items: [
{ text: "Server Configuration", link: "/config/server" },
{ text: "Authentication", link: "/config/authentication" },
{ text: "Advanced Options", link: "/config/advanced" }
]
},
{
text: "Architecture",
items: [
{ text: "Overview", link: "/architecture/" },
{ text: "Sync Algorithm", link: "/architecture/sync-algorithm" },
{ text: "Data Flow", link: "/architecture/data-flow" }
]
}
],
socialLinks: [{ icon: "github", link: "https://github.com/schmelczer/vault-link" }],
footer: {
message: "Released under the MIT License.",
copyright: "Copyright © 2024-present Andras Schmelczer"
},
search: {
provider: "local"
}
},
head: [["link", { rel: "icon", type: "image/svg+xml", href: "/vault-link/logo.svg" }]]
})

159
docs/README.md Normal file
View file

@ -0,0 +1,159 @@
# VaultLink Documentation
This directory contains the VaultLink documentation site built with [VitePress](https://vitepress.dev/).
## Development
### Prerequisites
- Node.js 18+
- npm
### Setup
```bash
cd docs
npm install
```
### Local Development
Start the development server with hot reload:
```bash
npm run dev
```
The site will be available at `http://localhost:5173/vault-link/`
### Build
Build the static site:
```bash
npm run build
```
Output will be in `.vitepress/dist/`
### Preview
Preview the built site:
```bash
npm run preview
```
### Format
Format all markdown and TypeScript files:
```bash
npm run format
```
Check formatting without making changes:
```bash
npm run format:check
```
### Spell Check
Check spelling (British English):
```bash
npm run spell
```
The spell checker enforces British English spellings (e.g., "synchronisation", "optimise", "behaviour").
## Deployment
The documentation is automatically deployed to GitHub Pages when changes are pushed to the `main` branch.
The deployment workflow is configured in `.github/workflows/deploy-docs.yml`.
## Structure
```
docs/
├── .vitepress/
│ └── config.ts # VitePress configuration
├── public/ # Static assets
│ └── logo.svg # VaultLink logo
├── guide/ # User guides
│ ├── what-is-vaultlink.md
│ ├── getting-started.md
│ ├── server-setup.md
│ ├── obsidian-plugin.md
│ └── cli-client.md
├── architecture/ # Architecture documentation
│ ├── index.md
│ ├── sync-algorithm.md
│ └── data-flow.md
├── config/ # Configuration reference
│ ├── server.md
│ ├── authentication.md
│ └── advanced.md
└── index.md # Home page
```
## Writing Documentation
### Language
All documentation uses **British English**. The spell checker enforces this in CI.
### Markdown Features
VitePress supports:
- GitHub Flavoured Markdown
- Custom containers (tip, warning, danger)
- Code syntax highlighting
- Mermaid diagrams
- Emoji :rocket:
### Custom Containers
```markdown
::: tip
This is a tip
:::
::: warning
This is a warning
:::
::: danger
This is a danger message
:::
```
### Code Blocks
````markdown
```bash
npm install
```
```yaml
server:
port: 3000
```
````
## Contributing
When adding new pages:
1. Create the markdown file in the appropriate directory
2. Add it to the sidebar in `.vitepress/config.ts`
3. Test locally with `npm run dev`
4. Submit a pull request
## License
MIT - Same as VaultLink

View file

@ -0,0 +1,553 @@
# Data Flow
How data flows through VaultLink, from client to server and back.
## Connection Lifecycle
### 1. Initial Connection
```mermaid
sequenceDiagram
participant C as Client
participant S as Server
participant DB as Database
C->>S: WebSocket connect
S->>S: Accept connection
C->>S: Auth message (token + vault)
S->>S: Validate token
S->>S: Check vault access
S-->>C: Auth success
Note over C,S: Connection established
```
**Steps**:
1. Client initiates WebSocket connection to server
2. Server accepts connection
3. Client sends authentication message with token and vault name
4. Server validates token against `config.yml`
5. Server checks if user has access to requested vault
6. Server responds with success or error
7. Connection is ready for syncing
### 2. Initial Sync
After authentication, the client performs initial synchronisation:
```mermaid
sequenceDiagram
participant C as Client
participant S as Server
participant DB as SQLite
C->>C: Scan local filesystem
C->>S: Request file list
S->>DB: Query all files
DB-->>S: File metadata
S-->>C: File list with versions
loop For each local file
C->>C: Check if file on server
alt File not on server
C->>S: Upload file
S->>DB: Store file + metadata
else File on server (different version)
C->>C: Compare versions
C->>S: Upload newer or merge
end
end
loop For each server file
C->>C: Check if file local
alt File not local
C->>S: Download file
S->>DB: Retrieve file
DB-->>S: File content
S-->>C: File content
C->>C: Write to disk
end
end
S-->>C: Sync complete message
```
**Process**:
1. Client scans local filesystem
2. Client requests file list from server
3. Server queries database and returns metadata
4. Client uploads missing or changed local files
5. Client downloads missing files from server
6. Server sends sync complete notification
### 3. Real-Time Synchronization
After initial sync, changes are pushed in real-time:
```mermaid
sequenceDiagram
participant FS as Filesystem
participant C1 as Client 1
participant S as Server
participant DB as Database
participant C2 as Client 2
FS->>C1: File changed (fs.watch)
C1->>C1: Read file content
C1->>S: Upload file
S->>DB: Store new version
S->>S: Apply OT if needed
S-->>C1: Upload ACK
S->>C2: File update notification
C2->>S: Download file
S->>DB: Retrieve file
DB-->>S: File content
S-->>C2: File content
C2->>FS: Write to disk
```
**Flow**:
1. Filesystem watcher detects local change
2. Client reads file content
3. Client uploads file via WebSocket
4. Server stores in database
5. Server applies operational transformation if concurrent edits
6. Server acknowledges upload to sender
7. Server broadcasts update to other clients
8. Other clients download and apply changes
## File Operations
### Upload
```
┌─────────┐
│ Client │
└───┬─-───┘
│ 1. Detect file change
├─► 2. Read file content
├─► 3. Create upload message
│ {
│ type: "upload_file",
│ path: "notes/daily.md",
│ content: "...",
│ version: 42,
│ timestamp: "2024-01-01T12:00:00Z"
│ }
┌─────────┐
│ Server │
└───┬────-┘
│ 4. Validate message
├─► 5. Check permissions
├─► 6. Apply OT (if conflicts)
├─► 7. Store in database
├─► 8. Update version
├─► 9. Broadcast to clients
└─► 10. Send ACK to uploader
```
### Download
```
┌─────────┐
│ Server │
└───┬─-───┘
│ 1. File updated by another client
├─► 2. Broadcast notification
│ {
│ type: "file_updated",
│ path: "notes/daily.md",
│ version: 43
│ }
┌─────────┐
│ Client │
└───┬─-───┘
│ 3. Receive notification
├─► 4. Request file download
│ {
│ type: "download_file",
│ path: "notes/daily.md",
│ version: 43
│ }
┌─────────┐
│ Server │
└───┬─=───┘
│ 5. Retrieve from database
└─► 6. Send file content
{
type: "file_content",
path: "notes/daily.md",
content: "...",
version: 43
}
┌─────────┐
│ Client │
└───-─┬───┘
│ 7. Write to filesystem
└─► 8. Update local metadata
```
### Delete
```
┌─────────┐
│ Client │
└────┬────┘
│ 1. File deleted locally
├─► 2. Send delete message
│ {
│ type: "delete_file",
│ path: "notes/old.md"
│ }
┌─────────┐
│ Server │
└────┬────┘
│ 3. Mark as deleted in DB
│ (soft delete for history)
├─► 4. Broadcast deletion
└─► 5. ACK to sender
┌─────────┐
│ Other │
│ Clients │
└────┬────┘
│ 6. Delete local file
└─► 7. Update metadata
```
## Conflict Resolution Flow
### Concurrent Edits Scenario
```
Time →
Client A Server Client B
│ │ │
│ Edit file v10 │ │
│ "Add line A" │ │ Edit file v10
│ │ │ "Add line B"
│ │ │
├─── Upload @ t1 ─────────►│ │
│ │◄────── Upload @ t2 ────────┤
│ │ │
│ │ 1. Receive both edits │
│ │ (based on v10) │
│ │ │
│ │ 2. Apply first edit │
│ │ → v11 (line A added) │
│ │ │
│ │ 3. Transform second edit │
│ │ against first │
│ │ │
│ │ 4. Apply transformed edit │
│ │ → v12 (both lines) │
│ │ │
│◄──── v12 content ────────┤ │
│ ├───── v12 content ─────────►│
│ │ │
│ Apply v12 │ │ Apply v12
│ (has both lines) │ │ (has both lines)
│ │ │
```
### Conflict Resolution Steps
1. **Detection**: Server receives two edits based on the same version
2. **Ordering**: Determine which edit to apply first (by timestamp or client ID)
3. **First edit**: Apply directly to database
4. **Transformation**: Transform second edit against first using OT
5. **Second edit**: Apply transformed edit to database
6. **Broadcast**: Send merged result to all clients
7. **Application**: Clients apply merged version locally
## Database Schema
### Core Tables
```sql
-- Document metadata
CREATE TABLE documents (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL,
version INTEGER NOT NULL,
content_hash TEXT,
size INTEGER,
created_at TIMESTAMP,
updated_at TIMESTAMP,
deleted BOOLEAN DEFAULT FALSE
);
-- Version history
CREATE TABLE versions (
id INTEGER PRIMARY KEY,
document_id INTEGER,
version INTEGER,
content BLOB,
created_at TIMESTAMP,
FOREIGN KEY (document_id) REFERENCES documents(id)
);
-- Client sync cursors
CREATE TABLE cursors (
client_id TEXT PRIMARY KEY,
last_version INTEGER,
last_updated TIMESTAMP
);
```
### Queries
**Get files since version**:
```sql
SELECT * FROM documents
WHERE version > ? AND deleted = FALSE
ORDER BY version ASC;
```
**Store new version**:
```sql
INSERT INTO versions (document_id, version, content, created_at)
VALUES (?, ?, ?, ?);
UPDATE documents
SET version = ?, updated_at = ?
WHERE id = ?;
```
**Update cursor**:
```sql
INSERT OR REPLACE INTO cursors (client_id, last_version, last_updated)
VALUES (?, ?, ?);
```
## Message Protocol
### Client → Server Messages
**Upload File**:
```json
{
"type": "upload_file",
"path": "notes/example.md",
"content": "File content here...",
"base_version": 10,
"timestamp": "2024-01-01T12:00:00Z"
}
```
**Download File**:
```json
{
"type": "download_file",
"path": "notes/example.md"
}
```
**Delete File**:
```json
{
"type": "delete_file",
"path": "notes/old.md"
}
```
**List Files**:
```json
{
"type": "list_files",
"since_version": 0
}
```
### Server → Client Messages
**File Updated**:
```json
{
"type": "file_updated",
"path": "notes/example.md",
"version": 11,
"size": 1024,
"hash": "abc123..."
}
```
**File Content**:
```json
{
"type": "file_content",
"path": "notes/example.md",
"content": "Updated content...",
"version": 11
}
```
**File Deleted**:
```json
{
"type": "file_deleted",
"path": "notes/old.md",
"version": 12
}
```
**Sync Complete**:
```json
{
"type": "sync_complete",
"total_files": 150,
"current_version": 200
}
```
**Error**:
```json
{
"type": "error",
"message": "File too large",
"code": "FILE_TOO_LARGE"
}
```
## Error Handling
### Client-Side Errors
**Network failure**:
1. Detect WebSocket disconnect
2. Queue pending operations
3. Retry connection with exponential backoff
4. Replay queued operations on reconnect
**File read error**:
1. Log error
2. Skip file
3. Continue with other files
4. Report to user
**Write conflict**:
1. Receive updated version from server
2. Apply OT merge locally
3. Overwrite local file
4. Continue syncing
### Server-Side Errors
**Database error**:
1. Log error
2. Return error to client
3. Client retries operation
**Invalid operation**:
1. Validate message format
2. Return specific error code
3. Client handles error appropriately
**Authentication failure**:
1. Reject connection
2. Send auth error
3. Client prompts for new credentials
## Performance Optimizations
### Batching
- Small, rapid changes are batched together
- Reduces message overhead
- Applied as single atomic update
### Compression
- Large files compressed before transmission
- Reduces bandwidth usage
- Transparent to application layer
### Incremental Sync
- Only changed portions of files sent
- Uses content-based diffing
- Significantly reduces data transfer
### Caching
- Server caches recent file versions
- Reduces database queries
- Improves response time
## Monitoring Data Flow
### Server Logs
```
2024-01-01 12:00:00 INFO WebSocket connection from 192.168.1.100
2024-01-01 12:00:01 INFO User 'alice' authenticated for vault 'personal'
2024-01-01 12:00:05 INFO Upload: notes/daily.md (v10 -> v11)
2024-01-01 12:00:06 INFO Broadcast to 3 clients
2024-01-01 12:00:10 INFO Conflict resolved: notes/shared.md (v12)
```
### Client Logs
```
2024-01-01 12:00:00 INFO Connecting to ws://sync.example.com
2024-01-01 12:00:01 INFO Connected, authenticating...
2024-01-01 12:00:01 INFO Authentication successful
2024-01-01 12:00:02 INFO Starting initial sync
2024-01-01 12:00:10 INFO Sync complete: 150 files, 200 MB
2024-01-01 12:00:15 INFO Uploaded: notes/daily.md
2024-01-01 12:00:20 INFO Downloaded: notes/shared.md (merged)
```
## Next Steps
- [Understand the sync algorithm →](/architecture/sync-algorithm)
- [Configure the server →](/config/server)
- [Deploy VaultLink →](/guide/getting-started)

334
docs/architecture/index.md Normal file
View file

@ -0,0 +1,334 @@
# Architecture Overview
Central sync server with multiple clients. High-level architecture and design decisions.
## System Components
```
┌─────────────────────────────────────────────────────────────┐
│ Clients │
├─────────────────────┬───────────────────┬───────────────────┤
│ Obsidian Plugin │ Obsidian Plugin │ CLI Client │
│ (User A - Device1) │ (User A - Device2│ (Server/Backup) │
└──────────┬──────────┴─────────┬─────────┴──────────┬────────┘
│ │ │
│ WebSocket │ WebSocket │ WebSocket
│ │ │
└────────────────────┼────────────────────┘
┌───────────▼───────────┐
│ Sync Server │
│ (Rust + Axum) │
│ │
│ ┌─────────────────┐ │
│ │ WebSocket Hub │ │
│ └────────┬────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ Sync Engine │ │
│ │ (OT Algorithm) │ │
│ └────────┬────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ SQLite Database │ │
│ │ (Per Vault) │ │
│ └─────────────────┘ │
└───────────────────────┘
```
## Core Components
### Sync Server
Central authority for synchronisation. Rust + Axum framework.
**Responsibilities**:
- Accept WebSocket connections from clients
- Authenticate users via token-based auth
- Store document versions in SQLite
- Coordinate real-time updates between clients
- Apply operational transformation for conflict resolution
- Manage vault access control
**Technology**:
- **Language**: Rust 1.92+
- **Framework**: Axum (async web framework)
- **Database**: SQLite with SQLx
- **Protocol**: WebSockets for real-time communication
- **Sync Algorithm**: reconcile-text (operational transformation)
### Sync Client Library
TypeScript library with core sync logic. Used by Obsidian plugin and CLI client.
**Responsibilities**:
- Manage WebSocket connection to server
- Watch local filesystem for changes
- Upload and download files
- Apply remote changes locally
- Handle conflict resolution
- Maintain sync metadata
**Technology**:
- **Language**: TypeScript
- **Build**: Webpack
- **Protocol**: WebSocket client
- **File System**: Node.js `fs` API / Obsidian API
### Obsidian Plugin
Integration layer between sync client and Obsidian.
**Responsibilities**:
- Provide UI for configuration
- Bridge sync client with Obsidian's file system API
- Handle Obsidian lifecycle events
- Display sync status to users
**Technology**:
- **Platform**: Obsidian Plugin API
- **Core**: sync-client library
- **UI**: Obsidian settings UI
### CLI Client
Standalone executable for syncing vaults without Obsidian.
**Responsibilities**:
- Command-line interface
- File system access via Node.js
- Daemon mode for continuous sync
- Health check endpoint for monitoring
**Technology**:
- **Language**: TypeScript
- **Runtime**: Node.js
- **CLI**: Commander.js
- **Core**: sync-client library
## Data Flow
### Initial Connection
1. Client connects via WebSocket to server
2. Server authenticates using provided token
3. Server verifies user has access to requested vault
4. Connection established, sync begins
### File Upload Flow
```
Client Server
│ │
│ 1. File changed locally │
│ │
│ 2. Read file content │
│ │
│ 3. WebSocket: Upload file │
├──────────────────────────────►│
│ │ 4. Store in SQLite
│ │
│ │ 5. Broadcast to other clients
│ ├───────────────────────►
│ 6. Ack upload │
│◄──────────────────────────────┤
```
### File Download Flow
```
Client A Server Client B
│ │ │
│ │ 1. File uploaded │
│ │◄────────────────────────┤
│ │ │
│ │ 2. Store in DB │
│ │ │
│ 3. Push notification │ │
│◄────────────────────────┤ │
│ │ │
│ 4. Download file │ │
├────────────────────────►│ │
│ │ │
│ 5. Write locally │ │
│ │ │
```
### Conflict Resolution
When two clients edit the same file simultaneously:
```
Client A Server Client B
│ │ │
│ 1. Edit file │ │ 1. Edit same file
│ │ │
│ 2. Upload changes │ │ 2. Upload changes
├────────────────────────►│◄────────────────────────┤
│ │ │
│ │ 3. Apply OT algorithm │
│ │ - Merge both edits │
│ │ - Preserve all changes│
│ │ │
│ 4. Receive merged ver. │ 5. Receive merged ver. │
│◄────────────────────────┤────────────────────────►│
│ │ │
│ 6. Apply locally │ │ 6. Apply locally
```
## Storage Architecture
### Server Storage
Each vault has its own SQLite database:
```
databases/
├── vault-1.db
├── vault-2.db
└── shared-team.db
```
**Database Schema** (simplified):
- **documents**: File metadata (path, size, modified time)
- **versions**: Document content with version history
- **cursors**: Client sync state
### Client Storage
Clients maintain sync metadata:
```
.vaultlink/
├── metadata.json # Sync state
└── cache/ # Optional local cache
```
The `.vaultlink` directory tracks which files have been synced and their versions to enable efficient synchronisation.
## Communication Protocol
### WebSocket Messages
Client-server communication uses JSON messages over WebSocket.
**Message Types**:
- `upload_file`: Client → Server (file upload)
- `download_file`: Client → Server (request file)
- `file_updated`: Server → Client (file changed notification)
- `file_deleted`: Server → Client (file deleted notification)
- `sync_complete`: Server → Client (initial sync finished)
### Authentication
Token-based authentication on connection:
```typescript
// Client sends token on connect
{
type: "auth",
token: "user-auth-token",
vault: "vault-name"
}
// Server responds
{
type: "auth_success"
}
// or
{
type: "auth_error",
message: "Invalid token"
}
```
## Scalability Considerations
### Current Architecture
- **SQLite per vault**: Simple, performant, limited to single server
- **WebSocket connections**: Stateful, requires sticky sessions for load balancing
- **Operational transformation**: Centralized on server
### Scaling Approaches
**Vertical Scaling**:
- Increase server resources (CPU, RAM, storage)
- Optimize database queries and indexing
- Tune connection limits
**Horizontal Scaling** (future):
- Separate vault servers (vault sharding)
- Load balancer with sticky sessions
- Shared storage layer for SQLite databases
- Consider alternative databases (PostgreSQL) for multi-server setups
### Performance Characteristics
- **Small vaults** (< 1000 files): Excellent performance
- **Medium vaults** (1000-10000 files): Good performance with tuning
- **Large vaults** (> 10000 files): May require optimisation
- **Concurrent users**: Tested with dozens of simultaneous clients per vault
## Security Model
### Authentication
- Token-based authentication
- Tokens configured in server `config.yml`
- No password hashing (tokens are secrets)
### Authorization
- Per-user vault access control
- Allow-list or deny-list patterns
- Global access or vault-specific access
### Network Security
- WebSocket over TLS (WSS) for encrypted transport
- No built-in SSL (use reverse proxy)
- CORS configured for web clients
### Data Security
- No encryption at rest (use encrypted filesystems if needed)
- No end-to-end encryption (server sees all content)
- Self-hosted model: you control the data
## Technology Choices
**Rust**: Low latency, memory safe, excellent async with Tokio, compile-time SQL verification
**SQLite**: No separate database server, fast for reads, single file per vault, backups are file copies
**WebSocket**: Bidirectional push, no polling overhead, built-in browser/Node.js support
**Operational Transformation**: Automatic conflict resolution, preserves all edits, real-time collaboration
## Design Principles
1. **Self-hosted first**: Users control their data and infrastructure
2. **Simplicity**: Easy to deploy and operate
3. **Real-time**: Changes appear immediately
4. **Reliability**: Handle network failures gracefully
5. **Performance**: Fast sync for typical vault sizes
6. **Privacy**: No third-party services or telemetry
## Next Steps
- [Learn about the sync algorithm →](/architecture/sync-algorithm)
- [Understand data flow in detail →](/architecture/data-flow)
- [Deploy the server →](/guide/server-setup)

View file

@ -0,0 +1,438 @@
# Sync Algorithm
VaultLink uses operational transformation (OT) to handle concurrent edits and maintain consistency across clients.
## Operational Transformation
Operational transformation is a technique for managing concurrent edits to the same document. It transforms operations (edits) so they can be applied in different orders while preserving user intent.
### Why OT?
Traditional conflict resolution approaches:
- **Last write wins**: Loses data, frustrating for users
- **Manual merging**: Interrupts workflow, requires user intervention
- **Version branching**: Complex, not suitable for real-time sync
Operational transformation:
- **Automatic**: No user intervention required
- **Preserves all edits**: No data loss
- **Real-time**: Changes appear immediately
- **Intuitive**: Behaviour matches user expectations
## The reconcile-text Library
VaultLink uses the [`reconcile-text`](https://crates.io/crates/reconcile-text) Rust library for operational transformation on text documents.
### Why reconcile-text over CRDTs?
VaultLink faces a **differential synchronisation** challenge: users edit Obsidian vaults with various editors (Obsidian desktop, Obsidian mobile, Vim, VS Code, or any text editor), often while offline. This means we only observe the **final state** of each document after editing, not the individual keystrokes or operations that produced it.
**The fundamental problem**:
- **CRDTs and traditional OT** require capturing every individual operation (each character insertion, deletion, cursor movement)
- **VaultLink's reality**: Users edit files with arbitrary tools, sync happens after the fact
- **What we know**: Parent version and two modified versions
- **What we don't know**: The sequence of operations that created those modifications
**Why reconcile-text wins for this use case**:
1. **Works with end states only**: reconcile-text performs conflict-free 3-way merging given just parent, left, and right versions—no operation history needed
2. **Editor-agnostic**: Users can edit with any tool without requiring VaultLink-specific plugins or operation tracking
3. **Offline-first**: Edits made while disconnected are merged cleanly when sync resumes, because we're diffing final states rather than replaying operations
4. **No conflict markers**: Unlike Git merge, produces clean merged output without `<<<<<<<` markers that interrupt note-taking flow
5. **Human text forgiveness**: For knowledge bases and documentation, a slightly imperfect merge (e.g., minor word order issues) is vastly preferable to manual conflict resolution
6. **Simpler infrastructure**: No need for complex operation capture, transformation logs, or tombstone management that CRDTs require
**The trade-off**:
CRDTs excel when you control the entire editing infrastructure and can capture every operation. reconcile-text excels when you're synchronising independently-edited files—exactly VaultLink's scenario. The merge quality depends on Myers' diff algorithm rather than operation history, which is the correct trade-off for differential sync.
For note-taking workflows where users value editor freedom and offline editing, this approach provides superior user experience compared to either CRDTs (which would require operation tracking) or Git-style merging (which requires manual conflict resolution).
[Learn more about reconcile-text →](https://schmelczer.dev/reconcile)
### How It Works
Given three versions (parent, left, right), reconcile-text produces a merged result.
**How reconcile-text works**:
1. **Tokenisation**: Split text into words (using `BuiltinTokenizer::Word`)
2. **Three-way diff**: Compare parent→left and parent→right changes
3. **Merge**: Combine non-conflicting changes, prefer content preservation for conflicts
4. **Result**: Merged text with both edits applied
**Example**:
```
Parent: "The quick brown fox"
User A: "The quick red fox" (changes "brown" → "red")
User B: "The very quick brown fox" (inserts "very ")
Merged: "The very quick red fox" (both changes applied)
```
**Merge conditions**: Only `.md` and `.txt` files with valid UTF-8 get merged. Binary files or other extensions use last-write-wins.
### Operation Types
The algorithm handles these operations:
- **Insert**: Add text at position
- **Delete**: Remove text from position
- **Retain**: Keep existing text unchanged
### Transformation Process
1. **Client A** makes edit and sends to server
2. **Client B** makes concurrent edit and sends to server
3. **Server** receives both edits
4. **Server** transforms operations to account for concurrent changes
5. **Server** applies merged result to database
6. **Server** sends transformed operations to both clients
7. **Clients** apply transformed operations locally
## Sync State Management
VaultLink maintains sync state to track which changes have been applied.
### Version Vectors
Each document has a version tracked by:
- **Server version**: Incremented on each change
- **Client cursors**: Track which version each client has seen
This enables:
- Efficient syncing (only send changes since last sync)
- Conflict detection (concurrent edits to same version)
- Ordering of operations
### Cursor Management
Clients maintain a cursor position:
```rust
struct Cursor {
vault_id: String,
client_id: String,
last_version: u64,
last_updated: DateTime,
}
```
On sync:
1. Client sends cursor (last seen version)
2. Server returns all changes since that version
3. Client applies changes and updates cursor
## Conflict Resolution Flow
### Scenario: Concurrent Edits
Two users edit the same paragraph simultaneously.
**Initial state**:
```
Version 10: "The quick brown fox jumps over the lazy dog."
```
**User A's edit** (version 11):
```
"The quick brown fox jumps over the very lazy dog."
```
_Inserts "very " at position 40_
**User B's edit** (also from version 10):
```
"The quick red fox jumps over the lazy dog."
```
_Replaces "brown" with "red" at position 10_
### Server Processing
1. **Receive User A's operation**:
- Base: version 10
- Operation: Insert("very ", position=40)
- Apply to database → version 11
2. **Receive User B's operation**:
- Base: version 10
- Operation: Replace("brown"→"red", position=10)
- **Conflict detected**: Base is version 10, but current is version 11
3. **Transform User B's operation**:
- Transform against User A's operation
- Adjust positions/content as needed
- Apply transformed operation → version 12
4. **Broadcast updates**:
- Send User A's operation to User B
- Send transformed User B's operation to User A
### Final Result
```
Version 12: "The quick red fox jumps over the very lazy dog."
```
Both edits are preserved in the final document.
## Edge Cases
### 1. Delete vs Insert Conflict
**Scenario**: User A deletes a paragraph while User B edits it.
**Resolution**:
- OT algorithm prioritizes preservation of content
- Insert operation is transformed to account for deletion
- Typically results in inserted content appearing nearby
**Example**:
```
Base: "Line 1\nLine 2\nLine 3"
User A: Delete Line 2 → "Line 1\nLine 3"
User B: Edit Line 2 → "Line 1\nLine 2 modified\nLine 3"
Result: "Line 1\nLine 2 modified\nLine 3"
```
(Insert takes precedence, preserving user content)
### 2. Overlapping Edits
**Scenario**: Two users edit overlapping regions.
**Resolution**:
- OT splits operations into non-overlapping segments
- Applies each segment independently
- Merges results
### 3. Delete vs Delete
**Scenario**: Two users delete overlapping text.
**Resolution**:
- Deletes are merged
- Final result has the union of deleted ranges removed
### 4. Network Partitions
**Scenario**: Client loses connection, makes edits offline, reconnects.
**Resolution**:
1. Client queues edits locally
2. On reconnect, sends all queued operations
3. Server applies OT against all operations that happened during partition
4. Client receives transformed operations and applies
## Performance Characteristics
### Time Complexity
- **Single operation**: O(1) for most operations
- **Transformation**: O(n) where n is operation size
- **Conflict resolution**: O(m × n) where m is number of concurrent operations
### Space Complexity
- **Version history**: Grows with number of changes
- **Cursors**: O(clients × vaults)
- **Active operations**: Minimal (processed in real-time)
### Optimisation
VaultLink optimises for:
- Small, frequent edits (typical typing patterns)
- Text documents (not binary files)
- Real-time processing (no batching delay)
## Limitations
### Binary and Non-Mergeable Files
Only **`.md`** and **`.txt`** files get automatic merging. Everything else uses last-write-wins.
**Binary detection**:
- Files with NUL bytes (`0x00`)
- Files failing UTF-8 validation
Even `.md` files are treated as binary if they fail UTF-8 checks.
**Last-write-wins behaviour**:
```
User A uploads image.png → Server version 1
User B uploads image.png → Server version 2 (A's upload lost)
```
**Workaround**: Avoid concurrent edits to non-text files. [See all limitations →](/guide/limitations)
### Large Documents
Very large documents (> 1MB) may have:
- Higher transformation costs
- Slower sync times
- Increased memory usage
**Workaround**: Split large documents or increase timeout settings.
### Complex Formatting
Markdown with complex structures may occasionally produce unexpected results:
- Nested lists
- Tables
- Code blocks
**Workaround**: Manual cleanup if needed, or minimize concurrent edits to complex structures.
## Consistency Guarantees
### Strong Consistency
VaultLink provides **strong eventual consistency**:
- All clients eventually converge to the same state
- Operations applied in causal order
- No data loss under normal operation
### Ordering Guarantees
- Operations from the same client are applied in order
- Concurrent operations may be applied in any order
- Final result is independent of operation order (commutative)
### Durability
- Operations are written to SQLite before acknowledgment
- SQLite ACID guarantees protect against data loss
- Clients retry failed uploads
## Comparison with Other Approaches
### Git-style Merging
| Aspect | Git Merge | VaultLink OT |
| -------------------------- | ------------ | ----------------------- |
| Real-time | No | Yes |
| Manual conflict resolution | Yes | No |
| Branching | Yes | No |
| Automatic merge | Limited | Always |
| Use case | Code changes | Collaborative documents |
### CRDTs (Conflict-free Replicated Data Types)
| Aspect | CRDTs | VaultLink (reconcile-text) |
| ----------------------------- | ------------------------------------ | ------------------------------------------------- |
| **Operation tracking** | Required (every keystroke) | Not required (end states only) |
| **Editor freedom** | Limited (must use CRDT-aware editor) | Unlimited (any text editor works) |
| **Offline editing** | Requires operation log | Works with file comparison |
| **Server required** | No | Yes |
| **Memory overhead** | Higher (tombstones, metadata) | Lower (versions only) |
| **Infrastructure complexity** | Higher | Lower |
| **Best for** | Controlled editing environments | Independent file editing (Obsidian, Vim, VS Code) |
**Key insight**: CRDTs are superior when you can capture every operation. reconcile-text is superior when users edit files independently with arbitrary tools—exactly VaultLink's scenario.
### Last Write Wins
| Aspect | LWW | VaultLink OT |
| --------------- | ---- | ------------ |
| Data loss | Yes | No |
| Simplicity | High | Medium |
| User experience | Poor | Excellent |
| Performance | Best | Good |
## Algorithm Details
### Transformation Rules
When transforming operation `A` against operation `B`:
1. **Insert vs Insert**:
- If positions equal: Order by client ID
- If different positions: Adjust positions
2. **Insert vs Delete**:
- If insert in deleted range: Shift insert position
- If insert after delete: Adjust position by deleted length
3. **Delete vs Delete**:
- If ranges overlap: Merge delete ranges
- If ranges disjoint: Adjust positions
4. **Retain vs Any**:
- Retain operations don't conflict
- Simply adjust positions
### Transformation Example
```rust
// Pseudo-code for transformation
fn transform(op_a: Operation, op_b: Operation) -> (Operation, Operation) {
match (op_a, op_b) {
(Insert(pos_a, text_a), Insert(pos_b, text_b)) => {
if pos_a < pos_b {
(op_a, Insert(pos_b + text_a.len(), text_b))
} else if pos_a > pos_b {
(Insert(pos_a + text_b.len(), text_a), op_b)
} else {
// Same position, use client ID to break tie
if client_id_a < client_id_b {
(op_a, Insert(pos_b + text_a.len(), text_b))
} else {
(Insert(pos_a + text_b.len(), text_a), op_b)
}
}
}
// ... other cases
}
}
```
## Best Practices
### For Smooth Collaboration
1. **Small edits**: Make small, focused changes for easier merging
2. **Coordinate major changes**: Discuss large refactors with team
3. **Monitor sync status**: Ensure changes are uploaded before signing off
4. **Test conflict resolution**: Verify behaviour matches expectations
### For Developers
1. **Text files preferred**: OT works best on text
2. **Limit file sizes**: Keep documents reasonably sized
3. **Binary files**: Use versioning or avoid concurrent edits
4. **Testing**: Test concurrent edit scenarios thoroughly
## Further Reading
- [reconcile-text library](https://crates.io/crates/reconcile-text)
- [Operational Transformation FAQ](https://en.wikipedia.org/wiki/Operational_transformation)
- [Data flow architecture →](/architecture/data-flow)

603
docs/config/advanced.md Normal file
View file

@ -0,0 +1,603 @@
# Advanced Configuration
Advanced topics for optimising and customising your VaultLink deployment.
## Database Optimisation
### SQLite Tuning
While VaultLink handles most SQLite configuration automatically, you can optimise for specific workloads.
#### WAL Mode
VaultLink uses Write-Ahead Logging (WAL) mode by default for better concurrency.
**Benefits**:
- Readers don't block writers
- Writers don't block readers
- Better performance for concurrent access
**Maintenance**:
```bash
# Checkpoint WAL to main database (run periodically)
sqlite3 databases/vault.db "PRAGMA wal_checkpoint(TRUNCATE);"
```
#### Database Size Management
Over time, databases can grow with version history:
```bash
# Check database size
du -h databases/*.db
# Vacuum to reclaim space (offline only)
sqlite3 databases/vault.db "VACUUM;"
# Analyse for query optimisation
sqlite3 databases/vault.db "ANALYZE;"
```
**Schedule maintenance**:
```bash
#!/bin/bash
# monthly-maintenance.sh
for db in databases/*.db; do
echo "Optimising $db"
sqlite3 "$db" "PRAGMA optimize;"
sqlite3 "$db" "PRAGMA wal_checkpoint(TRUNCATE);"
done
```
### Version History Cleanup
VaultLink stores **all versions indefinitely** by default. Database grows with every change.
**Database schema**: Each version stored in `documents` table with `vault_update_id` (sequential).
Manual cleanup (keep last 100 versions per document):
```bash
#!/bin/bash
# prune-old-versions.sh
for db in databases/*.db; do
sqlite3 "$db" <<EOF
DELETE FROM documents
WHERE vault_update_id NOT IN (
SELECT vault_update_id FROM documents d2
WHERE d2.document_id = documents.document_id
ORDER BY vault_update_id DESC
LIMIT 100
);
EOF
done
```
**Warning**: This deletes old versions permanently. No undo.
Run monthly via cron:
```bash
0 3 1 * * /opt/vaultlink/prune-old-versions.sh
```
## Performance Tuning
### Connection Pool Sizing
Calculate optimal `max_connections_per_vault`:
```
max_connections = (concurrent_users × avg_operations_per_user) + buffer
```
**Example**:
- 20 concurrent users
- 2 operations per user on average
- 25% buffer
```
max_connections = (20 × 2) × 1.25 = 50
```
### Timeout Configuration
Adjust timeouts based on network characteristics:
**Fast local network**:
```yaml
database:
cursor_timeout_seconds: 30
server:
response_timeout_seconds: 30
```
**Slow or unreliable network**:
```yaml
database:
cursor_timeout_seconds: 180
server:
response_timeout_seconds: 120
```
**Mobile clients**:
```yaml
database:
cursor_timeout_seconds: 300 # Longer for intermittent connections
server:
response_timeout_seconds: 180
```
## Reverse Proxy Configuration
### Nginx with SSL
Complete Nginx configuration for production:
```nginx
# Rate limiting
limit_req_zone $binary_remote_addr zone=vaultlink:10m rate=10r/s;
upstream vaultlink {
server localhost:3000;
keepalive 32;
}
server {
listen 443 ssl http2;
server_name sync.example.com;
ssl_certificate /etc/letsencrypt/live/sync.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/sync.example.com/privkey.pem;
# SSL security settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# HSTS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Rate limiting
limit_req zone=vaultlink burst=20 nodelay;
# Client body size (match server config)
client_max_body_size 512M;
# Timeouts
proxy_connect_timeout 90s;
proxy_send_timeout 90s;
proxy_read_timeout 3600s; # WebSocket long-lived connections
# WebSocket headers
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Disable buffering for WebSocket
proxy_buffering off;
location / {
proxy_pass http://vaultlink;
}
# Health check endpoint (use any vault name)
location /health {
proxy_pass http://vaultlink/vaults/test/ping;
access_log off;
}
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name sync.example.com;
return 301 https://$server_name$request_uri;
}
```
### Caddy with Auto SSL
Caddy handles SSL automatically:
```caddy
sync.example.com {
reverse_proxy localhost:3000 {
# WebSocket support
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
# Timeouts
transport http {
read_timeout 3600s
write_timeout 90s
}
}
# Rate limiting (requires caddy-rate-limit plugin)
rate_limit {
zone dynamic {
match {
remote_ip
}
rate 10r/s
burst 20
}
}
}
```
### Traefik Configuration
Using Docker labels:
```yaml
services:
vaultlink-server:
image: ghcr.io/schmelczer/vault-link-server:latest
labels:
- "traefik.enable=true"
- "traefik.http.routers.vaultlink.rule=Host(`sync.example.com`)"
- "traefik.http.routers.vaultlink.entrypoints=websecure"
- "traefik.http.routers.vaultlink.tls.certresolver=letsencrypt"
- "traefik.http.services.vaultlink.loadbalancer.server.port=3000"
# Middleware for timeouts
- "traefik.http.middlewares.vaultlink-timeout.timeout.request=3600s"
```
## Docker Optimizations
### Resource Limits
Limit container resources:
```yaml
services:
vaultlink-server:
image: ghcr.io/schmelczer/vault-link-server:latest
deploy:
resources:
limits:
cpus: "2.0"
memory: 4G
reservations:
cpus: "1.0"
memory: 2G
```
### Logging Configuration
Optimize Docker logging:
```yaml
services:
vaultlink-server:
image: ghcr.io/schmelczer/vault-link-server:latest
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "5"
```
### Volume Optimization
Use named volumes for better performance:
```yaml
services:
vaultlink-server:
image: ghcr.io/schmelczer/vault-link-server:latest
volumes:
- vaultlink-data:/data
- vaultlink-logs:/data/logs
volumes:
vaultlink-data:
driver: local
driver_opts:
type: none
o: bind
device: /mnt/fast-ssd/vaultlink
vaultlink-logs:
driver: local
```
## High Availability
### Health Checks
Comprehensive health monitoring:
```yaml
services:
vaultlink-server:
image: ghcr.io/schmelczer/vault-link-server:latest
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/vaults/test/ping || exit 1"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
```
Monitor health in production:
```bash
#!/bin/bash
# health-monitor.sh
while true; do
if ! curl -sf http://localhost:3000/vaults/test/ping > /dev/null; then
echo "Health check failed at $(date)" | mail -s "VaultLink Down" admin@example.com
# Optionally restart
# docker restart vaultlink-server
fi
sleep 30
done
```
### Backup Automation
Automated backup script:
```bash
#!/bin/bash
# backup-vaultlink.sh
BACKUP_DIR="/backup/vaultlink"
DATA_DIR="/data"
DATE=$(date +%Y%m%d-%H%M%S)
RETENTION_DAYS=30
# Create backup directory
mkdir -p "$BACKUP_DIR/$DATE"
# Backup databases (with WAL checkpoint)
for db in "$DATA_DIR"/databases/*.db; do
sqlite3 "$db" "PRAGMA wal_checkpoint(TRUNCATE);"
cp "$db" "$BACKUP_DIR/$DATE/"
[ -f "${db}-wal" ] && cp "${db}-wal" "$BACKUP_DIR/$DATE/"
[ -f "${db}-shm" ] && cp "${db}-shm" "$BACKUP_DIR/$DATE/"
done
# Backup configuration
cp "$DATA_DIR/config.yml" "$BACKUP_DIR/$DATE/"
# Compress backup
tar -czf "$BACKUP_DIR/vaultlink-$DATE.tar.gz" -C "$BACKUP_DIR" "$DATE"
rm -rf "$BACKUP_DIR/$DATE"
# Clean old backups
find "$BACKUP_DIR" -name "vaultlink-*.tar.gz" -mtime +$RETENTION_DAYS -delete
# Upload to remote storage (optional)
# rclone copy "$BACKUP_DIR/vaultlink-$DATE.tar.gz" remote:backups/
```
Schedule with cron:
```cron
0 2 * * * /opt/vaultlink/backup-vaultlink.sh
```
### Restore from Backup
```bash
#!/bin/bash
# restore-vaultlink.sh
BACKUP_FILE="$1"
DATA_DIR="/data"
if [ -z "$BACKUP_FILE" ]; then
echo "Usage: $0 <backup-file.tar.gz>"
exit 1
fi
# Stop server
docker stop vaultlink-server
# Extract backup
tar -xzf "$BACKUP_FILE" -C /tmp/
BACKUP_DATE=$(basename "$BACKUP_FILE" .tar.gz | cut -d- -f2-)
# Restore databases
cp /tmp/"$BACKUP_DATE"/databases/*.db "$DATA_DIR/databases/"
# Restore config (careful!)
# cp /tmp/$BACKUP_DATE/config.yml "$DATA_DIR/"
# Cleanup
rm -rf /tmp/"$BACKUP_DATE"
# Start server
docker start vaultlink-server
echo "Restore complete. Check server logs."
```
## Monitoring and Metrics
### Prometheus Metrics
While VaultLink doesn't expose metrics natively, monitor Docker:
```yaml
# docker-compose.yml
services:
vaultlink-server:
image: ghcr.io/schmelczer/vault-link-server:latest
labels:
- "prometheus.io/scrape=true"
- "prometheus.io/port=3000"
cadvisor:
image: gcr.io/cadvisor/cadvisor:latest
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
ports:
- 8080:8080
```
### Log Analysis
Analyze logs for insights:
```bash
# Most active users
grep "authenticated" logs/*.log | cut -d"'" -f2 | sort | uniq -c | sort -rn
# Failed authentications by IP
grep "Authentication failed" logs/*.log | grep -oP '\d+\.\d+\.\d+\.\d+' | sort | uniq -c | sort -rn
# Upload activity
grep "Upload:" logs/*.log | wc -l
# Average files per vault
grep "Sync complete" logs/*.log | grep -oP '\d+ files' | cut -d' ' -f1 | awk '{sum+=$1; count++} END {print sum/count}'
```
### Alerting
Simple alerting with cron:
```bash
#!/bin/bash
# alert-errors.sh
ERROR_THRESHOLD=10
ERROR_COUNT=$(grep -c "ERROR" logs/latest.log)
if [ "$ERROR_COUNT" -gt "$ERROR_THRESHOLD" ]; then
echo "VaultLink has $ERROR_COUNT errors in the last hour" | \
mail -s "VaultLink Alert" admin@example.com
fi
```
## Security Hardening
### Network Isolation
Run VaultLink in isolated network:
```yaml
services:
vaultlink-server:
image: ghcr.io/schmelczer/vault-link-server:latest
networks:
- vaultlink-internal
- proxy-external
networks:
vaultlink-internal:
internal: true
proxy-external:
driver: bridge
```
### Read-Only Root Filesystem
Run with read-only root (mount writable volumes for data):
```yaml
services:
vaultlink-server:
image: ghcr.io/schmelczer/vault-link-server:latest
read_only: true
volumes:
- ./data:/data
- /tmp
```
### Drop Capabilities
Run with minimal privileges:
```yaml
services:
vaultlink-server:
image: ghcr.io/schmelczer/vault-link-server:latest
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
```
## Migration
### Moving to New Server
1. **Backup on old server**:
```bash
./backup-vaultlink.sh
```
2. **Transfer backup**:
```bash
scp vaultlink-backup.tar.gz new-server:/tmp/
```
3. **Restore on new server**:
```bash
./restore-vaultlink.sh /tmp/vaultlink-backup.tar.gz
```
4. **Update DNS/clients** to point to new server
5. **Verify sync** on all clients
### Version Upgrades
```bash
# Pull latest image
docker pull ghcr.io/schmelczer/vault-link-server:latest
# Backup first
./backup-vaultlink.sh
# Stop old container
docker stop vaultlink-server
docker rm vaultlink-server
# Start with new image
docker run -d \
--name vaultlink-server \
--restart unless-stopped \
-p 3000:3000 \
-v ./data:/data \
ghcr.io/schmelczer/vault-link-server:latest \
/app/sync_server /data/config.yml
# Check logs
docker logs -f vaultlink-server
```
## Next Steps
- [Understand the architecture →](/architecture/)
- [Deploy the server →](/guide/server-setup)
- [Configure clients →](/guide/obsidian-plugin)

View file

@ -0,0 +1,558 @@
# Authentication Configuration
VaultLink uses token-based authentication with per-user vault access control. This guide covers all authentication and authorization options.
## Overview
Authentication in VaultLink:
- **Token-based**: Users authenticate with secure tokens
- **Configured in YAML**: All users defined in `config.yml`
- **Vault-level access**: Control which vaults each user can access
- **No password hashing**: Tokens are treated as secrets
## Basic Configuration
```yaml
users:
user_configs:
- name: alice
token: alice-secure-token-here
vault_access:
type: allow_access_to_all
```
## User Configuration Fields
### `name`
**Type**: String
**Required**: Yes
Human-readable identifier for the user. Used in logs and auditing.
```yaml
- name: alice
```
**Notes**:
- Must be unique across all users
- Used for identification only, not authentication
- Appears in server logs
- Can be any string (e.g., email, username)
### `token`
**Type**: String
**Required**: Yes
Authentication token for the user. Must be kept secret.
```yaml
- token: 1a2b3c4d5e6f7g8h9i0j...
```
**Best practices**:
- Generate with: `openssl rand -hex 32`
- Minimum length: 32 characters
- Use different token per user
- Never commit to version control
- Rotate periodically
**Example token generation**:
```bash
# Generate a secure token
openssl rand -hex 32
# Output: a7f3c9d1e8b2f4a6c3d9e1f7b8a4c2d6e9f1a3b7c5d8e2f4a6b9c3d1e8f7a4b2
```
### `vault_access`
**Type**: Object
**Required**: Yes
Defines which vaults the user can access.
**Three modes**:
1. `allow_access_to_all`: Access to all vaults
2. `allow_list`: Access to specific vaults only
3. `deny_list`: Access to all vaults except specific ones
## Access Control Modes
### Allow Access to All
Grant access to every vault:
```yaml
users:
user_configs:
- name: admin
token: admin-token
vault_access:
type: allow_access_to_all
```
**Use cases**:
- Administrator accounts
- Personal single-user deployments
- Development/testing
### Allow List
Grant access only to specific vaults:
```yaml
users:
user_configs:
- name: alice
token: alice-token
vault_access:
type: allow_list
allowed:
- personal
- shared-team
- project-alpha
```
**Use cases**:
- Multi-user deployments
- Restricted access scenarios
- Separation of concerns
**Notes**:
- User can only access listed vaults
- Attempting to access other vaults returns authentication error
- Empty list = no access to any vault
### Deny List
Grant access to all vaults except specific ones:
```yaml
users:
user_configs:
- name: bob
token: bob-token
vault_access:
type: deny_list
denied:
- restricted
- admin-only
```
**Use cases**:
- Users with broad access except sensitive vaults
- Simplify configuration when most vaults are accessible
**Notes**:
- User can access any vault not in the deny list
- Attempting to access denied vaults returns authentication error
## Multi-User Scenarios
### Personal Use (Single User)
```yaml
users:
user_configs:
- name: me
token: my-super-secret-token
vault_access:
type: allow_access_to_all
```
### Small Team (Shared Vaults)
```yaml
users:
user_configs:
- name: alice
token: alice-token
vault_access:
type: allow_list
allowed:
- personal-alice
- team-shared
- name: bob
token: bob-token
vault_access:
type: allow_list
allowed:
- personal-bob
- team-shared
- name: charlie
token: charlie-token
vault_access:
type: allow_list
allowed:
- personal-charlie
- team-shared
```
### Organization (Mixed Access)
```yaml
users:
user_configs:
- name: admin
token: admin-token
vault_access:
type: allow_access_to_all
- name: developer
token: dev-token
vault_access:
type: allow_list
allowed:
- engineering-docs
- api-specs
- shared
- name: designer
token: design-token
vault_access:
type: allow_list
allowed:
- design-docs
- brand-assets
- shared
- name: readonly
token: readonly-token
vault_access:
type: allow_list
allowed:
- public-wiki
```
## Authentication Flow
### Connection
1. Client connects via WebSocket
2. Client sends authentication message:
```json
{
"type": "auth",
"token": "user-token",
"vault": "vault-name"
}
```
3. Server validates:
- Token exists in config
- User has access to requested vault
4. Server responds:
- Success: Connection established
- Failure: Connection closed with error
### Validation
Server checks:
1. **Token match**: Token exists in `user_configs`
2. **Vault access**: User has permission for vault
3. **Connection limits**: Not exceeding `max_clients_per_vault`
### Errors
**Invalid token**:
```
Authentication failed: Invalid token
```
**No vault access**:
```
Authentication failed: User does not have access to vault 'restricted'
```
**Connection limit**:
```
Connection rejected: Maximum clients reached for vault
```
## Security Best Practices
### Token Generation
Generate strong tokens:
```bash
# 64 character hex token (256 bits)
openssl rand -hex 32
# Base64 encoded (256 bits)
openssl rand -base64 32
# UUID v4
uuidgen
```
### Token Storage
**In config file**:
```yaml
users:
user_configs:
- name: alice
token: !ENV ALICE_TOKEN # Read from environment variable
```
**Load from environment**:
```bash
export ALICE_TOKEN="$(openssl rand -hex 32)"
./sync_server config.yml
```
### Token Rotation
Periodically change tokens:
1. Generate new token
2. Update `config.yml`
3. Restart server
4. Update clients with new token
### Token Revocation
To revoke access:
1. Remove user from `config.yml`
2. Restart server
3. User's connections will be rejected
For immediate revocation:
- Remove user from config
- Restart server
- Existing connections are terminated
## Access Patterns
### Read-Only Users
VaultLink doesn't distinguish read-only vs read-write. Implement via client:
```yaml
# Server: Grant access
users:
user_configs:
- name: readonly
token: readonly-token
vault_access:
type: allow_list
allowed:
- public
# Client: Use CLI in read-only mode (mount vault read-only)
docker run -v /vault:/vault:ro ...
```
### Temporary Access
Grant temporary access:
1. Add user to config
2. Set reminder to remove later
3. Remove user when no longer needed
4. Restart server
For automation:
```bash
# Add user with expiry comment
echo " - name: temp-user # EXPIRES: 2024-12-31" >> config.yml
echo " token: temp-token" >> config.yml
```
### Shared Tokens (Not Recommended)
Multiple users sharing a token:
- All appear as same user in logs
- Can't revoke individual access
- Security risk if one person leaves
**Instead**: Create separate users with same vault access.
## Monitoring
### Server Logs
Authentication events are logged:
```
2024-01-01 12:00:00 INFO User 'alice' authenticated for vault 'personal'
2024-01-01 12:00:05 WARN Authentication failed: Invalid token from 192.168.1.100
2024-01-01 12:00:10 WARN User 'bob' denied access to vault 'restricted'
```
### Audit Trail
Monitor authentication:
```bash
# View authentication logs
grep "authenticated" logs/*.log
# View failed authentications
grep "Authentication failed" logs/*.log
# View access denials
grep "denied access" logs/*.log
```
## Advanced Scenarios
### Multiple Servers
Same user across multiple server instances:
```yaml
# Server 1 config.yml
users:
user_configs:
- name: alice
token: alice-global-token
vault_access:
type: allow_list
allowed:
- vault-1
- vault-2
# Server 2 config.yml
users:
user_configs:
- name: alice
token: alice-global-token # Same token
vault_access:
type: allow_list
allowed:
- vault-3
- vault-4
```
### Service Accounts
Tokens for automated systems:
```yaml
users:
user_configs:
- name: backup-service
token: backup-service-token
vault_access:
type: allow_access_to_all
- name: ci-pipeline
token: ci-token
vault_access:
type: allow_list
allowed:
- documentation
- name: monitoring
token: monitoring-token
vault_access:
type: allow_list
allowed:
- metrics
```
### Dynamic Vault Access
VaultLink doesn't support runtime user management. To change access:
1. Update `config.yml`
2. Restart server
3. Users reconnect automatically
For frequent changes, consider:
- Over-provision access (deny list)
- Use external authentication proxy
- Script config updates + reload
## Troubleshooting
### Can't connect
**Check token**:
```bash
# Verify token in config matches client
grep "token:" config.yml
```
**Check vault name**:
```bash
# Ensure vault is in allowed list
grep -A 5 "name: alice" config.yml
```
**Check server logs**:
```bash
tail -f logs/*.log | grep -i auth
```
### Access denied
**Verify vault access**:
```yaml
# Check user's vault_access configuration
users:
user_configs:
- name: alice
vault_access:
type: allow_list
allowed:
- vault-name # Must match exactly
```
**Case sensitivity**:
- Vault names are case-sensitive
- `Vault``vault`
- Ensure exact match in config and client
### Token not working
**Check for typos**:
- Extra spaces
- Hidden characters
- Wrong quotes in YAML
**Regenerate token**:
```bash
# Generate new token
openssl rand -hex 32
# Update config
# Restart server
# Update client
```
## Next Steps
- [Server configuration reference →](/config/server)
- [Advanced configuration →](/config/advanced)
- [Deploy the server →](/guide/server-setup)

489
docs/config/server.md Normal file
View file

@ -0,0 +1,489 @@
# Server Configuration
Complete reference for configuring the VaultLink sync server via `config.yml`.
## Configuration File Format
The server is configured using a YAML file passed as a command-line argument:
```bash
/app/sync_server /path/to/config.yml
```
## Complete Example
```yaml
database:
databases_directory_path: databases
max_connections_per_vault: 12
cursor_timeout_seconds: 60
server:
host: 0.0.0.0
port: 3000
max_body_size_mb: 512
max_clients_per_vault: 256
response_timeout_seconds: 60
users:
user_configs:
- name: admin
token: your-secure-random-token
vault_access:
type: allow_access_to_all
- name: alice
token: alice-token
vault_access:
type: allow_list
allowed:
- personal
- shared
- name: bob
token: bob-token
vault_access:
type: deny_list
denied:
- restricted
logging:
log_directory: logs
log_rotation: 7days
```
## Database Section
### `databases_directory_path`
**Type**: String
**Required**: Yes
**Default**: None
Directory where SQLite database files are stored. One database file per vault.
```yaml
database:
databases_directory_path: /data/databases
```
The directory structure:
```
databases/
├── vault-1.db
├── vault-2.db
└── personal.db
```
**Notes**:
- Path is relative to working directory or absolute
- Directory must be writable by the server process
- Ensure adequate disk space for vault data
- Back up this directory regularly
### `max_connections_per_vault`
**Type**: Integer
**Required**: Yes
**Default**: None
**Recommended**: 12
Maximum concurrent database connections per vault.
```yaml
database:
max_connections_per_vault: 12
```
**Tuning**:
- Higher values: Better performance under load
- Lower values: Less memory usage
- Typical range: 8-20
- Consider: Number of concurrent users × average operations per user
### `cursor_timeout_seconds`
**Type**: Integer
**Required**: Yes
**Default**: None
**Recommended**: 60
How long to keep database cursors alive for inactive clients.
```yaml
database:
cursor_timeout_seconds: 60
```
**Notes**:
- Cursors track client sync state
- Timeout too short: Clients may need to re-sync frequently
- Timeout too long: More memory usage
- Typical range: 30-300 seconds
## Server Section
### `host`
**Type**: String
**Required**: Yes
**Default**: None
Network interface to bind the server to.
```yaml
server:
host: 0.0.0.0 # All interfaces
# OR
host: 127.0.0.1 # Localhost only
# OR
host: 192.168.1.100 # Specific interface
```
**Common values**:
- `0.0.0.0`: Listen on all network interfaces (production)
- `127.0.0.1`: Listen on localhost only (development/testing)
- Specific IP: Listen on specific interface
### `port`
**Type**: Integer
**Required**: Yes
**Default**: None
**Recommended**: 3000
TCP port to listen on.
```yaml
server:
port: 3000
```
**Notes**:
- Must be available (not in use)
- Privileged ports (< 1024) require root
- Common ports: 3000, 8080, 8888
- Configure firewall to allow this port
### `max_body_size_mb`
**Type**: Integer
**Required**: Yes
**Default**: None
**Recommended**: 512
Maximum size of HTTP request body in megabytes.
```yaml
server:
max_body_size_mb: 512
```
**Usage**:
- Limits file upload size
- Prevents memory exhaustion attacks
- Must be larger than largest expected file
- Consider client `max_file_size_mb` settings
**Tuning**:
- Small vaults (mostly text): 100 MB
- Medium vaults (some images): 512 MB
- Large vaults (many images/PDFs): 1024+ MB
### `max_clients_per_vault`
**Type**: Integer
**Required**: Yes
**Default**: None
**Recommended**: 256
Maximum concurrent clients per vault.
```yaml
server:
max_clients_per_vault: 256
```
**Notes**:
- Limits concurrent WebSocket connections
- Prevents resource exhaustion
- Consider expected number of users
- Each client uses memory and file descriptors
**Scaling**:
- Personal use: 10-50
- Small team: 50-100
- Large team: 100-500
### `response_timeout_seconds`
**Type**: Integer
**Required**: Yes
**Default**: None
**Recommended**: 60
Maximum time to wait for client responses.
```yaml
server:
response_timeout_seconds: 60
```
**Usage**:
- Timeout for HTTP requests
- Timeout for WebSocket operations
- Clients disconnected if unresponsive
**Tuning**:
- Fast networks: 30 seconds
- Slow networks: 90-120 seconds
- Large file uploads: Increase proportionally
## Users Section
See [Authentication Configuration →](/config/authentication) for detailed user configuration.
## Logging Section
### `log_directory`
**Type**: String
**Required**: Yes
**Default**: None
Directory where log files are written.
```yaml
logging:
log_directory: /data/logs
# OR
log_directory: logs # Relative to working directory
```
**Notes**:
- Path is relative to working directory or absolute
- Directory must be writable
- Logs are rotated based on `log_rotation`
- Monitor disk usage
### `log_rotation`
**Type**: String
**Required**: Yes
**Default**: None
How often to rotate log files.
```yaml
logging:
log_rotation: 7days
# OR
log_rotation: 24hours
# OR
log_rotation: 30days
```
**Format**: `<number><unit>`
**Units**:
- `hours`: Hours (e.g., `12hours`, `24hours`)
- `days`: Days (e.g., `7days`, `30days`)
**Recommendations**:
- Development: `24hours` or `7days`
- Production: `7days` or `30days`
- High traffic: `24hours` (logs can be large)
## Environment-Specific Configurations
### Development
```yaml
database:
databases_directory_path: ./databases
max_connections_per_vault: 8
cursor_timeout_seconds: 30
server:
host: 127.0.0.1
port: 3000
max_body_size_mb: 100
max_clients_per_vault: 10
response_timeout_seconds: 30
users:
user_configs:
- name: dev
token: dev-token
vault_access:
type: allow_access_to_all
logging:
log_directory: logs
log_rotation: 24hours
```
### Production
```yaml
database:
databases_directory_path: /data/databases
max_connections_per_vault: 16
cursor_timeout_seconds: 120
server:
host: 0.0.0.0
port: 3000
max_body_size_mb: 512
max_clients_per_vault: 256
response_timeout_seconds: 90
users:
user_configs:
- name: admin
token: <strong-random-token>
vault_access:
type: allow_access_to_all
# Additional users...
logging:
log_directory: /data/logs
log_rotation: 7days
```
## Validation
The server validates configuration on startup:
```bash
# Start server
./sync_server config.yml
# Check for errors in logs
tail -f logs/latest.log
```
**Common errors**:
- Missing required fields
- Invalid YAML syntax
- Invalid values (negative numbers, etc.)
- Directory not writable
## Performance Tuning
### High Concurrency
For many concurrent users:
```yaml
database:
max_connections_per_vault: 20 # Increase
server:
max_clients_per_vault: 500 # Increase
response_timeout_seconds: 120 # Increase for slow clients
```
### Large Files
For vaults with large files:
```yaml
server:
max_body_size_mb: 1024 # Allow larger uploads
response_timeout_seconds: 180 # More time for uploads
```
### Resource-Constrained Systems
For limited CPU/memory:
```yaml
database:
max_connections_per_vault: 6 # Reduce
server:
max_clients_per_vault: 50 # Reduce
max_body_size_mb: 256 # Reduce
```
## Security Considerations
### Token Security
- Use strong random tokens: `openssl rand -hex 32`
- Never commit tokens to version control
- Rotate tokens periodically
- Use different tokens per user
### Network Security
- Bind to `127.0.0.1` if using reverse proxy on same host
- Use firewall to restrict access
- Enable SSL/TLS via reverse proxy
### Resource Limits
- Set `max_clients_per_vault` to prevent DoS
- Set `max_body_size_mb` to prevent memory exhaustion
- Configure `response_timeout_seconds` to prevent hanging connections
## Troubleshooting
### Server won't start
**Check YAML syntax**:
```bash
# Use a YAML validator
python -c 'import yaml, sys; yaml.safe_load(open("config.yml"))'
```
**Check file paths**:
```bash
# Ensure directories exist and are writable
mkdir -p databases logs
chmod 755 databases logs
```
**Check port availability**:
```bash
# Verify port is not in use
lsof -i :3000
```
### High memory usage
- Reduce `max_connections_per_vault`
- Reduce `max_clients_per_vault`
- Reduce `max_body_size_mb`
- Check for large vaults or many concurrent users
### Slow performance
- Increase `max_connections_per_vault`
- Increase database connection pool
- Use SSD for database storage
- Monitor database size (vacuum if needed)
## Next Steps
- [Configure authentication →](/config/authentication)
- [Advanced configuration options →](/config/advanced)
- [Deploy the server →](/guide/server-setup)

324
docs/guide/alternatives.md Normal file
View file

@ -0,0 +1,324 @@
# Comparison with Alternatives
VaultLink is one of several solutions for synchronising Obsidian vaults. This page compares VaultLink with popular alternatives to help you choose the right tool.
## Key Differentiator: Editor Agnostic
**VaultLink is not tied to Obsidian.** While it includes an Obsidian plugin for convenience, VaultLink synchronises plain text files and works with any editor:
- Edit with **Obsidian desktop** on your laptop
- Edit with **Vim** on your server
- Edit with **VS Code** on your workstation
- Edit with **Obsidian mobile** on your phone
- Use the **CLI client** for automated workflows
All changes merge automatically without conflict markers, regardless of which editor you use. This is possible because VaultLink uses [reconcile-text](/architecture/sync-algorithm#why-reconcile-text-over-crdts) for differential synchronisation rather than requiring operation-level tracking.
## VaultLink's Core Strengths
Before diving into comparisons:
1. **Fully self-hosted**: Server and all components are open source
2. **Collaborative editing**: Real-time sync with operational transformation
3. **Automatic conflict resolution**: No manual intervention or paid features required
4. **Cursor tracking**: See where other users are editing
5. **Extensively tested**: Comprehensive test suite for server and client
6. **Editor freedom**: Use any text editor, not just Obsidian
7. **Production-ready**: Docker images, health checks, monitoring
## Obsidian Sync Alternatives
### Self-hosted LiveSync
**Downloads**: ~300,000
**Repository**: https://github.com/vrtmrz/obsidian-livesync
**Overview**: CouchDB/IBM Cloudant-based sync with end-to-end encryption.
| Aspect | Self-hosted LiveSync | VaultLink |
| ------------------------- | --------------------------- | -------------------------------------- |
| **Self-hosted** | Yes (CouchDB required) | Yes (single binary or Docker) |
| **Conflict resolution** | Manual or automatic (basic) | Automatic (operational transformation) |
| **Collaborative editing** | No | Yes (real-time with cursors) |
| **Editor support** | Obsidian only | Any text editor |
| **Infrastructure** | CouchDB database | SQLite (bundled) |
| **Deployment complexity** | Medium (external DB) | Low (single container) |
| **End-to-end encryption** | Yes | No (transport encryption only) |
| **Out-of-band edits** | Limited support | Full support (edit with any tool) |
**When to use LiveSync**:
- Need end-to-end encryption
- Already running CouchDB
- Only use Obsidian (no external editors)
**When to use VaultLink**:
- Want collaborative editing with multiple users
- Edit files with various tools (Vim, VS Code, etc.)
- Need simpler deployment (no external database)
- Want operational transformation for better merges
---
### Remotely Save
**Downloads**: ~1.1M
**Repository**: https://github.com/remotely-save/remotely-save
**Overview**: Sync to cloud storage providers (S3, Dropbox, OneDrive, WebDAV).
| Aspect | Remotely Save | VaultLink |
| ------------------------- | ---------------------------- | ------------------------ |
| **Self-hosted** | Partial (uses cloud storage) | Fully self-hosted |
| **Conflict resolution** | Paid Pro feature | Free and automatic |
| **Collaborative editing** | No | Yes |
| **Editor support** | Obsidian only | Any text editor |
| **Storage backend** | Cloud providers | Self-hosted SQLite |
| **Cost** | Free (basic) / Paid (Pro) | Free (open source) |
| **Code quality** | No tests, complex codebase | Comprehensive test suite |
| **Real-time sync** | No (periodic polling) | Yes (WebSocket) |
**When to use Remotely Save**:
- Already use cloud storage (S3, Dropbox)
- Don't need real-time sync
- Single-user scenario
**When to use VaultLink**:
- Want full control over data
- Need automatic conflict resolution without paying
- Want real-time collaborative editing
- Value code quality and testing
**Note**: Remotely Save's conflict resolution is a paid feature. VaultLink provides superior automatic merging for free.
---
### Relay
**Downloads**: ~24,000
**Repository**: https://github.com/No-Instructions/Relay
**Overview**: CRDT-based sync with proprietary server component.
| Aspect | Relay | VaultLink |
| -------------------------- | ---------------------------- | ----------------------- |
| **Self-hosted** | No (proprietary server) | Yes (fully open source) |
| **Conflict resolution** | CRDT (automatic) | OT (automatic) |
| **Collaborative editing** | Yes | Yes |
| **Editor support** | Obsidian only | Any text editor |
| **Out-of-band edits** | No (breaks CRDT consistency) | Yes (differential sync) |
| **Server open source** | No | Yes |
| **Infrastructure control** | Limited | Full |
| **Per-file overhead** | High (CRDT metadata) | Low (version history) |
**When to use Relay**:
- Want hosted solution (don't self-host)
- Only edit within Obsidian
- Don't need out-of-band editing
**When to use VaultLink**:
- Need fully open source solution
- Want to self-host completely
- Edit files outside Obsidian (Vim, VS Code)
- Value infrastructure control
**Critical limitation**: Relay's CRDT approach requires tracking every operation within Obsidian. Editing files outside Obsidian breaks the CRDT state. VaultLink's differential sync works regardless of how files are edited.
---
### Obsidian Git
**Downloads**: ~1.4M
**Repository**: https://github.com/denolehov/obsidian-git
**Overview**: Uses Git for version control and synchronisation.
| Aspect | Obsidian Git | VaultLink |
| ------------------------- | ----------------------------- | ----------------------- |
| **Self-hosted** | Yes (Git server) | Yes (sync server) |
| **Conflict resolution** | Manual (conflict markers) | Automatic (no markers) |
| **Collaborative editing** | No | Yes (real-time) |
| **Editor support** | Any (it's Git) | Any (differential sync) |
| **Version history** | Full Git history | Document versions |
| **Real-time sync** | No (commit-based) | Yes (instant) |
| **Merge conflicts** | Manual resolution | Automatic |
| **Learning curve** | High (Git knowledge required) | Low |
| **Workflow interruption** | Yes (resolve conflicts) | No |
**When to use Obsidian Git**:
- Need full version control (branches, tags, etc.)
- Already familiar with Git workflows
- Want integration with existing Git repos
- Don't mind manual conflict resolution
**When to use VaultLink**:
- Want automatic conflict-free merging
- Need real-time collaborative editing
- Don't want workflow interruptions from merge conflicts
- Prefer simpler mental model (sync, not commits)
**Key difference**: Git requires manual conflict resolution with `<<<<<<<` markers. VaultLink automatically merges all changes using operational transformation, never interrupting your workflow.
---
### Syncthing Integration
**Downloads**: ~22,600
**Repository**: https://github.com/LBF38/obsidian-syncthing-integration
**Overview**: Wrapper around Syncthing for file synchronisation.
| Aspect | Syncthing Integration | VaultLink |
| ------------------------- | ------------------------------ | ----------------- |
| **Self-hosted** | Yes (Syncthing) | Yes (sync server) |
| **Conflict resolution** | Manual | Automatic |
| **Collaborative editing** | No | Yes |
| **Editor support** | Any | Any |
| **Status** | Unfinished | Production-ready |
| **Conflict files** | Creates `.sync-conflict` files | No conflict files |
| **Real-time sync** | Yes | Yes |
| **Automatic merging** | No | Yes |
**When to use Syncthing Integration**:
- Already use Syncthing for other files
- Don't need automatic conflict resolution
- Single-user with multiple devices
**When to use VaultLink**:
- Want automatic conflict resolution
- Need collaborative editing
- Want production-ready solution
- Don't want to manage conflict files
**Status note**: Syncthing Integration is marked as unfinished. VaultLink is production-ready with comprehensive testing.
---
### Remotely Sync
**Downloads**: ~38,000
**Repository**: https://github.com/sboesen/remotely-sync
**Overview**: Similar to Remotely Save, syncs to cloud storage.
| Aspect | Remotely Sync | VaultLink |
| ----------------------- | ----------------------- | ------------------- |
| **Self-hosted** | Partial (cloud storage) | Fully self-hosted |
| **Conflict resolution** | Limited/Paid | Free and automatic |
| **Code quality** | No tests | Comprehensive tests |
| **Maintenance** | Low activity | Active development |
**Same concerns as Remotely Save**: No test suite, conflict resolution limitations, cloud storage dependency.
**When to use VaultLink**: See Remotely Save comparison above.
---
### SyncFTP
**Downloads**: ~5,000
**Repository**: https://github.com/alex-donnan/SyncFTP
**Overview**: Simple FTP-based file synchronisation.
| Aspect | SyncFTP | VaultLink |
| ------------------------- | ---------------------- | ---------------- |
| **Conflict resolution** | None (last write wins) | Automatic (OT) |
| **Data loss risk** | High (overwrites) | None (merges) |
| **Collaborative editing** | No | Yes |
| **Sophistication** | Minimal | Production-grade |
**When to use SyncFTP**: Don't use SyncFTP for any scenario where data integrity matters.
**When to use VaultLink**: Any scenario requiring reliable synchronisation.
---
## Feature Comparison Matrix
| Feature | VaultLink | LiveSync | Relay | Git | Remotely Save | Syncthing |
| --------------------------------- | --------- | -------- | ----- | --- | ------------- | --------- |
| **Fully open source** | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ |
| **Self-hosted** | ✅ | ✅ | ❌ | ✅ | Partial | ✅ |
| **Automatic conflict resolution** | ✅ | Basic | ✅ | ❌ | Paid | ❌ |
| **Real-time sync** | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
| **Collaborative editing** | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **Cursor tracking** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Editor agnostic** | ✅ | ❌ | ❌ | ✅ | ❌ | ✅ |
| **Out-of-band edits** | ✅ | Limited | ❌ | ✅ | ❌ | ✅ |
| **No conflict markers** | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| **Comprehensive tests** | ✅ | ❌ | ❌ | N/A | ❌ | N/A |
| **Simple deployment** | ✅ | ❌ | N/A | ❌ | ✅ | ❌ |
| **Low infrastructure** | ✅ | ❌ | N/A | ✅ | ✅ | ✅ |
---
## VaultLink's Unique Position
VaultLink is the **only** solution that combines:
1. **Fully open source** self-hosted server
2. **Editor agnostic** operation (not locked to Obsidian)
3. **Automatic conflict-free merging** using operational transformation
4. **Real-time collaborative editing** with cursor tracking
5. **Differential synchronisation** supporting out-of-band edits
6. **Comprehensive test coverage** ensuring reliability
7. **Simple deployment** via Docker or single binary
## Use Case Recommendations
### Choose VaultLink when you:
- Edit vaults with multiple editors (Obsidian + Vim + VS Code)
- Need real-time collaboration with teammates
- Want automatic conflict resolution without manual intervention
- Value full control over infrastructure
- Need production-ready reliability with comprehensive testing
- Want to edit files while offline and sync later seamlessly
### Consider alternatives when you:
- **LiveSync**: Need end-to-end encryption and only use Obsidian
- **Git**: Need full version control with branches and advanced Git features
- **Remotely Save**: Already committed to cloud storage providers
- **Syncthing**: Already use Syncthing and don't need automatic merging
## Migration from Other Solutions
VaultLink works with plain Markdown files, making migration simple:
1. **From Git**: Clone your repo, point VaultLink to the directory
2. **From cloud sync**: Download files, configure VaultLink client
3. **From LiveSync**: Export vault, import to VaultLink
4. **From Syncthing**: Point VaultLink to synced directory
All solutions work with the same Markdown files—VaultLink just syncs them better.
## Beyond Obsidian
Because VaultLink is editor-agnostic, you can use it for:
- **Documentation teams**: Sync technical docs edited in VS Code
- **Academic writing**: Collaborate on papers with various Markdown editors
- **Personal knowledge bases**: Use Obsidian on mobile, Vim on servers
- **Automated workflows**: CLI client for backup systems and CI/CD
- **Multi-tool workflows**: Different team members use different editors
VaultLink doesn't lock you into Obsidian—it's a general-purpose differential sync system that happens to work excellently with Obsidian vaults.
## Next Steps
Ready to try VaultLink?
- [Get started →](/guide/getting-started)
- [Understand the architecture →](/architecture/)
- [See how sync works →](/architecture/sync-algorithm)

532
docs/guide/cli-client.md Normal file
View file

@ -0,0 +1,532 @@
# CLI Client
Sync vaults without Obsidian. Works on servers, automation, backups, headless systems.
## Installation
### Docker (Recommended)
Pull the latest image:
```bash
docker pull ghcr.io/schmelczer/vault-link-cli:latest
```
### npm
Install globally:
```bash
npm install -g @schmelczer/local-client-cli
```
Verify installation:
```bash
vaultlink --version
```
### From Source
Build from the repository:
```bash
git clone https://github.com/schmelczer/vault-link.git
cd vault-link/frontend/local-client-cli
npm install
npm run build
node dist/cli.js --help
```
## Usage
### Basic Usage
```bash
vaultlink \
--local-path /path/to/vault \
--remote-uri wss://sync.example.com \
--token your-auth-token \
--vault-name default
```
### Docker Usage
```bash
docker run -v /path/to/vault:/vault \
ghcr.io/schmelczer/vault-link-cli:latest \
-l /vault \
-r wss://sync.example.com \
-t your-auth-token \
-v default
```
### Docker Compose
Create `docker-compose.yml`:
```yaml
services:
vaultlink-cli:
image: ghcr.io/schmelczer/vault-link-cli:latest
restart: unless-stopped
volumes:
- ./vault:/vault
command:
- "-l"
- "/vault"
- "-r"
- "wss://sync.example.com"
- "-t"
- "your-token"
- "-v"
- "default"
```
Start the client:
```bash
docker compose up -d
```
## Configuration Options
### Required Arguments
| Argument | Short | Description | Example |
| -------------- | ----- | ----------------------- | ------------------------ |
| `--local-path` | `-l` | Local directory to sync | `/vault` |
| `--remote-uri` | `-r` | Server WebSocket URI | `wss://sync.example.com` |
| `--token` | `-t` | Authentication token | `abc123...` |
| `--vault-name` | `-v` | Vault name on server | `default` |
### Optional Arguments
| Argument | Default | Description |
| ------------------------------- | ------- | -------------------------------------- |
| `--sync-concurrency` | `1` | Concurrent file operations |
| `--max-file-size-mb` | `10` | Max file size in MB |
| `--ignore-pattern` | - | Glob pattern to ignore (repeatable) |
| `--websocket-retry-interval-ms` | `3500` | Reconnection interval |
| `--log-level` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
### Environment Variables
Alternative to command-line arguments:
```bash
export VAULTLINK_LOCAL_PATH="/vault"
export VAULTLINK_REMOTE_URI="wss://sync.example.com"
export VAULTLINK_TOKEN="your-token"
export VAULTLINK_VAULT_NAME="default"
vaultlink
```
## Examples
### Basic Sync
Sync a local directory to the server:
```bash
vaultlink \
-l ./my-notes \
-r wss://sync.example.com \
-t my-secure-token \
-v personal
```
### With Ignore Patterns
Exclude specific files or directories:
```bash
vaultlink \
-l ./vault \
-r wss://sync.example.com \
-t token123 \
-v default \
--ignore-pattern "*.tmp" \
--ignore-pattern ".DS_Store" \
--ignore-pattern "node_modules/**"
```
### Debug Logging
Enable verbose logging:
```bash
vaultlink \
-l ./vault \
-r wss://sync.example.com \
-t token123 \
-v default \
--log-level DEBUG
```
### High Concurrency
Faster initial sync:
```bash
vaultlink \
-l ./vault \
-r wss://sync.example.com \
-t token123 \
-v default \
--sync-concurrency 5
```
### Large Files
Allow larger file uploads:
```bash
vaultlink \
-l ./vault \
-r wss://sync.example.com \
-t token123 \
-v default \
--max-file-size-mb 50
```
## Docker Deployment
### Long-Running Sync
Run as a daemon for continuous synchronisation:
```bash
docker run -d \
--name vaultlink-sync \
--restart unless-stopped \
-v $(pwd)/vault:/vault \
ghcr.io/schmelczer/vault-link-cli:latest \
-l /vault \
-r wss://sync.example.com \
-t your-token \
-v default
```
Monitor logs:
```bash
docker logs -f vaultlink-sync
```
### Health Monitoring
The Docker image includes built-in health checks:
```bash
# Check health status
docker ps
# View detailed health info
docker inspect --format='{{json .State.Health}}' vaultlink-sync | jq
```
Health check verifies:
- Health file exists
- Status updated within last 30 seconds
- WebSocket connection is active
Configure custom health check:
```yaml
services:
vaultlink-cli:
image: ghcr.io/schmelczer/vault-link-cli:latest
healthcheck:
test: ["CMD", "node", "/app/healthcheck.js"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
```
### Read-Only Vault
Mount vault as read-only to prevent local changes:
```bash
docker run -d \
-v $(pwd)/vault:/vault:ro \
ghcr.io/schmelczer/vault-link-cli:latest \
-l /vault \
-r wss://sync.example.com \
-t token \
-v default
```
::: warning
The CLI needs write access to create `.vaultlink` metadata directory. Mount as read-write or provide a separate writeable directory.
:::
## How It Works
### Initial Sync
On startup:
1. Creates `.vaultlink/` directory for metadata
2. Scans local filesystem
3. Uploads all local files to server
4. Downloads files from server not present locally
5. Resolves conflicts using operational transformation
### Real-Time Synchronization
After initial sync:
1. Watches filesystem for changes using `fs.watch`
2. Uploads changed files immediately
3. Receives real-time updates from server via WebSocket
4. Handles bidirectional sync automatically
### Graceful Shutdown
On SIGINT (Ctrl+C) or SIGTERM:
1. Completes pending uploads
2. Closes WebSocket connection cleanly
3. Flushes metadata to disk
4. Exits gracefully
## Use Cases
### Automated Backups
Continuously backup vaults to a remote server:
```bash
docker run -d \
--name vault-backup \
-v /important/notes:/vault:ro \
ghcr.io/schmelczer/vault-link-cli:latest \
-l /vault -r wss://backup.example.com -t backup-token -v backups
```
### CI/CD Documentation
Sync documentation in automated pipelines:
```bash
# In your CI pipeline
docker run \
-v $(pwd)/docs:/vault \
ghcr.io/schmelczer/vault-link-cli:latest \
-l /vault -r wss://docs.example.com -t ci-token -v prod-docs
```
### Multi-Location Sync
Sync between different geographic locations:
```bash
# Location A
vaultlink -l /data/vault -r wss://hub.example.com -t token -v shared
# Location B
vaultlink -l /backup/vault -r wss://hub.example.com -t token -v shared
```
### Development Environment
Keep documentation in sync across dev environments:
```bash
# In docker-compose.yml for your dev stack
services:
docs-sync:
image: ghcr.io/schmelczer/vault-link-cli:latest
volumes:
- ./docs:/vault
command: ["-l", "/vault", "-r", "wss://docs-server", "-t", "dev-token", "-v", "dev"]
```
## Troubleshooting
### Client won't connect
**Check server accessibility**:
```bash
curl https://sync.example.com/vaults/test/ping
```
**Verify WebSocket protocol**:
- Use `ws://` for HTTP servers
- Use `wss://` for HTTPS servers
**Check authentication**:
- Token must match server config
- User must have access to the vault
### Permission errors
**Docker volume permissions**:
```bash
# Ensure directory is writable
chmod 755 /path/to/vault
# Check Docker user ID
docker run --rm ghcr.io/schmelczer/vault-link-cli:latest id
```
**SELinux issues**:
```bash
# Add :z flag to volume mount
docker run -v /path/to/vault:/vault:z ...
```
### Files not syncing
**Check ignore patterns**:
- View logs to see which files are skipped
- Ensure patterns don't match unintentionally
**File size limits**:
- Check `--max-file-size-mb` setting
- Large files are skipped with a warning
**Check metadata**:
```bash
# View sync metadata
cat /path/to/vault/.vaultlink/metadata.json
```
### High memory usage
**Reduce concurrency**:
```bash
--sync-concurrency 1
```
**Limit file sizes**:
```bash
--max-file-size-mb 5
```
**Check vault size**:
- Very large vaults may need more resources
- Consider splitting into multiple vaults
### Connection keeps dropping
**Increase retry interval**:
```bash
--websocket-retry-interval-ms 5000
```
**Check network stability**:
```bash
# Monitor connection
docker logs -f vaultlink-sync | grep -i websocket
```
**Server timeout settings**:
- Verify reverse proxy WebSocket timeout
- Check server `response_timeout_seconds`
## Advanced Usage
### Custom Healthcheck Script
Create your own health monitoring:
```bash
#!/bin/bash
HEALTH_FILE="/tmp/vaultlink-health.json"
if [ ! -f "$HEALTH_FILE" ]; then
exit 1
fi
# Check file is recent (within 60 seconds)
if [ $(( $(date +%s) - $(stat -c %Y "$HEALTH_FILE") )) -gt 60 ]; then
exit 1
fi
# Check WebSocket is connected
if ! jq -e '.connected == true' "$HEALTH_FILE" > /dev/null; then
exit 1
fi
exit 0
```
### Automated Recovery
Restart on failure with exponential backoff:
```bash
#!/bin/bash
RETRY_DELAY=5
while true; do
vaultlink -l /vault -r wss://server -t token -v default
echo "Client exited, restarting in ${RETRY_DELAY}s..."
sleep $RETRY_DELAY
# Exponential backoff up to 5 minutes
RETRY_DELAY=$((RETRY_DELAY * 2))
if [ $RETRY_DELAY -gt 300 ]; then
RETRY_DELAY=300
fi
done
```
### Integration with systemd
Create `/etc/systemd/system/vaultlink-cli.service`:
```ini
[Unit]
Description=VaultLink CLI Sync
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Restart=always
RestartSec=10
Environment="VAULTLINK_LOCAL_PATH=/data/vault"
Environment="VAULTLINK_REMOTE_URI=wss://sync.example.com"
Environment="VAULTLINK_TOKEN=your-token"
Environment="VAULTLINK_VAULT_NAME=default"
ExecStart=/usr/local/bin/vaultlink
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
sudo systemctl daemon-reload
sudo systemctl enable vaultlink-cli
sudo systemctl start vaultlink-cli
```
## Next Steps
- [Configure server authentication →](/config/authentication)
- [Learn about the sync algorithm →](/architecture/sync-algorithm)
- [Set up Obsidian plugin →](/guide/obsidian-plugin)

View file

@ -0,0 +1,125 @@
# Getting Started
Set up VaultLink in 5 minutes. Deploy server, connect clients, done.
## Prerequisites
- Docker (or Rust toolchain if building from source)
- A server (VPS, home server, or localhost for testing)
## Step 1: Deploy Server
Create `config.yml`:
```yaml
database:
databases_directory_path: databases
max_connections_per_vault: 12
cursor_timeout_seconds: 60
server:
host: 0.0.0.0
port: 3000
max_body_size_mb: 512
max_clients_per_vault: 256
response_timeout_seconds: 60
users:
user_configs:
- name: admin
token: change-this-to-secure-random-token
vault_access:
type: allow_access_to_all
logging:
log_directory: logs
log_rotation: 7days
```
::: tip
Generate secure token: `openssl rand -hex 32`
:::
Run server:
```bash
docker run -d \
--name vaultlink-server \
--restart unless-stopped \
-p 3000:3000 \
-v $(pwd):/data \
ghcr.io/schmelczer/vault-link-server:latest \
/app/sync_server /data/config.yml
```
Verify: `curl http://localhost:3000/vaults/test/ping` should return server version and auth status
## Step 2: Connect Client
### Obsidian Plugin
1. Settings → Community Plugins → Browse
2. Search "VaultLink", install, enable
3. Configure:
- Server URL: `ws://localhost:3000` (or `wss://your-server.com` for SSL)
- Token: Your token from config.yml
- Vault Name: `default`
[Full plugin guide →](/guide/obsidian-plugin)
### CLI Client
```bash
docker run -d \
--name vaultlink-cli \
--restart unless-stopped \
-v /path/to/vault:/vault \
ghcr.io/schmelczer/vault-link-cli:latest \
-l /vault -r ws://localhost:3000 -t your-token -v default
```
[Full CLI guide →](/guide/cli-client)
## Production Setup
For production:
1. **SSL/TLS**: Use Nginx/Caddy reverse proxy for `wss://` ([setup guide](/guide/server-setup#ssl-tls-with-reverse-proxy))
2. **Secure tokens**: Generate with `openssl rand -hex 32`, don't reuse the example
3. **Firewall**: Only expose port 3000 to reverse proxy
4. **Backups**: SQLite databases are in `databases/` directory
## Multiple Users
```yaml
users:
user_configs:
- name: alice
token: alice-token
vault_access:
type: allow_list
allowed:
- personal
- shared
- name: bob
token: bob-token
vault_access:
type: allow_list
allowed:
- shared
```
[Auth docs →](/config/authentication)
## Troubleshooting
**Server won't start**: `docker logs vaultlink-server`
**Client can't connect**:
1. Verify server: `curl http://your-server:3000/vaults/test/ping`
2. Check URL: `ws://` for HTTP, `wss://` for HTTPS
3. Verify token matches config.yml
**Understanding limitations**: [See what VaultLink can and can't do →](/guide/limitations)
**Files not syncing**: Check client logs, verify vault name matches
[Server setup →](/guide/server-setup) | [Architecture →](/architecture/)

192
docs/guide/limitations.md Normal file
View file

@ -0,0 +1,192 @@
# Limitations
VaultLink works well for most Obsidian vaults, but has some constraints you should know about.
## File Type Limitations
### Mergeable Files
Only **`.md`** and **`.txt`** files get automatic conflict-free merging.
Other file types (images, PDFs, etc.) use last-write-wins:
```
User A updates diagram.png → Server stores version 1
User B updates diagram.png → Server stores version 2 (overwrites A's changes)
```
**Workaround**: Avoid editing the same non-text file simultaneously.
### Binary Detection
Files are treated as binary if they:
- Contain NUL bytes (`0x00`)
- Fail UTF-8 validation
Binary files within `.md` or `.txt` extensions still get last-write-wins (no merge).
## Performance Constraints
### Server Limits (Configurable)
| Resource | Default | Maximum Tested |
| ------------------------ | ------- | -------------- |
| Clients per vault | 256 | ~256 |
| Database connections | 12 | 20 |
| Max file size | 512 MB | 4096 MB |
| Request timeout | 60s | 180s |
| WebSocket cursor timeout | 60s | 300s |
| Database busy timeout | 3600s | - |
### Vault Size
- **Small vaults** (< 1000 files): Excellent performance
- **Medium vaults** (1000-10000 files): Good performance
- **Large vaults** (> 10000 files): Works, but initial sync slower
No hard file count limit—constrained by disk space and sync time.
### Resource Usage
Rough estimates (varies by vault size and activity):
- **RAM**: ~50-200 MB base + ~1-5 MB per active client
- **CPU**: Low (< 5%) for typical usage, spikes during merges
- **Disk**: Vault size + version history (grows over time)
## Version History
### Storage
- All versions stored indefinitely (no automatic cleanup)
- Each vault is a separate SQLite database
- Deleted files marked as deleted (not purged)
**Growth**: Version history grows with every change. A 10 MB vault with frequent edits might grow to 100+ MB over months.
**Cleanup**: Manual only (see [Advanced Configuration](/config/advanced#version-history-cleanup)).
### Implications
- Disk usage grows over time
- Database size affects backup time
- No built-in retention policy
## Merge Quality
### Text Merging
VaultLink uses word-level tokenisation for merging:
```markdown
Parent: "The quick brown fox"
User A: "The quick red fox"
User B: "The very quick brown fox"
Result: "The very quick red fox" ← Both changes preserved
```
**Imperfect scenarios**:
- Complex nested Markdown (tables, code blocks)
- Simultaneous edits to the same sentence
- Large structural changes (moving sections around)
**Result**: Merged file might need manual cleanup in ~1-5% of concurrent edits.
## Scalability
### SQLite Limitations
- One SQLite database per vault
- Single-server architecture (no built-in clustering)
- Write serialisation through database
**For high concurrency**: Consider multiple vaults instead of one massive shared vault.
### Horizontal Scaling
Not currently supported. Running multiple servers requires manual vault partitioning.
## Network Requirements
### Latency
- Real-time sync typically < 500ms on good connections
- Mobile/slow networks: 1-5s latency possible
- Timeout failures on very slow connections (> 60s)
### Offline Behaviour
- Clients queue changes locally
- On reconnect, sync all changes since last connection
- Conflicts resolved automatically (for mergeable files)
**Limitation**: No offline conflict preview—merged result appears after reconnect.
## Security
### No End-to-End Encryption
- Server sees all file contents
- Transport encryption only (WSS/TLS)
- Trust your server
**Workaround**: Self-host on infrastructure you control.
### Authentication
- Token-based only (no OAuth, SAML, etc.)
- Tokens configured in server config file
- No runtime user management
## Known Edge Cases
### Simultaneous Deletes and Edits
```
User A deletes note.md
User B edits note.md
Result: Edit wins (file recreated with B's content)
```
Operational transformation prioritises content preservation.
### Large File Uploads
Files > 100 MB may time out on slow connections. Increase `response_timeout_seconds` or split large files.
### Mobile Sync
- Mobile networks may drop WebSocket connections frequently
- Client auto-reconnects, but causes sync delays
- Battery impact from constant reconnections
## What VaultLink is NOT
- **Not a backup solution**: Version history helps but isn't a backup (make backups!)
- **Not Git**: No branching, no commit messages, no diffs to review before merge
- **Not encrypted storage**: Server sees everything
- **Not multi-master**: One server, multiple clients (not peer-to-peer)
## Recommendations
### Good Use Cases
- Personal multi-device sync (< 10 devices)
- Small team collaboration (< 20 people)
- Primarily text/Markdown content
- Trusted server environment
### Poor Use Cases
- Large teams (> 50 concurrent users per vault)
- Primarily binary files (images, videos, large PDFs)
- Untrusted server (need E2E encryption)
- Highly regulated environments (HIPAA, etc.)
## Next Steps
- [Server configuration limits →](/config/server)
- [Advanced tuning →](/config/advanced)
- [Architecture details →](/architecture/)

View file

@ -0,0 +1,276 @@
# Obsidian Plugin
Real-time sync for Obsidian vaults.
## Installation
### From Obsidian Community Plugins
1. Open Obsidian Settings
2. Navigate to **Community Plugins**
3. Click **Browse** and search for "VaultLink"
4. Click **Install**
5. Enable the plugin
### Manual Installation
1. Download the latest release from [GitHub Releases](https://github.com/schmelczer/vault-link/releases)
2. Extract `main.js`, `manifest.json`, and `styles.css`
3. Copy to `.obsidian/plugins/vault-link/` in your vault
4. Reload Obsidian
5. Enable VaultLink in Community Plugins settings
## Configuration
After installation, configure the plugin in **Settings → VaultLink**.
### Required Settings
#### Server URL
The WebSocket URL of your sync server.
- **Development/Local**: `ws://localhost:3000`
- **Production (SSL)**: `wss://sync.example.com`
::: tip
Use `ws://` for unencrypted connections and `wss://` for SSL connections (production).
:::
#### Authentication Token
Your authentication token from the server's `config.yml`.
Generate a secure token:
```bash
openssl rand -hex 32
```
#### Vault Name
The name of the vault on the server. Can be any string.
Multiple Obsidian vaults can sync to the same server vault name (for shared vaults), or use unique names for separate vaults.
### Optional Settings
#### Sync Concurrency
Number of files to sync simultaneously.
- **Default**: 1
- **Range**: 1-10
- Higher values = faster initial sync, more resource usage
#### Max File Size
Maximum file size to sync (in MB).
- **Default**: 10
- Files larger than this are skipped
#### Ignore Patterns
Glob patterns for files to exclude from sync.
Examples:
- `*.tmp` - Ignore temporary files
- `.trash/**` - Ignore trash folder
- `private/**` - Ignore private directory
#### WebSocket Retry Interval
Milliseconds between reconnection attempts when disconnected.
- **Default**: 3500ms
- Increase for flaky networks to avoid connection spam
## Usage
### Initial Sync
When first connecting:
1. The plugin uploads all local files to the server
2. Downloads any missing files from the server
3. Resolves any conflicts using operational transformation
4. Begins real-time synchronisation
Initial sync time depends on vault size and `sync_concurrency` setting.
### Real-Time Sync
Once connected:
- **File changes**: Automatically synced when saved
- **File creation**: New files immediately uploaded
- **File deletion**: Deletions propagated to other clients
- **File renames**: Tracked and synchronised
The plugin watches your vault filesystem and syncs changes in real-time via WebSocket.
### Status Indicators
The plugin provides visual feedback:
- **Connected**: Green status in settings
- **Syncing**: Progress indicator during uploads
- **Disconnected**: Red status, automatic reconnection attempts
- **Error**: Error message in settings and console
Check the Obsidian console (Ctrl+Shift+I / Cmd+Option+I) for detailed logs.
## Features
### Automatic Conflict Resolution
When multiple users edit the same file simultaneously, operational transformation merges changes automatically:
- All edits are preserved
- No manual conflict resolution required
- Changes appear in real-time as others type
### Mobile Support
VaultLink works on Obsidian mobile (iOS and Android):
- Same configuration as desktop
- Real-time sync across all devices
- Handle network changes gracefully
::: warning
Ensure your sync server is accessible from mobile networks (use WSS with a public domain or VPN).
:::
### Offline Support
The plugin handles offline scenarios:
- Continue working when disconnected
- Changes queue locally
- Automatic sync when connection restored
- Conflict resolution if others edited the same files
## Collaboration Workflows
### Personal Multi-Device Sync
Sync the same vault across devices:
1. Configure each Obsidian instance with the same vault name
2. Use the same authentication token
3. All devices stay in sync automatically
### Team Shared Vault
Multiple users collaborating:
1. Each user has their own token (configured in server `config.yml`)
2. All users connect to the same vault name
3. Real-time collaborative editing with automatic conflict resolution
### Selective Sharing
Share specific folders while keeping others private:
1. Use different vault names for shared vs. private content
2. Configure access control on the server per vault
3. Use ignore patterns to exclude sensitive directories
## Troubleshooting
### Plugin won't connect
1. **Verify server is running**:
```bash
curl http://your-server:3000/vaults/test/ping
```
Should return `pong`
2. **Check URL format**:
- Local: `ws://localhost:3000`
- Remote (SSL): `wss://sync.example.com`
- Don't include `/vault/name` in the URL
3. **Verify token**:
- Must match server config exactly
- No extra spaces or quotes
- Check server logs for authentication errors
4. **Check firewall**:
- Ensure port is accessible from your network
- For mobile, server must be publicly accessible or use VPN
### Files not syncing
1. **Check ignore patterns**: File may match an exclusion pattern
2. **File size**: Check if file exceeds `max_file_size_mb`
3. **Permissions**: Ensure vault directory is readable/writable
4. **Console errors**: Open dev tools (Ctrl+Shift+I) and check console
### Slow initial sync
1. **Increase concurrency**: Set `sync_concurrency` higher (e.g., 5)
2. **Network speed**: Check internet connection
3. **Server resources**: Ensure server isn't overloaded
4. **Large files**: Consider increasing timeout settings
### Conflicts not resolving
Operational transformation should handle conflicts automatically. If issues persist:
1. Check console for sync errors
2. Verify both clients are connected
3. Check server logs for processing errors
4. Ensure files are text-based (binary files may not merge well)
### High CPU/Memory usage
1. **Reduce concurrency**: Lower `sync_concurrency`
2. **Add ignore patterns**: Exclude unnecessary files
3. **File watchers**: Large vaults may trigger many filesystem events
4. **Check for sync loops**: Ensure no circular dependencies
## Advanced Configuration
### Multiple Vaults
To sync multiple Obsidian vaults to different server vaults:
1. Each Obsidian vault has its own VaultLink plugin configuration
2. Use different vault names for each
3. Can use the same or different tokens (depending on access control)
### Custom Sync Patterns
Combine ignore patterns for fine-grained control:
```
# Ignore patterns
*.tmp
*.bak
.DS_Store
.trash/**
private/**
drafts/**/*.draft.md
```
### Development/Testing
For plugin development:
1. Clone the repository
2. `cd frontend && npm install`
3. `npm run dev` to build in watch mode
4. Plugin rebuilds automatically on changes
5. Reload Obsidian to test changes
## Next Steps
- [Learn about the sync algorithm →](/architecture/sync-algorithm)
- [Configure the server →](/config/server)
- [Set up the CLI client →](/guide/cli-client)

379
docs/guide/server-setup.md Normal file
View file

@ -0,0 +1,379 @@
# Server Setup
Deploy VaultLink server via Docker, binary, or build from source.
## Deployment Options
### Docker (Recommended)
Easiest deployment path, includes health checks.
#### Basic Docker Deployment
```bash
# Pull the latest image
docker pull ghcr.io/schmelczer/vault-link-server:latest
# Create data directory
mkdir -p ~/vaultlink-data
# Create config.yml (see Configuration section below)
# Run the container
docker run -d \
--name vaultlink-server \
--restart unless-stopped \
-p 3000:3000 \
-v ~/vaultlink-data:/data \
ghcr.io/schmelczer/vault-link-server:latest \
/app/sync_server /data/config.yml
```
#### Docker Compose
Create `docker-compose.yml`:
```yaml
services:
vaultlink-server:
image: ghcr.io/schmelczer/vault-link-server:latest
container_name: vaultlink-server
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- ./data:/data
command: ["/app/sync_server", "/data/config.yml"]
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/vaults/fake/ping"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
```
Start the server:
```bash
docker compose up -d
```
### Binary Installation
Download pre-built binaries from [GitHub Releases](https://github.com/schmelczer/vault-link/releases):
```bash
# Download the binary for your platform
wget https://github.com/schmelczer/vault-link/releases/latest/download/sync_server-linux-x86_64
# Make executable
chmod +x sync_server-linux-x86_64
# Run the server
./sync_server-linux-x86_64 config.yml
```
### Build from Source
Requirements: Rust 1.92.0+, SQLite development headers, SQLx CLI
```bash
# Clone the repository
git clone https://github.com/schmelczer/vault-link.git
cd vault-link/sync-server
# Install SQLx CLI
cargo install sqlx-cli
# Set up the database
sqlx database create --database-url sqlite://db.sqlite3
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3
cargo sqlx prepare --workspace
# Build in release mode
cargo build --release
# Run the server
./target/release/sync_server config.yml
```
## Configuration
Create a `config.yml` file with your server configuration:
```yaml
database:
databases_directory_path: databases
max_connections_per_vault: 12
cursor_timeout_seconds: 60
server:
host: 0.0.0.0
port: 3000
max_body_size_mb: 512
max_clients_per_vault: 256
response_timeout_seconds: 60
users:
user_configs:
- name: admin
token: your-secure-random-token-here
vault_access:
type: allow_access_to_all
logging:
log_directory: logs
log_rotation: 7days
```
### Configuration Fields
#### Database
- `databases_directory_path`: Directory for SQLite databases (one per vault)
- `max_connections_per_vault`: Maximum concurrent database connections
- `cursor_timeout_seconds`: How long to keep database cursors alive
#### Server
- `host`: Bind address (use `0.0.0.0` for all interfaces)
- `port`: Port to listen on (default: 3000)
- `max_body_size_mb`: Maximum upload size
- `max_clients_per_vault`: Concurrent client limit per vault
- `response_timeout_seconds`: Request timeout
#### Users
See [Authentication Configuration →](/config/authentication) for detailed user setup.
#### Logging
- `log_directory`: Where to store log files
- `log_rotation`: How often to rotate logs (e.g., `7days`, `24hours`)
## Production Deployment
### SSL/TLS with Reverse Proxy
VaultLink doesn't handle SSL directly. Use a reverse proxy like Nginx or Caddy.
#### Nginx Configuration
```nginx
upstream vaultlink {
server localhost:3000;
}
server {
listen 443 ssl http2;
server_name sync.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://vaultlink;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket specific
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
```
Reload Nginx:
```bash
sudo nginx -t
sudo systemctl reload nginx
```
#### Caddy Configuration
Caddy handles SSL automatically:
```caddy
sync.example.com {
reverse_proxy localhost:3000
}
```
Start Caddy:
```bash
caddy run --config Caddyfile
```
### Systemd Service
Create `/etc/systemd/system/vaultlink.service`:
```ini
[Unit]
Description=VaultLink Sync Server
After=network.target
[Service]
Type=simple
User=vaultlink
WorkingDirectory=/opt/vaultlink
ExecStart=/opt/vaultlink/sync_server /opt/vaultlink/config.yml
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
sudo systemctl daemon-reload
sudo systemctl enable vaultlink
sudo systemctl start vaultlink
sudo systemctl status vaultlink
```
### Security Best Practices
1. **Use strong tokens**: Generate with `openssl rand -hex 32`
2. **Enable firewall**: Only expose port 3000 to reverse proxy
3. **Regular updates**: Keep Docker images and binaries updated
4. **Backup databases**: SQLite files in `databases_directory_path`
5. **Monitor logs**: Check log directory for errors and anomalies
6. **Limit access**: Use vault-specific access controls per user
### Backup Strategy
The SQLite databases contain all vault data and history:
```bash
# Backup script
#!/bin/bash
BACKUP_DIR="/backup/vaultlink/$(date +%Y%m%d)"
DATA_DIR="/data/databases"
mkdir -p "$BACKUP_DIR"
cp -r "$DATA_DIR" "$BACKUP_DIR/"
# Keep 30 days of backups
find /backup/vaultlink -type d -mtime +30 -exec rm -rf {} +
```
Run daily via cron:
```cron
0 2 * * * /opt/vaultlink/backup.sh
```
### Monitoring
#### Health Checks
The server exposes a ping endpoint:
```bash
curl http://localhost:3000/vaults/test/ping
# Returns: {"server_version":"0.10.1","is_authenticated":false}
```
Replace `test` with any vault name. The endpoint returns:
- `server_version`: Current server version
- `is_authenticated`: Whether the request included a valid token
Docker health check is built-in and checks this endpoint every 30 seconds.
#### Prometheus Metrics
For advanced monitoring, collect Docker stats or implement custom metrics.
#### Log Monitoring
Logs are written to the configured `log_directory`. Monitor for:
- Connection failures
- Authentication errors
- Database errors
- WebSocket disconnections
Example log watching:
```bash
tail -f /data/logs/*.log | grep -i error
```
## Scaling
### Horizontal Scaling
VaultLink currently uses SQLite, which limits horizontal scaling. For multiple servers:
1. Run separate instances for different vaults
2. Use load balancer with sticky sessions (same vault → same server)
3. Consider database architecture for your scale needs
### Vertical Scaling
Increase resources for the server:
- More CPU for handling concurrent connections
- More RAM for database caching
- Faster storage (SSD) for database operations
Tune configuration:
- Increase `max_clients_per_vault` for more concurrent users
- Increase `max_connections_per_vault` for database performance
- Adjust `max_body_size_mb` based on typical file sizes
## Troubleshooting
### Server won't start
```bash
# Check Docker logs
docker logs vaultlink-server
# Common issues:
# - Port already in use: Change port mapping
# - Config syntax error: Validate YAML
# - Permission error: Check volume permissions
```
### High memory usage
- Reduce `max_connections_per_vault`
- Reduce `max_clients_per_vault`
- Check for large vaults (may need database optimisation)
### Database corruption
```bash
# Verify database integrity
sqlite3 databases/your-vault.db "PRAGMA integrity_check;"
# If corrupted, restore from backup
cp /backup/databases/your-vault.db /data/databases/
```
### WebSocket connection drops
- Check reverse proxy timeout settings
- Verify firewall isn't closing connections
- Review client retry intervals
- Check server logs for errors
## Next Steps
- [Configure authentication and access control →](/config/authentication)
- [Set up Obsidian plugin →](/guide/obsidian-plugin)
- [Deploy CLI client →](/guide/cli-client)
- [Understand the architecture →](/architecture/)

View file

@ -0,0 +1,71 @@
# What is VaultLink?
Self-hosted sync for Obsidian vaults with automatic conflict-free merging. Edit with any tool, collaborate in real-time, no conflict markers.
## The Problem
Syncing Obsidian vaults across devices or sharing with teammates sucks:
- **Commercial services**: Lock-in, subscriptions, third-party access to your data
- **Git**: Manual conflict resolution with `<<<<<<<` markers interrupting your workflow
- **Cloud storage**: Last-write-wins data loss or manual conflict resolution
- **CRDT solutions**: Only work if you edit inside Obsidian (break if you use Vim, VS Code, etc.)
## VaultLink's Solution
Differential synchronisation with operational transformation for Markdown and text files.
Edit `.md` and `.txt` files with Obsidian, Vim, VS Code, or any editor. VaultLink compares versions and automatically merges all changes. No operation tracking required, no conflict markers.
**Note**: Binary files (images, PDFs, etc.) use last-write-wins. [See limitations →](/guide/limitations)
## How It Works
1. **Server**: Rust WebSocket server with SQLite stores document versions
2. **Clients**: Obsidian plugin or CLI client watches filesystem changes
3. **Sync**: Changes upload to server, server broadcasts to other clients
4. **Merge**: [reconcile-text](https://schmelczer.dev/reconcile) automatically merges concurrent edits
No CRDT infrastructure. No operation logs. Just file comparison and smart merging.
## Key Advantages
**Editor agnostic**: Edit files with any tool. Other solutions break when you edit outside their ecosystem.
**Self-hosted**: Your data, your server. No third parties, no subscriptions, no surprises.
**Automatic merging**: Operational transformation handles conflicts without interrupting your workflow.
**Production-ready**: Comprehensive tests, E2E tests, battle-tested. Many alternatives have zero tests.
**Collaborative**: Real-time sync with cursor tracking. See where teammates are editing.
## Not Tied to Obsidian
VaultLink syncs Markdown files. Use it for:
- Obsidian vaults (Obsidian desktop + mobile + CLI)
- Technical documentation (VS Code, your-editor, CLI)
- Academic writing (multiple Markdown editors)
- Automated workflows (CLI client for backups/CI/CD)
The Obsidian plugin is just a convenience wrapper around the sync client.
## Quick Comparison
| Feature | VaultLink | Git | Cloud Sync | CRDT Solutions |
| ------------------- | --------- | --- | ---------- | -------------- |
| Self-hosted | ✅ | ✅ | ❌ | Varies |
| Any editor | ✅ | ✅ | ✅ | ❌ |
| No conflict markers | ✅ | ❌ | ❌ | ✅ |
| Real-time | ✅ | ❌ | ❌ | ✅ |
| No subscriptions | ✅ | ✅ | ❌ | Varies |
| Comprehensive tests | ✅ | N/A | N/A | ❌ |
[Detailed comparison with alternatives →](/guide/alternatives)
## Next Steps
- [Get started →](/guide/getting-started) (5 minute setup)
- [See the architecture →](/architecture/) (understand how it works)
- [Compare alternatives →](/guide/alternatives) (why VaultLink vs others)

55
docs/index.md Normal file
View file

@ -0,0 +1,55 @@
---
layout: home
hero:
name: VaultLink
text: Self-Hosted Obsidian Sync
tagline: Edit with any tool. Automatic conflict-free merging. Your infrastructure.
image:
src: /logo.svg
alt: VaultLink
actions:
- theme: brand
text: Get Started
link: /guide/getting-started
- theme: alt
text: Why VaultLink?
link: /guide/what-is-vaultlink
features:
- title: Edit Anywhere
details: Use Obsidian, Vim, VS Code, or any editor. VaultLink syncs files, not keystrokes—edit however you want
- title: Your Data, Your Server
details: Fully self-hosted. No third parties, no subscriptions, no data mining. Single Docker container or binary
- title: No Conflict Markers
details: Automatic merge using operational transformation. Never see conflict markers in your notes again
- title: Real-Time Collaboration
details: See teammate cursors, merge edits instantly. Rust-powered WebSocket server with SQLite
- title: Open Source Everything
details: MIT licensed. Server, clients, and sync algorithm are all open source. No proprietary components
- title: Battle-Tested
details: Comprehensive test suite. E2E tests. Used in production. Unlike alternatives with zero tests
---
## Why Self-Host?
**You own your knowledge base.** Commercial sync services can disappear, change pricing, or lock you out. VaultLink runs on your infrastructure—VPS, home server, or localhost.
**Edit with any tool.** Other solutions require CRDT-aware editors or break when you edit outside Obsidian. VaultLink uses differential sync: edit files however you want, sync handles the rest.
**No conflict markers.** Git forces manual merging. Other tools use last-write-wins. VaultLink's operational transformation automatically merges Markdown and text files without conflict markers or workflow interruption. [See what's supported →](/guide/limitations)
[See how VaultLink compares to alternatives →](/guide/alternatives)
## Quick Start
Deploy server (single command):
```bash
docker run -d -p 3000:3000 -v $(pwd)/data:/data \
ghcr.io/schmelczer/vault-link-server:latest
```
Then install the [Obsidian plugin](/guide/obsidian-plugin) or [CLI client](/guide/cli-client).
[Full setup guide →](/guide/getting-started)

2989
docs/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

25
docs/package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "docs",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "vitepress dev --host",
"build": "vitepress build",
"preview": "vitepress preview",
"format": "prettier --write \"**/*.md\" \"**/*.mts\"",
"format:check": "prettier --check \"**/*.md\" \"**/*.mts\"",
"spell": "cspell \"**/*.md\" \"**/*.mts\"",
"spell:check": "cspell \"**/*.md\" \"**/*.mts\""
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@cspell/dict-en-gb": "^5.0.19",
"cspell": "^9.3.2",
"prettier": "^3.6.2",
"vitepress": "^1.6.4",
"vue": "^3.5.24"
}
}

47
docs/public/logo.svg Normal file
View file

@ -0,0 +1,47 @@
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4A90E2;stop-opacity:1" />
<stop offset="100%" style="stop-color:#357ABD;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background circle -->
<circle cx="100" cy="100" r="90" fill="url(#grad1)" opacity="0.15"/>
<!-- Main vault icon -->
<g transform="translate(100, 100)">
<!-- Vault body -->
<rect x="-45" y="-50" width="90" height="80" rx="8" fill="none" stroke="url(#grad1)" stroke-width="6"/>
<!-- Vault door circle -->
<circle cx="0" cy="-10" r="22" fill="none" stroke="url(#grad1)" stroke-width="5"/>
<circle cx="0" cy="-10" r="14" fill="none" stroke="url(#grad1)" stroke-width="3"/>
<circle cx="0" cy="-10" r="6" fill="url(#grad1)"/>
<!-- Vault handle -->
<line x1="0" y1="-4" x2="18" y2="-4" stroke="url(#grad1)" stroke-width="3" stroke-linecap="round"/>
<circle cx="18" cy="-4" r="4" fill="url(#grad1)"/>
<!-- Link chain -->
<g opacity="0.9">
<!-- Left link -->
<ellipse cx="-30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
<!-- Right link -->
<ellipse cx="30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
<!-- Center link connecting them -->
<ellipse cx="0" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
</g>
<!-- Sync arrows (subtle) -->
<g opacity="0.5">
<!-- Clockwise arrow top-right -->
<path d="M 35 -35 Q 50 -35 50 -20 L 50 -15" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
<polygon points="50,-15 47,-22 53,-22" fill="url(#grad1)"/>
<!-- Counter-clockwise arrow bottom-left -->
<path d="M -35 25 Q -50 25 -50 10 L -50 5" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
<polygon points="-50,5 -47,12 -53,12" fill="url(#grad1)"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,118 @@
# Deterministic Tests
Scripted multi-client (with an in-memory filesystem) sync tests that run against a real server. Each test defines a sequence of file operations, sync/server controls, and assertions to exercise a specific conflict or edge case.
Complements the fuzz-based E2E tests (`test-client`): fuzz tests discover bugs through random operations; deterministic tests pin down exact reproduction sequences for known scenarios.
## How it works
Each test is a `TestDefinition`: a client count and an ordered list of steps. The test name is derived from the registry key (which matches the file name). The `TestRunner` spins up N `DeterministicAgent` instances (each wrapping a real `SyncClient` with an `InMemoryFileSystem`) pointed at a shared vault on the server, then executes steps one by one.
Tests that don't pause the server share a single server process (vault-name isolation). Tests that use `pause-server`/`resume-server` (SIGSTOP/SIGCONT) each get a dedicated server, since SIGSTOP freezes the entire process.
The runner executes two sequential phases: regular tests on the shared server, then pause-server tests on dedicated servers. Within each phase tests run in parallel up to a concurrency limit.
## Step types
Clients always start with syncing disabled.
**File operations** (per-client, fire-and-forget — sync is enqueued but not awaited):
- `create`, `update`, `rename`, `delete`
- `rename-next-write` — arm a deferred rename that fires the next time the given path is written. Lets a test race a user-rename against an in-flight remote create that's about to land at the same path.
**Sync control:**
- `sync` — wait for a specific client or all clients to finish pending operations
- `barrier` — retry until all clients converge to identical file state (60s timeout)
- `enable-sync` / `disable-sync` — simulate going online/offline
- `reset` — reset a client's tracked sync state (keeps disk files); equivalent to a forced re-handshake on next enable
- `sleep` — wall-clock pause; use sparingly, prefer `barrier` / `sync`
**WebSocket control** (per-client):
- `pause-websocket` / `resume-websocket` — buffer/release WebSocket messages for a specific client
**Server control:**
- `pause-server` / `resume-server` — SIGSTOP/SIGCONT the server process
- `resume-server-until-history-then-pause` — resume the server, wait until a specific client observes a matching history entry (`CREATE`/`UPDATE`/`DELETE` for a path), then re-pause. Used to land exactly one operation across the wire.
**Fault injection** (per-client):
- `drop-next-create-response` — arm a one-shot interceptor that lets the next `POST /documents` reach the server (commit happens) but throws `SyncResetError` before the client sees the response, simulating connection loss after server commit.
- `wait-for-dropped-create-response` — wait until the armed drop has fired.
**Assertions:**
- `assert-consistent` — all clients have identical files; optionally takes a custom `verify(state: AssertableState)` callback
## Running
```sh
# Build server first
cd sync-server && cargo build --release && cd -
# Run all tests
cd frontend && npm run build -w sync-client && npm run test -w deterministic-tests
# Filter by name
npm run test -w deterministic-tests -- --filter=rename
# Control parallelism (default: number of CPU cores)
npm run test -w deterministic-tests -- -j 4
```
## Adding a test
1. Create `src/tests/my-scenario.test.ts`:
```typescript
import type { TestDefinition } from "../test-definition";
export const myScenarioTest: TestDefinition = {
description:
"Client 0 creates A.md offline. After syncing, both clients should have the file.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "A.md", content: "hello" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) => {
s.assertFileCount(1).assertContent("A.md", "hello");
}
}
]
};
```
The `verify` callback receives an `AssertableState` object with chainable assertion methods:
```typescript
s.assertFileCount(n); // exact file count
s.assertFileExists("path"); // file must exist
s.assertFileNotExists("path"); // file must not exist
s.assertContent("path", "expected"); // exact content match
s.assertContains("path", "a", "b"); // all substrings present in file
s.assertContainsAny("path", "a", "b"); // at least one substring present
s.assertAnyFileContains("text"); // substring present in some file
s.assertNoFileContains("text"); // substring absent from every file
s.assertSubstringCount("path", "x", 3); // substring appears exactly N times
s.assertContentInAtMostOneFile("text"); // no duplicate content
s.ifFileExists("path", (s) => { /* … */ }); // conditional block
s.getContent("path"); // raw content (or "" if missing)
```
2. Register it in `src/test-registry.ts`:
```typescript
import { myScenarioTest } from "./tests/my-scenario.test";
const TESTS = {
// ...
"my-scenario": myScenarioTest
};
```

View file

@ -0,0 +1,23 @@
{
"name": "deterministic-tests",
"version": "0.14.0",
"private": true,
"bin": {
"deterministic-tests": "./dist/cli.js"
},
"scripts": {
"dev": "webpack watch --mode development",
"build": "webpack --mode production",
"test": "npm run build && node dist/cli.js"
},
"devDependencies": {
"commander": "^14.0.2",
"@types/node": "^25.0.2",
"sync-client": "file:../sync-client",
"ts-loader": "^9.5.4",
"tslib": "2.8.1",
"typescript": "5.9.3",
"webpack": "^5.103.0",
"webpack-cli": "^6.0.1"
}
}

View file

@ -0,0 +1,243 @@
import { TestRunner } from "./test-runner";
import { ServerControl } from "./server-control";
import { ServerManager } from "./server-manager";
import { PrefixedLogger } from "./prefixed-logger";
import { TESTS } from "./test-registry";
import type { TestDefinition, TestResult } from "./test-definition";
import { parseArgs } from "./parse-args";
import { runWithConcurrency } from "./run-with-concurrency";
import { TOKEN, SERVER_BINARY_PATH, CONFIG_PATH } from "./consts";
import * as path from "node:path";
import * as fs from "node:fs";
import { debugging, Logger } from "sync-client";
const logger = new Logger();
debugging.logToConsole(logger, { useColors: true });
process.on("unhandledRejection", (reason) => {
logger.error(`Unhandled Rejection: ${reason}`);
process.exit(1);
});
process.on("uncaughtException", (error) => {
logger.error(`Uncaught Exception: ${error}`);
process.exit(1);
});
const serverManager = new ServerManager(logger);
serverManager.installSignalHandlers();
function testUsesPauseServer(test: TestDefinition): boolean {
return test.steps.some(
(step) =>
step.type === "pause-server" ||
step.type === "resume-server" ||
step.type === "resume-server-until-history-then-pause"
);
}
/**
* Walk up from the CLI binary's location until we find a directory
* containing `sync-server/` and `frontend/`.
*/
function findProjectRoot(): string {
let dir = path.dirname(__filename);
const root = path.parse(dir).root;
while (dir !== root) {
if (
fs.existsSync(path.join(dir, "sync-server")) &&
fs.existsSync(path.join(dir, "frontend"))
) {
return dir;
}
dir = path.dirname(dir);
}
throw new Error(
`Could not locate project root (no ancestor of ${__filename} contains both 'sync-server' and 'frontend')`
);
}
interface NamedTestResult {
name: string;
result: TestResult;
}
async function runSharedServerTest(
name: string,
test: TestDefinition,
sharedServer: ServerControl
): Promise<NamedTestResult> {
const testLogger = new PrefixedLogger(logger, name);
const runner = new TestRunner(
sharedServer,
testLogger,
TOKEN,
sharedServer.remoteUri
);
const result = await runner.runTest(name, test);
if (result.success) {
logger.info(`PASSED: ${name} (${result.duration}ms)`);
} else {
logger.error(`FAILED: ${name} - ${result.error}`);
}
return { name, result };
}
/**
* Run a test with its own dedicated server (for tests that use pause-server).
* SIGSTOP/SIGCONT affects the entire server process, so these tests need
* isolated servers to avoid interfering with other tests.
*/
async function runDedicatedServerTest(
name: string,
test: TestDefinition,
serverPath: string,
configPath: string
): Promise<NamedTestResult> {
const testLogger = new PrefixedLogger(logger, name);
const server = new ServerControl(serverPath, configPath, testLogger);
serverManager.track(server);
try {
await server.start();
const runner = new TestRunner(
server,
testLogger,
TOKEN,
server.remoteUri
);
const result = await runner.runTest(name, test);
if (result.success) {
logger.info(`PASSED: ${name} (${result.duration}ms)`);
} else {
logger.error(`FAILED: ${name} - ${result.error}`);
}
return { name, result };
} finally {
try {
await server.stop();
} catch {
// best-effort cleanup
}
serverManager.untrack(server);
}
}
async function main(): Promise<void> {
const projectRoot = findProjectRoot();
const serverPath = path.join(projectRoot, SERVER_BINARY_PATH);
if (!fs.existsSync(serverPath)) {
logger.error(`Server binary not found at: ${serverPath}`);
process.exit(1);
}
const configPath = path.join(projectRoot, CONFIG_PATH);
if (!fs.existsSync(configPath)) {
logger.error(`Config file not found at: ${configPath}`);
process.exit(1);
}
const { filter, concurrency } = parseArgs(process.argv);
const testsToRun: [string, TestDefinition][] = [];
for (const [key, test] of Object.entries(TESTS)) {
if (test) {
if (
filter !== undefined &&
filter.length > 0 &&
!key.includes(filter)
) {
continue;
}
testsToRun.push([key, test]);
}
}
if (testsToRun.length === 0) {
logger.error(
filter !== undefined && filter.length > 0
? `No tests matched filter "${filter}"`
: "No tests found"
);
process.exit(1);
}
const regularTests = testsToRun.filter(([, t]) => !testUsesPauseServer(t));
const pauseTests = testsToRun.filter(([, t]) => testUsesPauseServer(t));
logger.info(`Server: ${serverPath}`);
logger.info(`Config: ${configPath}`);
logger.info(
`Tests: ${testsToRun.length} total (${regularTests.length} regular, ${pauseTests.length} server-pause)`
);
logger.info(`Concurrency: ${concurrency}`);
const allResults: NamedTestResult[] = [];
if (regularTests.length > 0) {
logger.info(
`\n--- Running ${regularTests.length} regular tests (shared server, concurrency ${concurrency}) ---`
);
const sharedServer = new ServerControl(serverPath, configPath, logger);
serverManager.track(sharedServer);
try {
await sharedServer.start();
const results = await runWithConcurrency(
regularTests,
concurrency,
async ([name, test]) =>
runSharedServerTest(name, test, sharedServer)
);
allResults.push(...results);
} finally {
try {
await sharedServer.stop();
} catch (error) {
logger.warn(
`Error stopping shared server: ${error instanceof Error ? error.message : String(error)}`
);
}
serverManager.untrack(sharedServer);
}
}
if (pauseTests.length > 0) {
logger.info(
`\n--- Running ${pauseTests.length} server-pause tests (dedicated servers, concurrency ${concurrency}) ---`
);
const results = await runWithConcurrency(
pauseTests,
concurrency,
async ([name, test]) =>
runDedicatedServerTest(name, test, serverPath, configPath)
);
allResults.push(...results);
}
const passed = allResults.filter((r) => r.result.success);
const failed = allResults.filter((r) => !r.result.success);
logger.info(
`\n--- Results: ${passed.length}/${allResults.length} passed ---`
);
if (failed.length > 0) {
for (const { name, result } of failed) {
logger.error(` FAILED: ${name}: ${result.error}`);
}
process.exit(1);
} else {
logger.info("All tests passed!");
process.exit(0);
}
}
main().catch((err: unknown) => {
logger.error(`Unexpected error: ${err}`);
process.exit(1);
});

View file

@ -0,0 +1,17 @@
export const TOKEN = "test-token-change-me";
export const SERVER_BINARY_PATH = "sync-server/target/release/sync_server";
export const CONFIG_PATH = "sync-server/config-e2e.yml";
export const STOP_TIMEOUT_MS = 5_000;
export const CONVERGENCE_TIMEOUT_MS = 60_000;
export const CONVERGENCE_RETRY_DELAY_MS = 500;
export const AGENT_INIT_TIMEOUT_MS = 30_000;
export const IS_SYNC_ENABLED_BY_DEFAULT = false;
export const WAIT_TIMEOUT_MS = 60_000;
export const WEBSOCKET_CONNECT_TIMEOUT_MS = 10_000;
export const WEBSOCKET_POLL_INTERVAL_MS = 50;
export const SERVER_READY_POLL_INTERVAL_MS = 100;
export const SERVER_READY_MAX_ATTEMPTS = 50;
export const SERVER_START_MAX_ATTEMPTS = 5;

View file

@ -0,0 +1,483 @@
import type {
HistoryEntry,
StoredDatabase,
SyncSettings,
RelativePath,
TextWithCursors
} from "sync-client";
import {
SyncClient,
SyncResetError,
debugging,
LogLevel,
utils
} from "sync-client";
import { assert } from "./utils/assert";
import { sleep } from "./utils/sleep";
import { withTimeout } from "./utils/with-timeout";
import {
IS_SYNC_ENABLED_BY_DEFAULT,
WAIT_TIMEOUT_MS,
WEBSOCKET_CONNECT_TIMEOUT_MS,
WEBSOCKET_POLL_INTERVAL_MS
} from "./consts";
import { ManagedWebSocketFactory } from "./managed-websocket";
export class DeterministicAgent extends debugging.InMemoryFileSystem {
public readonly clientId: number;
private readonly logger: (msg: string) => void;
private client!: SyncClient;
private data: Partial<{
settings: Partial<SyncSettings>;
database: Partial<StoredDatabase>;
}> = {};
private isSyncEnabled = IS_SYNC_ENABLED_BY_DEFAULT;
private readonly syncErrors: Error[] = [];
private readonly pendingSyncOperations = new Set<Promise<void>>();
private readonly wsFactory = new ManagedWebSocketFactory();
private nextWriteRename:
| {
oldPath: RelativePath;
newPath: RelativePath;
}
| undefined;
private nextCreateResponseDrop:
| {
dropped: Promise<void>;
resolveDropped: () => void;
}
| undefined;
public constructor(
clientId: number,
initialSettings: Partial<SyncSettings>,
logger: (msg: string) => void
) {
super();
this.clientId = clientId;
this.logger = logger;
this.data.settings = { ...initialSettings };
}
public async init(
fetchImplementation: typeof globalThis.fetch
): Promise<void> {
this.client = await SyncClient.create({
fs: this,
persistence: {
load: async () => this.data,
save: async (data) => void (this.data = data)
},
fetch: this.wrapFetch(fetchImplementation),
webSocket: this.wsFactory.constructorFn
});
this.client.logger.onLogEmitted.add((line) => {
const prefix = `[Client ${this.clientId}]`;
switch (line.level) {
case LogLevel.ERROR:
this.logger(`${prefix} ERROR: ${line.message}`);
break;
case LogLevel.WARNING:
this.logger(`${prefix} WARN: ${line.message}`);
break;
case LogLevel.INFO:
this.logger(`${prefix} INFO: ${line.message}`);
break;
case LogLevel.DEBUG:
this.logger(`${prefix} DEBUG: ${line.message}`);
break;
}
});
await this.client.start();
const connectionCheck = await this.client.checkConnection();
assert(
connectionCheck.isSuccessful,
`Client ${this.clientId} connection check failed`
);
if (this.isSyncEnabled) {
await this.waitForWebSocket();
}
}
public pauseWebSocket(): void {
this.log("Pausing WebSocket message delivery");
this.wsFactory.pause();
}
public resumeWebSocket(): void {
this.log("Resuming WebSocket message delivery");
this.wsFactory.resume();
}
public dropNextCreateResponse(): void {
assert(
this.nextCreateResponseDrop === undefined,
`Client ${this.clientId} already has a create response drop armed`
);
let resolveDropped!: () => void;
const dropped = new Promise<void>((resolve) => {
resolveDropped = resolve;
});
this.nextCreateResponseDrop = {
dropped,
resolveDropped
};
this.log("Armed next create response drop");
}
public async waitForDroppedCreateResponse(): Promise<void> {
assert(
this.nextCreateResponseDrop !== undefined,
`Client ${this.clientId} has no create response drop armed`
);
await withTimeout(
this.nextCreateResponseDrop.dropped,
WAIT_TIMEOUT_MS,
`Client ${this.clientId} timed out waiting for create response drop`
);
this.log("Create response was dropped after server commit");
}
public async waitForHistoryEntry(
matches: (entry: HistoryEntry) => boolean,
onMatch?: (entry: HistoryEntry) => void
): Promise<void> {
const existing = this.client.getHistoryEntries().find(matches);
if (existing !== undefined) {
onMatch?.(existing);
return;
}
await withTimeout(
new Promise<void>((resolve) => {
const unsubscribe = this.client.onSyncHistoryUpdated.add(() => {
const entry = this.client
.getHistoryEntries()
.find(matches);
if (entry === undefined) {
return;
}
unsubscribe();
onMatch?.(entry);
resolve();
});
}),
WAIT_TIMEOUT_MS,
`Client ${this.clientId} timed out waiting for history entry`
);
}
public async waitForSync(): Promise<void> {
this.log("Waiting for sync to complete...");
// Drain agent-level sync operations first. These are the fire-and-forget
// promises from enqueueSync() that call into the SyncClient's methods.
// Without this, waitUntilFinished() might return before the SyncClient
// has even been told about the operation.
await this.drainPendingSyncOperations();
await withTimeout(
this.client.waitUntilFinished(),
WAIT_TIMEOUT_MS,
`Client ${this.clientId} waitForSync timed out after ${WAIT_TIMEOUT_MS}ms`
);
if (this.syncErrors.length > 0) {
const errors = this.syncErrors.splice(0);
throw new Error(
`Client ${this.clientId} had ${errors.length} sync error(s):\n${errors.map((e) => e.message).join("\n")}`
);
}
this.log("Sync complete");
}
public async reset(): Promise<void> {
this.log("Resetting client (clears tracked state, keeps disk files)");
await this.drainPendingSyncOperations();
await this.client.reset();
if (this.isSyncEnabled) {
await this.waitForWebSocket();
}
}
public async disableSync(): Promise<void> {
this.log("Disabling sync");
// Drain pending enqueued operations before disabling so the SyncClient
// knows about all operations that were enqueued while sync was enabled.
await this.drainPendingSyncOperations();
await this.client.setSetting("isSyncEnabled", false);
this.isSyncEnabled = false;
// Wait for in-flight operations to drain. Disabling sync triggers
// a reset, which aborts in-flight fetches with SyncResetError.
try {
await withTimeout(
this.client.waitUntilFinished(),
WAIT_TIMEOUT_MS,
`Client ${this.clientId} disableSync drain timed out`
);
} catch (error) {
if (error instanceof Error && error.name === "SyncResetError") {
this.log("Disable sync drain interrupted by reset (expected)");
} else {
throw error;
}
}
}
public async enableSync(): Promise<void> {
this.log("Enabling sync");
await this.client.setSetting("isSyncEnabled", true);
this.isSyncEnabled = true;
await this.waitForWebSocket();
}
public async getFileContent(path: string): Promise<string> {
const bytes = await this.read(path);
return new TextDecoder().decode(bytes);
}
public renameNextWrite(oldPath: RelativePath, newPath: RelativePath): void {
assert(
this.nextWriteRename === undefined,
`Client ${this.clientId} already has a next-write rename armed`
);
this.nextWriteRename = { oldPath, newPath };
this.log(`Armed next write rename: ${oldPath} -> ${newPath}`);
}
public async cleanup(): Promise<void> {
this.log("Cleaning up...");
// Guard against uninitialized client (init() failed partway).
// The class field uses `!:` so TS thinks this is always defined,
// but at runtime it can be undefined when init() throws partway.
const maybeClient = this.client as SyncClient | undefined;
if (maybeClient === undefined) {
this.log("Client not initialized, nothing to clean up");
return;
}
try {
await this.drainPendingSyncOperations();
await withTimeout(
this.client.waitUntilFinished(),
WAIT_TIMEOUT_MS,
`Client ${this.clientId} cleanup waitUntilFinished timed out`
);
} catch (error) {
if (error instanceof Error && error.name === "SyncResetError") {
this.log(`Cleanup interrupted by reset (expected): ${error}`);
} else {
this.log(`Cleanup waitUntilFinished failed: ${error}`);
}
}
// Surface any background sync errors that arrived after the last
// waitForSync (e.g. between the final assert-consistent and here).
// Without this, regressions that fault the engine during the very
// last step of a test would be silently swallowed.
const pendingErrors = this.syncErrors.splice(0);
await this.client.destroy();
this.log("Cleanup complete");
if (pendingErrors.length > 0) {
throw new Error(
`Client ${this.clientId} had ${pendingErrors.length} background sync error(s) during cleanup:\n${pendingErrors.map((e) => e.message).join("\n")}`
);
}
}
public override async read(path: RelativePath): Promise<Uint8Array> {
await Promise.resolve();
return super.read(path);
}
public override async write(
path: RelativePath,
content: Uint8Array
): Promise<void> {
await Promise.resolve();
const isNew = !this.files.has(path);
await super.write(path, content);
if (this.isSyncEnabled && isNew) {
this.enqueueSync(async () => {
this.client.syncLocallyCreatedFile(path);
});
}
const nextWriteRename = this.nextWriteRename;
if (
nextWriteRename !== undefined &&
nextWriteRename.oldPath === path
) {
this.nextWriteRename = undefined;
await super.rename(
nextWriteRename.oldPath,
nextWriteRename.newPath
);
if (this.isSyncEnabled) {
this.enqueueSync(async () => {
this.client.syncLocallyUpdatedFile({
oldPath: nextWriteRename.oldPath,
relativePath: nextWriteRename.newPath
});
});
}
// The rename consumed `path`. Skip the post-update enqueue below
// — it would send a syncLocallyUpdatedFile for a path that no
// longer exists.
return;
}
if (!this.isSyncEnabled) {
return;
}
if (!isNew) {
this.enqueueSync(async () => {
this.client.syncLocallyUpdatedFile({ relativePath: path });
});
}
}
public override async atomicUpdateText(
path: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> {
const result = await super.atomicUpdateText(path, updater);
if (this.isSyncEnabled) {
this.enqueueSync(async () => {
this.client.syncLocallyUpdatedFile({ relativePath: path });
});
}
return result;
}
public override async delete(path: RelativePath): Promise<void> {
await super.delete(path);
if (this.isSyncEnabled) {
this.enqueueSync(async () => {
this.client.syncLocallyDeletedFile(path);
});
}
}
public override async rename(
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
await super.rename(oldPath, newPath);
if (this.isSyncEnabled) {
this.enqueueSync(async () => {
this.client.syncLocallyUpdatedFile({
oldPath,
relativePath: newPath
});
});
}
}
private async waitForWebSocket(): Promise<void> {
const deadline = Date.now() + WEBSOCKET_CONNECT_TIMEOUT_MS;
while (!this.client.isWebSocketConnected && Date.now() < deadline) {
await sleep(WEBSOCKET_POLL_INTERVAL_MS);
}
assert(
this.client.isWebSocketConnected,
`Client ${this.clientId} WebSocket failed to connect within ${WEBSOCKET_CONNECT_TIMEOUT_MS}ms`
);
}
/**
* Wait until all agent-level enqueued sync operations have completed.
* Uses a loop because completing one operation can trigger new enqueues.
*/
private async drainPendingSyncOperations(): Promise<void> {
while (this.pendingSyncOperations.size > 0) {
await utils.awaitAll([...this.pendingSyncOperations]);
}
}
private enqueueSync(operation: () => Promise<void>): void {
const promise = this.executeSyncOperation(operation).catch(
(error: unknown) => {
const err =
error instanceof Error ? error : new Error(String(error));
this.log(`Background sync failed: ${err.message}`);
this.syncErrors.push(err);
}
);
this.pendingSyncOperations.add(promise);
void promise.finally(() => {
this.pendingSyncOperations.delete(promise);
});
}
private async executeSyncOperation(
operation: () => Promise<void>
): Promise<void> {
try {
await operation();
} catch (error) {
if (error instanceof Error && error.name === "SyncResetError") {
this.log(`Sync operation interrupted by reset: ${error}`);
return;
}
if (
error instanceof Error &&
error.message.includes("has been destroyed")
) {
this.log(`Sync operation interrupted by destroy: ${error}`);
return;
}
throw error;
}
}
private log(message: string): void {
this.logger(`[Client ${this.clientId}] ${message}`);
}
private wrapFetch(
fetchImplementation: typeof globalThis.fetch
): typeof globalThis.fetch {
return async (input, init) => {
const response = await fetchImplementation(input, init);
const drop = this.nextCreateResponseDrop;
if (
drop !== undefined &&
DeterministicAgent.isCreateDocumentRequest(input, init)
) {
this.nextCreateResponseDrop = undefined;
try {
await response.body?.cancel();
} catch {
// Best-effort — body may already be consumed/closed.
}
drop.resolveDropped();
throw new SyncResetError();
}
return response;
};
}
private static isCreateDocumentRequest(
input: RequestInfo | URL,
init: RequestInit | undefined
): boolean {
const method =
init?.method ??
(typeof Request !== "undefined" && input instanceof Request
? input.method
: "GET");
if (method.toUpperCase() !== "POST") {
return false;
}
const url =
input instanceof URL
? input
: new URL(typeof input === "string" ? input : input.url);
return /\/documents\/?$/.test(url.pathname);
}
}

View file

@ -0,0 +1,245 @@
/**
* A WebSocket wrapper that can pause and resume message delivery.
* When paused, incoming messages are buffered. When resumed, buffered
* messages are delivered in order via the onmessage handler.
*
* Member layout follows typescript-eslint default member-ordering: all
* accessor properties are declared with `declare` and wired through the
* constructor using Object.defineProperty so we don't need conflicting
* get/set accessor pairs.
*/
class ManagedWebSocket implements WebSocket {
public static readonly CONNECTING = WebSocket.CONNECTING;
public static readonly OPEN = WebSocket.OPEN;
public static readonly CLOSING = WebSocket.CLOSING;
public static readonly CLOSED = WebSocket.CLOSED;
public readonly CONNECTING = WebSocket.CONNECTING;
public readonly OPEN = WebSocket.OPEN;
public readonly CLOSING = WebSocket.CLOSING;
public readonly CLOSED = WebSocket.CLOSED;
declare public readonly readyState: number;
declare public readonly url: string;
declare public readonly protocol: string;
declare public readonly extensions: string;
declare public readonly bufferedAmount: number;
declare public binaryType: BinaryType;
declare public onopen: ((this: WebSocket, ev: Event) => unknown) | null;
declare public onclose:
| ((this: WebSocket, ev: CloseEvent) => unknown)
| null;
declare public onerror: ((this: WebSocket, ev: Event) => unknown) | null;
declare public onmessage:
| ((this: WebSocket, ev: MessageEvent) => unknown)
| null;
private readonly ws: WebSocket;
private readonly bufferedMessages: MessageEvent[] = [];
private paused = false;
private externalOnMessage: ((event: MessageEvent) => unknown) | null = null;
public constructor(url: string | URL, protocols?: string | string[]) {
this.ws = new WebSocket(url, protocols);
const { ws } = this;
Object.defineProperties(this, {
readyState: {
get: (): number => ws.readyState,
enumerable: true,
configurable: true
},
url: {
get: (): string => ws.url,
enumerable: true,
configurable: true
},
protocol: {
get: (): string => ws.protocol,
enumerable: true,
configurable: true
},
extensions: {
get: (): string => ws.extensions,
enumerable: true,
configurable: true
},
bufferedAmount: {
get: (): number => ws.bufferedAmount,
enumerable: true,
configurable: true
},
binaryType: {
get: (): BinaryType => ws.binaryType,
set: (v: BinaryType): void => {
ws.binaryType = v;
},
enumerable: true,
configurable: true
},
onopen: {
get: (): ((this: WebSocket, ev: Event) => unknown) | null =>
ws.onopen,
set: (
h: ((this: WebSocket, ev: Event) => unknown) | null
): void => {
ws.onopen = h;
},
enumerable: true,
configurable: true
},
onclose: {
get: ():
| ((this: WebSocket, ev: CloseEvent) => unknown)
| null => ws.onclose,
set: (
h: ((this: WebSocket, ev: CloseEvent) => unknown) | null
): void => {
ws.onclose = h;
},
enumerable: true,
configurable: true
},
onerror: {
get: (): ((this: WebSocket, ev: Event) => unknown) | null =>
ws.onerror,
set: (
h: ((this: WebSocket, ev: Event) => unknown) | null
): void => {
ws.onerror = h;
},
enumerable: true,
configurable: true
},
onmessage: {
get: ():
| ((this: WebSocket, ev: MessageEvent) => unknown)
| null => this.externalOnMessage,
set: (
h: ((this: WebSocket, ev: MessageEvent) => unknown) | null
): void => {
this.externalOnMessage = h;
},
enumerable: true,
configurable: true
}
});
this.ws.onmessage = (event: MessageEvent): void => {
if (this.paused) {
this.bufferedMessages.push(event);
} else {
this.externalOnMessage?.(event);
}
};
}
public pause(): void {
this.paused = true;
}
public resume(): void {
// Drain buffered messages BEFORE flipping `paused` to false.
// If `externalOnMessage` is async (its return type is `unknown`),
// dispatch yields control between buffered messages, and a fresh
// live `ws.onmessage` event firing during that yield would jump
// ahead of unprocessed buffered messages — silently reordering
// events relative to the wire. Keeping `paused = true` during the
// drain forces the live handler to keep buffering, so we splice
// those late arrivals onto the tail and dispatch them in order.
while (this.bufferedMessages.length > 0) {
const messages = this.bufferedMessages.splice(0);
for (const msg of messages) {
this.externalOnMessage?.(msg);
}
}
this.paused = false;
}
public send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
this.ws.send(data);
}
public close(code?: number, reason?: string): void {
this.ws.close(code, reason);
}
public addEventListener(
...args: Parameters<WebSocket["addEventListener"]>
): void {
// Only the `.onmessage` setter routes through the pause buffer.
// If sync-client ever attaches "message" listeners via
// addEventListener instead, those messages would bypass pause/resume
// and deterministic tests would silently lose their fault injection.
if (args[0] === "message") {
throw new Error(
"ManagedWebSocket: addEventListener('message') bypasses the " +
"pause buffer. Use the .onmessage setter instead, or " +
"extend ManagedWebSocket to route message listeners."
);
}
this.ws.addEventListener(...args);
}
public removeEventListener(
...args: Parameters<WebSocket["removeEventListener"]>
): void {
this.ws.removeEventListener(...args);
}
public dispatchEvent(event: Event): boolean {
return this.ws.dispatchEvent(event);
}
}
/**
* Factory that creates ManagedWebSocket instances and tracks them
* for pause/resume control from the test harness
*/
export class ManagedWebSocketFactory {
// Append-only: closed sockets stay tracked. Bounded per test (one
// factory per agent, each test discards its agents on cleanup), so
// not a real leak — but iterating over closed instances on
// pause/resume is a deliberate no-op since their `.onmessage` is
// already detached.
private readonly instances: ManagedWebSocket[] = [];
// Sticky pause state: applied to current instances on `pause()` AND
// to any new instance created later (e.g. WS reconnect after a
// `disable-sync` / `reset` cycle). Without this, a test pausing the
// WS before the agent reconnects would silently see the new socket
// start un-paused and miss the messages it meant to buffer.
private currentlyPaused = false;
public get constructorFn(): typeof globalThis.WebSocket {
const trackInstance = (instance: ManagedWebSocket): void => {
this.instances.push(instance);
if (this.currentlyPaused) {
instance.pause();
}
};
class TrackedManagedWebSocket extends ManagedWebSocket {
public constructor(
url: string | URL,
protocols?: string | string[]
) {
super(url, protocols);
trackInstance(this);
}
}
return TrackedManagedWebSocket;
}
public pause(): void {
this.currentlyPaused = true;
for (const ws of this.instances) {
ws.pause();
}
}
public resume(): void {
this.currentlyPaused = false;
for (const ws of this.instances) {
ws.resume();
}
}
}

Some files were not shown because too many files have changed in this diff Show more