mirror of
https://github.com/logseq/logseq.git
synced 2026-05-24 20:54:09 +00:00
feat(cli): unify backup impl for desktop and cli
This commit is contained in:
477
docs/agent-guide/db-worker/005-unified-graph-backup.md
Normal file
477
docs/agent-guide/db-worker/005-unified-graph-backup.md
Normal file
@@ -0,0 +1,477 @@
|
||||
# Unified Graph Backup Implementation Plan
|
||||
|
||||
Goal: Unify Desktop app and `logseq-cli` graph backup behavior around one shared db-worker-node backup implementation.
|
||||
|
||||
Architecture: Keep the live SQLite snapshot operation inside the existing db-worker-node runtime and continue to use `:thread-api/backup-db-sqlite`.
|
||||
Architecture: Move backup naming, paths, target selection, metadata, listing, deletion, and retention into one shared Node-only namespace that both Electron main process code and CLI command code call.
|
||||
Architecture: Do not add a new `thread-api` unless implementation proves that the existing `:thread-api/backup-db-sqlite` and `:thread-api/import-db-binary` APIs cannot preserve the required behavior.
|
||||
|
||||
Tech Stack: ClojureScript, Node.js `fs` and `path`, Promesa, existing db-worker-node HTTP invoke bridge, existing `logseq.cli.server`, existing `electron.db-worker`, existing `logseq.db.sqlite.backup`.
|
||||
|
||||
Related: Builds on `docs/agent-guide/db-worker/001-db-worker-node-restart-on-version-mismatch.md`.
|
||||
Related: Relates to `docs/agent-guide/db-worker/002-desktop-db-worker-request-cap-switch-graph.md`.
|
||||
Related: Relates to `docs/agent-guide/db-worker/003-server-list-write-lock.md`.
|
||||
Related: Relates to `docs/agent-guide/074-db-worker-node-invoke-main-thread-refactor.md`.
|
||||
Related: Relates to `docs/cli/logseq-cli.md`.
|
||||
|
||||
## Problem statement
|
||||
|
||||
Desktop and CLI already share the db-worker-node SQLite snapshot primitive, but they do not share graph backup orchestration.
|
||||
|
||||
The db-worker-node runtime exposes `:thread-api/backup-db-sqlite` from `/Users/rcmerci/gh-repos/logseq/src/main/frontend/worker/db_core.cljs`.
|
||||
|
||||
The db-worker-node HTTP server marks `:thread-api/backup-db-sqlite` as a write method in `/Users/rcmerci/gh-repos/logseq/src/main/frontend/worker/db_worker_node.cljs`, so it already validates the bound repo and the db-worker-node write lock before allowing the backup.
|
||||
|
||||
The Node platform adapter in `/Users/rcmerci/gh-repos/logseq/src/main/frontend/worker/platform/node.cljs` implements the actual snapshot by calling the SQLite connection backup operation.
|
||||
|
||||
Desktop graph backups currently live in `/Users/rcmerci/gh-repos/logseq/src/electron/electron/db.cljs`.
|
||||
|
||||
Desktop uses `electron.db/backup-db-via-worker!` to ask db-worker-node to copy the live database to a temporary path, then reads that temporary file back into Electron and writes a timestamped `.sqlite` file through `/Users/rcmerci/gh-repos/logseq/src/electron/electron/backup_file.cljs`.
|
||||
|
||||
Desktop stores graph backups under `<graphs-dir>/<encoded-graph>/backups`.
|
||||
|
||||
Desktop retains 12 versions and skips non-forced backups when the newest backup is less than one hour old.
|
||||
|
||||
CLI graph backups currently live in `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/graph.cljs`.
|
||||
|
||||
CLI stores graph backups under `<root-dir>/graphs/<encoded-graph>/backup/<encoded-backup-name>/db.sqlite`.
|
||||
|
||||
CLI can list, create, restore, and remove backups.
|
||||
|
||||
CLI backup creation already invokes the existing `:thread-api/backup-db-sqlite` method directly against the final backup `db.sqlite` path.
|
||||
|
||||
The two implementations now duplicate backup path and retention responsibilities in different places and use different filesystem layouts.
|
||||
|
||||
This means Desktop automatic backups cannot be listed, restored, or removed by the CLI graph backup commands.
|
||||
|
||||
This also means future backup behavior fixes must be made in multiple places.
|
||||
|
||||
## Testing Plan
|
||||
|
||||
I will use @Test-Driven Development (TDD) before changing implementation behavior.
|
||||
|
||||
I will add a new unit test namespace at `/Users/rcmerci/gh-repos/logseq/src/test/logseq/db_worker/graph_backup_test.cljs`.
|
||||
|
||||
I will test that the shared backup namespace derives backup roots as `<graphs-dir>/<encoded-graph>/backup`.
|
||||
|
||||
I will test that the shared backup namespace writes backup directories as `<backup-root>/<encoded-backup-name>/db.sqlite`.
|
||||
|
||||
I will test that backup names preserve the current CLI shape for graph backup create, including optional labels and UTC timestamps.
|
||||
|
||||
I will test that list returns only backups that contain a `db.sqlite` file and ignores incomplete temporary directories.
|
||||
|
||||
I will test that create calls a caller-provided snapshot function exactly once with a temporary SQLite destination and publishes the final backup only after the snapshot succeeds.
|
||||
|
||||
I will test that a failed snapshot removes temporary files and leaves no listable backup.
|
||||
|
||||
I will test that explicit CLI backups do not apply Desktop automatic backup throttling or retention.
|
||||
|
||||
I will test that Desktop automatic backups skip creation when the latest explicit Desktop automatic backup is newer than one hour and `force-backup?` is false.
|
||||
|
||||
I will test that Desktop automatic backup retention prunes only backups whose metadata explicitly identifies them as Desktop automatic backups.
|
||||
|
||||
I will test that Desktop manual backups and CLI backups are not pruned by Desktop automatic retention.
|
||||
|
||||
I will update `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/command/graph_test.cljs` so CLI backup list, create, restore, and remove exercise the shared backup namespace behavior.
|
||||
|
||||
I will update `/Users/rcmerci/gh-repos/logseq/src/test/electron/db_test.cljs` so Desktop graph backups use the same shared `backup` layout as CLI graph backups and still invoke db-worker-node through the existing `:thread-api/backup-db-sqlite`.
|
||||
|
||||
I will update `/Users/rcmerci/gh-repos/logseq/src/test/frontend/persist_db_test.cljs` only if the renderer IPC contract changes.
|
||||
|
||||
The preferred implementation keeps the renderer IPC contract stable, so existing renderer tests for `:db-export` should continue to pass without broad changes.
|
||||
|
||||
I will update `/Users/rcmerci/gh-repos/logseq/src/test/frontend/handler/export_test.cljs` only if the visible SQLite export flow changes.
|
||||
|
||||
The preferred implementation leaves `:db-export-as` as a direct SQLite export and not a graph backup.
|
||||
|
||||
I will run focused RED tests before implementation and confirm they fail because the shared namespace does not exist or current Electron tests still expect the old `backups` layout.
|
||||
|
||||
I will run focused tests after implementation with commands such as `bb dev:test -v logseq.db-worker.graph-backup-test`, `bb dev:test -v logseq.cli.command.graph-test`, and `bb dev:test -v electron.db-test`.
|
||||
|
||||
I will run `bb dev:lint-and-test` after focused tests pass.
|
||||
|
||||
I will run CLI E2E non-sync coverage after the unit suite passes with `bb -f cli-e2e/bb.edn build` followed by `bb -f cli-e2e/bb.edn test --skip-build`.
|
||||
|
||||
NOTE: I will write *all* tests before I add any implementation behavior.
|
||||
|
||||
## Current implementation snapshot
|
||||
|
||||
| Concern | Current location | Current behavior | Unification issue |
|
||||
|---------|------------------|------------------|-------------------|
|
||||
| db-worker-node snapshot API | `/Users/rcmerci/gh-repos/logseq/src/main/frontend/worker/db_core.cljs` | `:thread-api/backup-db-sqlite` checkpoints the graph DB and calls the platform SQLite backup function. | This is already the right shared snapshot primitive. |
|
||||
| db-worker-node HTTP boundary | `/Users/rcmerci/gh-repos/logseq/src/main/frontend/worker/db_worker_node.cljs` | `/v1/invoke` validates repo, write methods, and db-worker-node lock ownership. | No new HTTP endpoint or thread API should be needed. |
|
||||
| Node SQLite backup | `/Users/rcmerci/gh-repos/logseq/src/main/frontend/worker/platform/node.cljs` | The Node platform calls `.backup` on the SQLite connection. | This should stay behind `:thread-api/backup-db-sqlite`. |
|
||||
| Desktop backup orchestration | `/Users/rcmerci/gh-repos/logseq/src/electron/electron/db.cljs` | Copies through db-worker-node into a temp file, reads bytes into Electron, then writes through `electron.backup-file/backup-file`. | This duplicates retention and writes a second copy from Electron. |
|
||||
| Desktop backup file helper | `/Users/rcmerci/gh-repos/logseq/src/electron/electron/backup_file.cljs` | Writes timestamped files under `backups` and prunes old versions. | This helper is still used by file write failure backup and should not own graph backup policy. |
|
||||
| Desktop IPC | `/Users/rcmerci/gh-repos/logseq/src/electron/electron/handler.cljs` | `:db-export` calls `backup-db-via-worker!` and `:db-export-as` calls direct SQLite export. | `:db-export` should use shared graph backup orchestration, while `:db-export-as` remains an export. |
|
||||
| Desktop open backup folder | `/Users/rcmerci/gh-repos/logseq/src/main/frontend/config.cljs` | `get-electron-backup-dir` points to `<graph>/backups`. | It should point to the unified `<graph>/backup` directory after graph backup unification. |
|
||||
| CLI backup orchestration | `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/graph.cljs` | Owns backup path helpers, list, target selection, create, restore, and remove. | These helpers should move to the shared backup namespace. |
|
||||
| CLI docs | `/Users/rcmerci/gh-repos/logseq/docs/cli/logseq-cli.md` | Documents CLI backup snapshots under `<root-dir>/graphs/<graph>/backup`. | The documented CLI layout should become the Desktop graph backup layout too. |
|
||||
|
||||
## Target behavior
|
||||
|
||||
One Node-only namespace owns graph backup filesystem behavior.
|
||||
|
||||
Both Desktop and CLI use that namespace for graph backup path derivation, backup name creation, list, create, remove, metadata, and retention.
|
||||
|
||||
The canonical graph backup root is `<graphs-dir>/<encoded-graph>/backup`.
|
||||
|
||||
Each listable backup is stored as `<backup-root>/<encoded-backup-name>/db.sqlite`.
|
||||
|
||||
The shared namespace must use `logseq.common.graph-dir` path encoding instead of ad hoc string replacement.
|
||||
|
||||
The shared namespace must accept a caller-provided snapshot function so it does not depend on Electron windows, CLI config, or HTTP transport.
|
||||
|
||||
Desktop snapshot creation supplies a snapshot function that ensures the db-worker-node runtime through `electron.db-worker/ensure-runtime!` and invokes `:thread-api/backup-db-sqlite`.
|
||||
|
||||
CLI snapshot creation supplies a snapshot function that ensures the db-worker-node runtime through `logseq.cli.server/ensure-server!` and invokes `:thread-api/backup-db-sqlite`.
|
||||
|
||||
Restore continues to use the existing CLI SQLite import flow and `:thread-api/import-db-binary`.
|
||||
|
||||
Manual SQLite export through `:db-export-as` remains an export flow and does not become a graph backup.
|
||||
|
||||
`electron.backup-file` remains available for non-graph file write failure backups, but graph backups no longer depend on it.
|
||||
|
||||
No new `thread-api` should be added for this change.
|
||||
|
||||
No browser renderer code should import the shared Node-only backup namespace.
|
||||
|
||||
Desktop automatic backup retention must not remove CLI-created backups.
|
||||
|
||||
Desktop automatic backup retention must not remove Desktop manual backups.
|
||||
|
||||
Missing or invalid backup names at command boundaries should return actionable errors instead of silently picking defaults.
|
||||
|
||||
## Proposed architecture
|
||||
|
||||
The shared namespace should be `/Users/rcmerci/gh-repos/logseq/src/main/logseq/db_worker/graph_backup.cljs`.
|
||||
|
||||
The namespace should be Node-only because it uses Node `fs` and `path`.
|
||||
|
||||
The namespace should not require `electron.*` namespaces.
|
||||
|
||||
The namespace should not require `logseq.cli.server`.
|
||||
|
||||
The namespace should not call `transport/invoke`.
|
||||
|
||||
The namespace should accept `graphs-dir`, `repo`, `backup-name`, `source`, and `snapshot!` from callers.
|
||||
|
||||
The shared namespace should write optional metadata next to each backup.
|
||||
|
||||
The metadata file should be used for retention filtering and debugging, not for the CLI output contract unless a later product decision asks for that.
|
||||
|
||||
The metadata file should be named `metadata.edn`.
|
||||
|
||||
The metadata should include `:schema-version`, `:name`, `:repo`, `:source`, `:created-at-ms`, and `:db-path`.
|
||||
|
||||
The shared namespace should only prune backups that explicitly opt into the retention source being pruned.
|
||||
|
||||
Missing metadata must not make a backup eligible for Desktop automatic retention.
|
||||
|
||||
The implementation should keep the current CLI backup output fields unless a test proves an output update is required.
|
||||
|
||||
```text
|
||||
CLI command code
|
||||
|
|
||||
| ensures db-worker-node with logseq.cli.server
|
||||
| passes snapshot! to shared graph backup namespace
|
||||
v
|
||||
logseq.db-worker.graph-backup
|
||||
|
|
||||
| owns backup path, create, list, remove, retention
|
||||
| calls snapshot! with a temp sqlite path
|
||||
v
|
||||
db-worker-node /v1/invoke
|
||||
|
|
||||
| existing :thread-api/backup-db-sqlite
|
||||
v
|
||||
live SQLite connection backup
|
||||
|
||||
Electron main process
|
||||
|
|
||||
| ensures db-worker-node with electron.db-worker
|
||||
| passes snapshot! to shared graph backup namespace
|
||||
v
|
||||
same shared graph backup namespace
|
||||
```
|
||||
|
||||
## Shared namespace responsibilities
|
||||
|
||||
Add `/Users/rcmerci/gh-repos/logseq/src/main/logseq/db_worker/graph_backup.cljs`.
|
||||
|
||||
Add `backup-root-path` to return `<graphs-dir>/<encoded-graph>/backup`.
|
||||
|
||||
Add `backup-dir-name` to encode a backup name with `logseq.common.graph-dir/graph-dir-key->encoded-dir-name`.
|
||||
|
||||
Add `backup-dir-path` to return `<backup-root>/<encoded-backup-name>`.
|
||||
|
||||
Add `backup-db-path` to return `<backup-dir>/db.sqlite`.
|
||||
|
||||
Add `backup-metadata-path` to return `<backup-dir>/metadata.edn`.
|
||||
|
||||
Add `build-backup-name` to preserve the current CLI naming shape.
|
||||
|
||||
Add `next-backup-target` to find a non-existing target and append numeric suffixes when needed.
|
||||
|
||||
Add `list-backups` to list only directories with a real `db.sqlite`.
|
||||
|
||||
Add `<create-backup!` to create one backup by calling a caller-provided snapshot function.
|
||||
|
||||
Add `remove-backup!` to delete one backup directory.
|
||||
|
||||
Add `latest-backup-info` to support Desktop automatic backup throttling.
|
||||
|
||||
Add `prune-backups!` to remove old backups for one explicit metadata source.
|
||||
|
||||
All async public functions should return Promesa promises and use a leading `<` name when the function itself is promise-returning.
|
||||
|
||||
Synchronous path helpers can keep plain names.
|
||||
|
||||
## Backup creation details
|
||||
|
||||
The shared create flow should validate `graphs-dir`, `repo`, `backup-name`, and `snapshot!` before touching the filesystem.
|
||||
|
||||
The shared create flow should create the backup root if needed.
|
||||
|
||||
The shared create flow should reserve the target directory before calling `snapshot!`.
|
||||
|
||||
The shared create flow should write the SQLite snapshot to a temporary path inside the reserved target directory.
|
||||
|
||||
The shared create flow should rename the temporary SQLite file to `db.sqlite` only after `snapshot!` succeeds.
|
||||
|
||||
The shared create flow should write `metadata.edn` only after `db.sqlite` exists.
|
||||
|
||||
The shared create flow should remove the reserved target directory if `snapshot!` fails before a listable backup exists.
|
||||
|
||||
The shared create flow should return `{:backup-name ..., :path ..., :created? true}` after successful creation.
|
||||
|
||||
The shared create flow should return `{:backup-name nil, :path nil, :created? false, :reason :too-soon}` when Desktop automatic backup throttling intentionally skips creation.
|
||||
|
||||
The shared create flow should throw on invalid paths, missing inputs, snapshot failure, and retention failure.
|
||||
|
||||
The shared create flow should not silently swallow backup failures.
|
||||
|
||||
## Desktop integration plan
|
||||
|
||||
Update `/Users/rcmerci/gh-repos/logseq/src/electron/electron/db.cljs`.
|
||||
|
||||
Remove graph backup orchestration from `backup-db-with-sqlite-backup!` or shrink it to a small wrapper around the shared graph backup namespace.
|
||||
|
||||
Keep `ensure-graphs-dir!`, `ensure-graph-dir!`, and `get-db` only if callers still need them.
|
||||
|
||||
Change `backup-db-via-worker!` so it calls the shared `<create-backup!` with `graphs-dir` from `logseq.cli.common.graph/get-db-graphs-dir`.
|
||||
|
||||
Use `electron.db-worker/ensure-runtime!` and `logseq.cli.transport/invoke` only inside the Desktop snapshot function.
|
||||
|
||||
Pass `:source :electron-auto` when `force-backup?` is false.
|
||||
|
||||
Pass `:source :electron-manual` when `force-backup?` is true.
|
||||
|
||||
Apply one-hour throttling and 12-version retention only for `:electron-auto`.
|
||||
|
||||
Do not apply automatic retention to `:electron-manual` or `:cli` backups.
|
||||
|
||||
Keep `export-db-via-worker!` and `export-db-to-export-dir-via-worker!` as direct export helpers that write to the requested export path.
|
||||
|
||||
Update `/Users/rcmerci/gh-repos/logseq/src/main/frontend/config.cljs` so `get-electron-backup-dir` returns `<graph>/backup`.
|
||||
|
||||
Do not add new visible UI text for the folder change unless product wants a migration note.
|
||||
|
||||
If visible UI text is added or changed, load @logseq-i18n first and follow `/Users/rcmerci/gh-repos/logseq/.agents/skills/logseq-i18n/SKILL.md`.
|
||||
|
||||
## CLI integration plan
|
||||
|
||||
Update `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/graph.cljs`.
|
||||
|
||||
Before editing this file, read `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/AGENTS.md`.
|
||||
|
||||
Remove private backup path helpers from the CLI command namespace once the shared namespace owns them.
|
||||
|
||||
Keep CLI parsing, validation, action construction, and presentation contracts in the CLI command namespaces.
|
||||
|
||||
Keep the current `graph backup create` backup name shape by delegating name construction to the shared namespace.
|
||||
|
||||
Change `execute-graph-backup-list` to call the shared `list-backups`.
|
||||
|
||||
Change `execute-graph-backup-create` to call the shared `<create-backup!`.
|
||||
|
||||
The CLI snapshot function should call `logseq.cli.server/ensure-server!` and `logseq.cli.transport/invoke` with `:thread-api/backup-db-sqlite`.
|
||||
|
||||
Change `execute-graph-backup-restore` to locate the source `db.sqlite` with the shared `backup-db-path`, then keep reusing the existing SQLite import flow.
|
||||
|
||||
Change `execute-graph-backup-remove` to call the shared `remove-backup!`.
|
||||
|
||||
Keep machine-readable JSON and EDN output fields stable unless the implementation intentionally changes the public contract and updates tests.
|
||||
|
||||
## Detailed implementation tasks
|
||||
|
||||
1. Read `/Users/rcmerci/gh-repos/logseq/AGENTS.md`.
|
||||
|
||||
2. Read `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/AGENTS.md` before editing CLI code.
|
||||
|
||||
3. Load @Test-Driven Development (TDD) before writing implementation code.
|
||||
|
||||
4. Write failing path derivation tests in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/db_worker/graph_backup_test.cljs`.
|
||||
|
||||
5. Write failing list tests in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/db_worker/graph_backup_test.cljs`.
|
||||
|
||||
6. Write failing create success tests in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/db_worker/graph_backup_test.cljs`.
|
||||
|
||||
7. Write failing create failure cleanup tests in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/db_worker/graph_backup_test.cljs`.
|
||||
|
||||
8. Write failing automatic throttling tests in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/db_worker/graph_backup_test.cljs`.
|
||||
|
||||
9. Write failing automatic retention tests in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/db_worker/graph_backup_test.cljs`.
|
||||
|
||||
10. Run `bb dev:test -v logseq.db-worker.graph-backup-test` and confirm the new tests fail for the expected missing namespace or missing behavior.
|
||||
|
||||
11. Add `/Users/rcmerci/gh-repos/logseq/src/main/logseq/db_worker/graph_backup.cljs` with only enough implementation to pass the shared namespace tests.
|
||||
|
||||
12. Run `bb dev:test -v logseq.db-worker.graph-backup-test` and confirm the shared namespace tests pass.
|
||||
|
||||
13. Write failing CLI tests in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/command/graph_test.cljs` for shared create, list, restore, and remove behavior.
|
||||
|
||||
14. Run the focused CLI graph tests and confirm the new assertions fail against the old CLI-local helper implementation.
|
||||
|
||||
15. Refactor `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/graph.cljs` to use the shared graph backup namespace.
|
||||
|
||||
16. Run `bb dev:test -v logseq.cli.command.graph-test` and confirm the CLI graph tests pass.
|
||||
|
||||
17. Write failing Electron tests in `/Users/rcmerci/gh-repos/logseq/src/test/electron/db_test.cljs` for unified backup path, source metadata, throttling, retention, and existing worker invocation.
|
||||
|
||||
18. Run `bb dev:test -v electron.db-test` and confirm the new Electron backup tests fail against the current `backups` layout.
|
||||
|
||||
19. Refactor `/Users/rcmerci/gh-repos/logseq/src/electron/electron/db.cljs` to use the shared graph backup namespace for graph backups.
|
||||
|
||||
20. Keep `/Users/rcmerci/gh-repos/logseq/src/electron/electron/db.cljs` direct export helpers on their current worker-backed export path.
|
||||
|
||||
21. Run `bb dev:test -v electron.db-test` and confirm the Electron tests pass.
|
||||
|
||||
22. Update `/Users/rcmerci/gh-repos/logseq/src/main/frontend/config.cljs` to point Electron graph backup folder links at `backup`.
|
||||
|
||||
23. Run affected frontend tests if a config or renderer expectation changes.
|
||||
|
||||
24. Update `/Users/rcmerci/gh-repos/logseq/docs/cli/logseq-cli.md` only if the documented CLI behavior changes or if the document should explicitly note that Desktop graph backups now use the same directory.
|
||||
|
||||
25. Run `bb dev:lint-and-test`.
|
||||
|
||||
26. Run `bb -f cli-e2e/bb.edn build`.
|
||||
|
||||
27. Run `bb -f cli-e2e/bb.edn test --skip-build`.
|
||||
|
||||
28. Use @logseq-review-workflow to review the completed implementation.
|
||||
|
||||
29. In the review, apply repository-wide rules from `.agents/skills/logseq-review-workflow/rules/common.md`.
|
||||
|
||||
30. In the review, apply ClojureScript rules from `.agents/skills/logseq-review-workflow/rules/libraries/clojure-cljs.md`.
|
||||
|
||||
31. In the review, apply Promesa rules from `.agents/skills/logseq-review-workflow/rules/libraries/promesa.md`.
|
||||
|
||||
32. In the review, apply Shadow CLJS Node rules from `.agents/skills/logseq-review-workflow/rules/libraries/shadow-cljs-node.md`.
|
||||
|
||||
33. In the review, apply CLI rules from `.agents/skills/logseq-review-workflow/rules/modules/logseq-cli.md`.
|
||||
|
||||
34. In the review, apply Electron main process rules from `.agents/skills/logseq-review-workflow/rules/modules/electron-main.md`.
|
||||
|
||||
35. In the review, apply import/export rules from `.agents/skills/logseq-review-workflow/rules/modules/import-export.md`.
|
||||
|
||||
36. Fix any blocking or important review findings.
|
||||
|
||||
37. Re-run the focused tests that cover any fixes made after review.
|
||||
|
||||
38. Re-run `bb dev:lint-and-test` if review fixes touched source code.
|
||||
|
||||
## Edge cases
|
||||
|
||||
A backup directory without `db.sqlite` must not appear in `graph backup list`.
|
||||
|
||||
A failed snapshot must not leave a partial `db.sqlite` that can be restored later.
|
||||
|
||||
Concurrent create calls with the same base backup name must not overwrite one another.
|
||||
|
||||
Backup names with slashes, colons, spaces, or non-ASCII text must be encoded through the graph-dir helper.
|
||||
|
||||
Backup removal must only remove one encoded backup directory inside the selected graph backup root.
|
||||
|
||||
Backup restore must fail with `:backup-not-found` when the source backup is missing or incomplete.
|
||||
|
||||
Desktop automatic backup throttling must not skip a forced manual backup.
|
||||
|
||||
Desktop automatic backup retention must not delete CLI backups.
|
||||
|
||||
Desktop automatic backup retention must not delete backups without explicit `:electron-auto` metadata.
|
||||
|
||||
Desktop automatic backup should surface worker errors to the Electron caller and log auto-backup failures.
|
||||
|
||||
CLI backup create should keep using the requester revision checks in `logseq.cli.server/ensure-server!`.
|
||||
|
||||
CLI backup create should fail if db-worker-node cannot start or the existing server cannot satisfy revision checks.
|
||||
|
||||
Restore into an existing destination graph must keep failing through the existing `require-missing-graph` guard.
|
||||
|
||||
Direct SQLite export through `:db-export-as` must keep writing the requested export file and must not create a graph backup entry.
|
||||
|
||||
## Review Plan
|
||||
|
||||
The completed implementation must be checked with @logseq-review-workflow.
|
||||
|
||||
The review scope should include `/Users/rcmerci/gh-repos/logseq/src/main/logseq/db_worker/graph_backup.cljs`, `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/graph.cljs`, `/Users/rcmerci/gh-repos/logseq/src/electron/electron/db.cljs`, `/Users/rcmerci/gh-repos/logseq/src/main/frontend/config.cljs`, and all changed tests.
|
||||
|
||||
The review should verify that no new `thread-api` was added.
|
||||
|
||||
The review should verify that browser renderer bundles do not import Node-only backup code.
|
||||
|
||||
The review should verify that CLI output contracts remain stable.
|
||||
|
||||
The review should verify that promise-returning backup functions propagate failures.
|
||||
|
||||
The review should verify that Desktop automatic retention cannot delete CLI or manual backups.
|
||||
|
||||
The review should verify that tests cover behavior and failure modes instead of only checking mocks.
|
||||
|
||||
## Testing Details
|
||||
|
||||
The shared namespace tests prove the canonical backup layout, backup creation, failure cleanup, list filtering, throttling, and retention behavior directly through filesystem state.
|
||||
|
||||
The CLI tests prove that `graph backup list`, `graph backup create`, `graph backup restore`, and `graph backup remove` use the shared layout while preserving command behavior.
|
||||
|
||||
The Electron tests prove that Desktop graph backups use the same shared layout and still snapshot through the existing db-worker-node `:thread-api/backup-db-sqlite` path.
|
||||
|
||||
The db-worker-node tests that already cover `:thread-api/backup-db-sqlite` should continue to prove that the live SQLite connection can be copied and imported into another graph.
|
||||
|
||||
The CLI E2E non-sync cases prove the user-facing graph backup lifecycle still works through the built CLI and db-worker-node bundle.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
- Create `/Users/rcmerci/gh-repos/logseq/src/main/logseq/db_worker/graph_backup.cljs` as the single owner of graph backup filesystem policy.
|
||||
- Keep db-worker-node snapshotting on the existing `:thread-api/backup-db-sqlite` API.
|
||||
- Keep CLI restore on the existing SQLite import path and `:thread-api/import-db-binary`.
|
||||
- Use `<graphs-dir>/<encoded-graph>/backup/<encoded-backup-name>/db.sqlite` as the canonical layout.
|
||||
- Use `metadata.edn` only for retention filtering and diagnostics in the first implementation.
|
||||
- Stop using `electron.backup-file/backup-file` for graph backups.
|
||||
- Keep `electron.backup-file/backup-file` for non-graph file write failure backups.
|
||||
- Keep `:db-export-as` as a direct export flow, not a graph backup flow.
|
||||
- Preserve CLI machine output unless product explicitly wants metadata exposed.
|
||||
- Run @logseq-review-workflow after implementation and before considering the work complete.
|
||||
|
||||
## Question
|
||||
|
||||
There are no open product questions after review.
|
||||
|
||||
Do not migrate or read old Desktop backups under `<graph>/backups`.
|
||||
|
||||
Do not add compatibility for the old Desktop graph backup layout.
|
||||
|
||||
Keep CLI JSON and EDN output stable and do not expose backup metadata such as `:source`.
|
||||
|
||||
Treat Desktop `force-backup? true` as a manual backup.
|
||||
|
||||
Protect Desktop manual backups from automatic throttling and automatic retention.
|
||||
|
||||
Prune only backups whose metadata explicitly marks them as `:electron-auto`.
|
||||
|
||||
Keep the shared backup namespace Node-only.
|
||||
|
||||
Do not add a new `thread-api` for this implementation.
|
||||
|
||||
---
|
||||
@@ -135,6 +135,7 @@ For any command that requires `--graph`, if the target graph does not exist, the
|
||||
Backup scope note:
|
||||
- `graph backup create` copies only `db.sqlite`.
|
||||
- `search-db.sqlite` and `client-ops-db.sqlite` are intentionally excluded.
|
||||
- Desktop graph backups use the same `<root-dir>/graphs/<graph>/backup/<backup-name>/db.sqlite` layout, so the CLI can list, restore, and remove Desktop-created graph backups when it uses the same root directory.
|
||||
|
||||
Server commands:
|
||||
- `server list` - list running db-worker-node servers
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"Provides SQLite dbs for electron and manages files of those dbs"
|
||||
(:require ["fs-extra" :as fs]
|
||||
["path" :as node-path]
|
||||
[electron.backup-file :as backup-file]
|
||||
[electron.db-worker :as db-worker]
|
||||
[lambdaisland.glogi :as log]
|
||||
[logseq.cli.common.graph :as cli-common-graph]
|
||||
@@ -10,11 +9,14 @@
|
||||
[logseq.common.graph-dir :as graph-dir]
|
||||
[logseq.db.common.sqlite :as common-sqlite]
|
||||
[logseq.db.sqlite.backup :as sqlite-backup]
|
||||
[logseq.db-worker.graph-backup :as graph-backup]
|
||||
[promesa.core :as p]))
|
||||
|
||||
(def ^:private backup-interval-ms
|
||||
(* 60 60 1000))
|
||||
|
||||
(def ^:private automatic-backup-keep-versions 12)
|
||||
|
||||
(defonce *auto-backup
|
||||
(atom {:window->repo {}
|
||||
:interval-id nil}))
|
||||
@@ -38,36 +40,34 @@
|
||||
(when (fs/existsSync db-path)
|
||||
(fs/readFileSync db-path))))
|
||||
|
||||
(defn- temp-backup-path
|
||||
[backups-path]
|
||||
(node-path/join backups-path
|
||||
(str ".tmp."
|
||||
(.now js/Date)
|
||||
"."
|
||||
(rand-int 1000000)
|
||||
".sqlite")))
|
||||
(defn- backup-source
|
||||
[{:keys [force-backup?]}]
|
||||
(if (true? force-backup?)
|
||||
:electron-manual
|
||||
:electron-auto))
|
||||
|
||||
(defn- <create-graph-backup!
|
||||
[db-name opts snapshot!]
|
||||
(let [_ (ensure-graph-dir! db-name)
|
||||
source (backup-source opts)]
|
||||
(graph-backup/<create-backup!
|
||||
(cond-> {:graphs-dir (cli-common-graph/get-db-graphs-dir)
|
||||
:repo db-name
|
||||
:backup-name (graph-backup/build-backup-name db-name nil)
|
||||
:source source
|
||||
:snapshot! snapshot!}
|
||||
(= :electron-auto source)
|
||||
(assoc :throttle-ms backup-interval-ms
|
||||
:keep-versions automatic-backup-keep-versions)))))
|
||||
|
||||
(defn backup-db-with-sqlite-backup!
|
||||
[db-name {:keys [force-backup? sqlite-backup!]}]
|
||||
(let [_ (ensure-graph-dir! db-name)
|
||||
[_db-name db-path] (common-sqlite/get-db-full-path (cli-common-graph/get-db-graphs-dir) db-name)
|
||||
backups-path (common-sqlite/get-db-backups-path (cli-common-graph/get-db-graphs-dir) db-name)]
|
||||
(when (fs/existsSync db-path)
|
||||
(let [tmp-path (temp-backup-path backups-path)]
|
||||
(-> (p/let [_ (fs/ensureDirSync backups-path)
|
||||
_ (sqlite-backup! db-path tmp-path)
|
||||
payload (fs/readFileSync tmp-path)]
|
||||
(backup-file/backup-file db-name nil nil
|
||||
".sqlite"
|
||||
payload
|
||||
:backups-dir backups-path
|
||||
:keep-versions 12
|
||||
:force-backup? force-backup?))
|
||||
(p/finally (fn []
|
||||
(try
|
||||
(fs/removeSync tmp-path)
|
||||
(catch :default _
|
||||
nil)))))))))
|
||||
(let [[_db-name db-path] (common-sqlite/get-db-full-path (cli-common-graph/get-db-graphs-dir) db-name)]
|
||||
(<create-graph-backup!
|
||||
db-name
|
||||
{:force-backup? force-backup?}
|
||||
(fn [dst-path]
|
||||
(sqlite-backup! db-path dst-path)))))
|
||||
|
||||
(defn backup-db!
|
||||
[db-name opts]
|
||||
@@ -77,15 +77,14 @@
|
||||
|
||||
(defn backup-db-via-worker!
|
||||
[db-name window-id opts]
|
||||
(backup-db-with-sqlite-backup!
|
||||
(<create-graph-backup!
|
||||
db-name
|
||||
(assoc opts
|
||||
:sqlite-backup!
|
||||
(fn [_src-path dst-path]
|
||||
(p/let [runtime (db-worker/ensure-runtime! db-name window-id)]
|
||||
(cli-transport/invoke runtime
|
||||
:thread-api/backup-db-sqlite
|
||||
[db-name dst-path]))))))
|
||||
opts
|
||||
(fn [dst-path]
|
||||
(p/let [runtime (db-worker/ensure-runtime! db-name window-id)]
|
||||
(cli-transport/invoke runtime
|
||||
:thread-api/backup-db-sqlite
|
||||
[db-name dst-path])))))
|
||||
|
||||
(defn export-db-via-worker!
|
||||
[db-name window-id dst-path]
|
||||
|
||||
@@ -332,7 +332,7 @@
|
||||
|
||||
(defn get-electron-backup-dir
|
||||
[repo]
|
||||
(path/path-join (get-local-dir repo) "backups"))
|
||||
(path/path-join (get-local-dir repo) "backup"))
|
||||
|
||||
(defn get-repo-dir
|
||||
[repo-url]
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
[logseq.cli.transport :as transport]
|
||||
[logseq.common.graph :as common-graph]
|
||||
[logseq.common.graph-dir :as graph-dir]
|
||||
[logseq.db-worker.graph-backup :as graph-backup]
|
||||
[promesa.core :as p]))
|
||||
|
||||
(def ^:private graph-export-spec
|
||||
@@ -58,8 +59,6 @@
|
||||
(def ^:private graph-backup-remove-spec
|
||||
{:src {:desc "Source backup name"}})
|
||||
|
||||
(def ^:private backup-root-dir-name "backup")
|
||||
(def ^:private backup-db-file-name "db.sqlite")
|
||||
(def ^:private export-root-dir-name "export")
|
||||
|
||||
(def entries
|
||||
@@ -170,24 +169,6 @@
|
||||
:error {:code :missing-graph
|
||||
:message "graph name is required"}})
|
||||
|
||||
(defn- pad2
|
||||
[value]
|
||||
(if (< value 10)
|
||||
(str "0" value)
|
||||
(str value)))
|
||||
|
||||
(defn- utc-timestamp
|
||||
[]
|
||||
(let [now (js/Date.)]
|
||||
(str (.getUTCFullYear now)
|
||||
(pad2 (inc (.getUTCMonth now)))
|
||||
(pad2 (.getUTCDate now))
|
||||
"T"
|
||||
(pad2 (.getUTCHours now))
|
||||
(pad2 (.getUTCMinutes now))
|
||||
(pad2 (.getUTCSeconds now))
|
||||
"Z")))
|
||||
|
||||
(defn- trimmed-option
|
||||
[value]
|
||||
(some-> value str string/trim not-empty))
|
||||
@@ -211,10 +192,7 @@
|
||||
:error {:code :missing-repo
|
||||
:message "repo is required for backup create"}}
|
||||
(let [graph (core/repo->graph repo)
|
||||
name-part (trimmed-option name)
|
||||
backup-name (if (seq name-part)
|
||||
(str graph "-" name-part "-" (utc-timestamp))
|
||||
(str graph "-" (utc-timestamp)))]
|
||||
backup-name (graph-backup/build-backup-name repo name)]
|
||||
{:ok? true
|
||||
:action {:type :graph-backup-create
|
||||
:command :graph-backup-create
|
||||
@@ -369,13 +347,6 @@
|
||||
:legacy-undecodable (:legacy-dir item)
|
||||
nil))
|
||||
|
||||
(defn- backup-root-path
|
||||
[config repo]
|
||||
(when-let [graph-dir-name (graph-dir/repo->encoded-graph-dir-name repo)]
|
||||
(node-path/join (cli-server/graphs-dir config)
|
||||
graph-dir-name
|
||||
backup-root-dir-name)))
|
||||
|
||||
(defn- export-root-path
|
||||
[config repo]
|
||||
(when-let [graph-dir-name (graph-dir/repo->encoded-graph-dir-name repo)]
|
||||
@@ -393,61 +364,6 @@
|
||||
(quot (.now js/Date) 1000)
|
||||
".sqlite"))))
|
||||
|
||||
(defn- backup-dir-name
|
||||
[backup-name]
|
||||
(graph-dir/graph-dir-key->encoded-dir-name backup-name))
|
||||
|
||||
(defn- backup-dir-path
|
||||
[config repo backup-name]
|
||||
(some->> (backup-dir-name backup-name)
|
||||
(node-path/join (backup-root-path config repo))))
|
||||
|
||||
(defn- backup-db-path
|
||||
[config repo backup-name]
|
||||
(some-> (backup-dir-path config repo backup-name)
|
||||
(node-path/join backup-db-file-name)))
|
||||
|
||||
(defn- backup-metadata
|
||||
[^js dirent root-path]
|
||||
(let [dir-name (.-name dirent)
|
||||
backup-name (graph-dir/decode-graph-dir-name dir-name)
|
||||
db-path (node-path/join root-path dir-name backup-db-file-name)]
|
||||
(when (and (seq backup-name)
|
||||
(fs/existsSync db-path))
|
||||
(let [stat (fs/statSync db-path)]
|
||||
(when (.isFile stat)
|
||||
{:name backup-name
|
||||
:created-at (.-mtimeMs stat)
|
||||
:size-bytes (.-size stat)})))))
|
||||
|
||||
(defn- list-backups
|
||||
[config repo]
|
||||
(if-let [root-path (backup-root-path config repo)]
|
||||
(let [dirents (if (fs/existsSync root-path)
|
||||
(fs/readdirSync root-path #js {:withFileTypes true})
|
||||
#js [])]
|
||||
(->> dirents
|
||||
(filter #(.isDirectory ^js %))
|
||||
(keep #(backup-metadata % root-path))
|
||||
(sort-by (juxt :name :created-at))
|
||||
vec))
|
||||
[]))
|
||||
|
||||
(defn- next-backup-target
|
||||
[config repo base-name]
|
||||
(loop [suffix 0]
|
||||
(let [backup-name (if (zero? suffix)
|
||||
base-name
|
||||
(str base-name "-" suffix))
|
||||
dir-path (backup-dir-path config repo backup-name)]
|
||||
(if (and (seq dir-path)
|
||||
(fs/existsSync dir-path))
|
||||
(recur (inc suffix))
|
||||
{:backup-name backup-name
|
||||
:dir-path dir-path
|
||||
:db-path (when (seq dir-path)
|
||||
(node-path/join dir-path backup-db-file-name))}))))
|
||||
|
||||
(defn execute-graph-list
|
||||
[_action config]
|
||||
(let [graph-items (vec (cli-server/list-graph-items config))
|
||||
@@ -466,30 +382,33 @@
|
||||
(defn execute-graph-backup-list
|
||||
[action config]
|
||||
{:status :ok
|
||||
:data {:backups (list-backups config (:repo action))}})
|
||||
:data {:backups (graph-backup/list-backups (cli-server/graphs-dir config)
|
||||
(:repo action))}})
|
||||
|
||||
(defn execute-graph-backup-create
|
||||
[action config]
|
||||
(p/let [cfg (cli-server/ensure-server! config (:repo action))
|
||||
{:keys [backup-name dir-path db-path]} (next-backup-target config (:repo action) (:backup-name action))
|
||||
_ (when-not (seq dir-path)
|
||||
(throw (ex-info "invalid backup target path"
|
||||
{:code :invalid-backup-path
|
||||
:backup-name backup-name})))
|
||||
_ (fs/mkdirSync dir-path #js {:recursive true})
|
||||
_ (transport/invoke cfg
|
||||
:thread-api/backup-db-sqlite
|
||||
[(:repo action) db-path])]
|
||||
{:keys [backup-name path]} (graph-backup/<create-backup!
|
||||
{:graphs-dir (cli-server/graphs-dir config)
|
||||
:repo (:repo action)
|
||||
:backup-name (:backup-name action)
|
||||
:source :cli
|
||||
:snapshot! (fn [dst-path]
|
||||
(transport/invoke cfg
|
||||
:thread-api/backup-db-sqlite
|
||||
[(:repo action) dst-path]))})]
|
||||
{:status :ok
|
||||
:data {:backup-name backup-name
|
||||
:path db-path
|
||||
:path path
|
||||
:message (str "Created backup " backup-name)}}))
|
||||
|
||||
(declare execute-graph-import)
|
||||
|
||||
(defn execute-graph-backup-restore
|
||||
[action config]
|
||||
(let [src-path (backup-db-path config (:source-repo action) (:src action))]
|
||||
(let [src-path (graph-backup/backup-db-path (cli-server/graphs-dir config)
|
||||
(:source-repo action)
|
||||
(:src action))]
|
||||
(if-not (and (seq src-path)
|
||||
(fs/existsSync src-path))
|
||||
{:status :error
|
||||
@@ -502,16 +421,15 @@
|
||||
|
||||
(defn execute-graph-backup-remove
|
||||
[action config]
|
||||
(let [dir-path (backup-dir-path config (:repo action) (:src action))]
|
||||
(if-not (and (seq dir-path)
|
||||
(fs/existsSync dir-path))
|
||||
(let [removed? (graph-backup/remove-backup! (cli-server/graphs-dir config)
|
||||
(:repo action)
|
||||
(:src action))]
|
||||
(if-not removed?
|
||||
{:status :error
|
||||
:error {:code :backup-not-found
|
||||
:message (str "backup not found: " (:src action))}}
|
||||
(do
|
||||
(fs/rmSync dir-path #js {:recursive true :force true})
|
||||
{:status :ok
|
||||
:data {:message (str "Removed backup " (:src action))}}))))
|
||||
{:status :ok
|
||||
:data {:message (str "Removed backup " (:src action))}})))
|
||||
|
||||
(defn- format-validation-errors
|
||||
[errors]
|
||||
|
||||
@@ -1061,14 +1061,28 @@
|
||||
[_context {:keys [message]}]
|
||||
message)
|
||||
|
||||
(defn- automatic-backup-source?
|
||||
[source]
|
||||
(contains? #{:electron-auto "electron-auto"} source))
|
||||
|
||||
(defn- format-backup-auto-save
|
||||
[backup]
|
||||
(let [source (or (:source backup)
|
||||
(get-in backup [:metadata :source]))]
|
||||
(cond
|
||||
(automatic-backup-source? source) "yes"
|
||||
(some? source) "no"
|
||||
:else "-")))
|
||||
|
||||
(defn- format-graph-backup-list
|
||||
[backups now-ms]
|
||||
(format-counted-table
|
||||
["NAME" "CREATED-AT" "SIZE-BYTES"]
|
||||
(mapv (fn [{:keys [name created-at size-bytes]}]
|
||||
["NAME" "CREATED-AT" "SIZE-BYTES" "AUTO-SAVE"]
|
||||
(mapv (fn [{:keys [name created-at size-bytes] :as backup}]
|
||||
[(or name "-")
|
||||
(human-ago created-at now-ms)
|
||||
(or size-bytes 0)])
|
||||
(or size-bytes 0)
|
||||
(format-backup-auto-save backup)])
|
||||
(or backups []))))
|
||||
|
||||
(defn- format-graph-backup-create
|
||||
|
||||
299
src/main/logseq/db_worker/graph_backup.cljs
Normal file
299
src/main/logseq/db_worker/graph_backup.cljs
Normal file
@@ -0,0 +1,299 @@
|
||||
(ns logseq.db-worker.graph-backup
|
||||
"Shared Node-only graph backup filesystem policy."
|
||||
(:require ["fs" :as fs]
|
||||
["path" :as node-path]
|
||||
[cljs.reader :as reader]
|
||||
[clojure.string :as string]
|
||||
[logseq.common.graph-dir :as graph-dir]
|
||||
[promesa.core :as p]))
|
||||
|
||||
(def ^:private backup-root-dir-name "backup")
|
||||
(def ^:private backup-db-file-name "db.sqlite")
|
||||
(def ^:private backup-metadata-file-name "metadata.edn")
|
||||
(def ^:private metadata-schema-version 1)
|
||||
|
||||
(defn- required!
|
||||
[value label]
|
||||
(when-not (and (some? value)
|
||||
(or (not (string? value))
|
||||
(seq value)))
|
||||
(throw (js/Error. (str label " is required"))))
|
||||
value)
|
||||
|
||||
(defn- child-path!
|
||||
[parent child label]
|
||||
(let [path (node-path/join parent child)
|
||||
parent-path (node-path/resolve parent)
|
||||
child-path (node-path/resolve path)
|
||||
relative-path (node-path/relative parent-path child-path)]
|
||||
(when (or (string/blank? relative-path)
|
||||
(string/starts-with? relative-path "..")
|
||||
(node-path/isAbsolute relative-path))
|
||||
(throw (ex-info (str "invalid " label " path")
|
||||
{:code :invalid-backup-path
|
||||
:parent parent
|
||||
:child child
|
||||
:path path})))
|
||||
path))
|
||||
|
||||
(defn backup-root-path
|
||||
[graphs-dir repo]
|
||||
(required! graphs-dir "graphs-dir")
|
||||
(required! repo "repo")
|
||||
(node-path/join (child-path! graphs-dir
|
||||
(required! (graph-dir/repo->encoded-graph-dir-name repo)
|
||||
"encoded graph directory")
|
||||
"graph directory")
|
||||
backup-root-dir-name))
|
||||
|
||||
(defn backup-dir-name
|
||||
[backup-name]
|
||||
(required! backup-name "backup-name")
|
||||
(required! (graph-dir/graph-dir-key->encoded-dir-name backup-name)
|
||||
"encoded backup directory"))
|
||||
|
||||
(defn backup-dir-path
|
||||
[graphs-dir repo backup-name]
|
||||
(child-path! (backup-root-path graphs-dir repo)
|
||||
(backup-dir-name backup-name)
|
||||
"backup directory"))
|
||||
|
||||
(defn backup-db-path
|
||||
[graphs-dir repo backup-name]
|
||||
(node-path/join (backup-dir-path graphs-dir repo backup-name)
|
||||
backup-db-file-name))
|
||||
|
||||
(defn backup-metadata-path
|
||||
[graphs-dir repo backup-name]
|
||||
(node-path/join (backup-dir-path graphs-dir repo backup-name)
|
||||
backup-metadata-file-name))
|
||||
|
||||
(defn- pad2
|
||||
[value]
|
||||
(if (< value 10)
|
||||
(str "0" value)
|
||||
(str value)))
|
||||
|
||||
(defn- utc-timestamp
|
||||
[]
|
||||
(let [now (js/Date.)]
|
||||
(str (.getUTCFullYear now)
|
||||
(pad2 (inc (.getUTCMonth now)))
|
||||
(pad2 (.getUTCDate now))
|
||||
"T"
|
||||
(pad2 (.getUTCHours now))
|
||||
(pad2 (.getUTCMinutes now))
|
||||
(pad2 (.getUTCSeconds now))
|
||||
"Z")))
|
||||
|
||||
(defn- trimmed-option
|
||||
[value]
|
||||
(some-> value str string/trim not-empty))
|
||||
|
||||
(defn build-backup-name
|
||||
([repo label]
|
||||
(build-backup-name repo label (utc-timestamp)))
|
||||
([repo label timestamp]
|
||||
(let [graph-name (required! (graph-dir/repo->graph-dir-key repo) "graph name")
|
||||
label-part (trimmed-option label)]
|
||||
(if (seq label-part)
|
||||
(str graph-name "-" label-part "-" timestamp)
|
||||
(str graph-name "-" timestamp)))))
|
||||
|
||||
(defn next-backup-target
|
||||
[graphs-dir repo base-name]
|
||||
(required! base-name "backup-name")
|
||||
(loop [suffix 0]
|
||||
(let [backup-name (if (zero? suffix)
|
||||
base-name
|
||||
(str base-name "-" suffix))
|
||||
dir-path (backup-dir-path graphs-dir repo backup-name)
|
||||
db-path (node-path/join dir-path backup-db-file-name)]
|
||||
(if (fs/existsSync dir-path)
|
||||
(recur (inc suffix))
|
||||
{:backup-name backup-name
|
||||
:dir-path dir-path
|
||||
:db-path db-path}))))
|
||||
|
||||
(defn- stat-file
|
||||
[file-path]
|
||||
(try
|
||||
(let [stat (fs/statSync file-path)]
|
||||
(when (.isFile stat)
|
||||
stat))
|
||||
(catch :default e
|
||||
(when-not (= "ENOENT" (.-code e))
|
||||
(throw e)))))
|
||||
|
||||
(defn- read-metadata
|
||||
[metadata-path]
|
||||
(try
|
||||
(reader/read-string (.toString (fs/readFileSync metadata-path) "utf8"))
|
||||
(catch :default _
|
||||
nil)))
|
||||
|
||||
(defn- backup-entry
|
||||
[root-path ^js dirent]
|
||||
(let [dir-name (.-name dirent)
|
||||
backup-name (graph-dir/decode-graph-dir-name dir-name)
|
||||
dir-path (node-path/join root-path dir-name)
|
||||
db-path (node-path/join dir-path backup-db-file-name)]
|
||||
(when (seq backup-name)
|
||||
(when-let [stat (stat-file db-path)]
|
||||
{:name backup-name
|
||||
:dir-path dir-path
|
||||
:path db-path
|
||||
:created-at (.-mtimeMs stat)
|
||||
:size-bytes (.-size stat)
|
||||
:metadata (read-metadata (node-path/join dir-path backup-metadata-file-name))}))))
|
||||
|
||||
(defn- backup-entries
|
||||
[graphs-dir repo]
|
||||
(let [root-path (backup-root-path graphs-dir repo)]
|
||||
(if (fs/existsSync root-path)
|
||||
(->> (fs/readdirSync root-path #js {:withFileTypes true})
|
||||
(filter #(.isDirectory ^js %))
|
||||
(keep #(backup-entry root-path %))
|
||||
(sort-by (juxt :name :created-at))
|
||||
vec)
|
||||
[])))
|
||||
|
||||
(defn list-backups
|
||||
[graphs-dir repo]
|
||||
(->> (backup-entries graphs-dir repo)
|
||||
(mapv (fn [{:keys [metadata] :as entry}]
|
||||
(cond-> (select-keys entry [:name :created-at :size-bytes])
|
||||
(some? (:source metadata))
|
||||
(assoc :source (:source metadata)))))))
|
||||
|
||||
(defn latest-backup-info
|
||||
[graphs-dir repo source]
|
||||
(->> (backup-entries graphs-dir repo)
|
||||
(filter #(= source (get-in % [:metadata :source])))
|
||||
(sort-by #(get-in % [:metadata :created-at-ms]) >)
|
||||
first))
|
||||
|
||||
(defn prune-backups!
|
||||
[{:keys [graphs-dir repo source keep-versions]}]
|
||||
(required! graphs-dir "graphs-dir")
|
||||
(required! repo "repo")
|
||||
(required! source "source")
|
||||
(when-not (nat-int? keep-versions)
|
||||
(throw (js/Error. "keep-versions must be a non-negative integer")))
|
||||
(let [to-remove (->> (backup-entries graphs-dir repo)
|
||||
(filter #(= source (get-in % [:metadata :source])))
|
||||
(sort-by #(get-in % [:metadata :created-at-ms]) >)
|
||||
(drop keep-versions)
|
||||
vec)]
|
||||
(doseq [{:keys [dir-path]} to-remove]
|
||||
(fs/rmSync dir-path #js {:recursive true :force true}))
|
||||
(mapv #(select-keys % [:name :dir-path :path]) to-remove)))
|
||||
|
||||
(defn remove-backup!
|
||||
[graphs-dir repo backup-name]
|
||||
(let [dir-path (backup-dir-path graphs-dir repo backup-name)]
|
||||
(if (fs/existsSync dir-path)
|
||||
(do
|
||||
(fs/rmSync dir-path #js {:recursive true :force true})
|
||||
true)
|
||||
false)))
|
||||
|
||||
(defn- reserve-next-backup-target!
|
||||
[graphs-dir repo base-name]
|
||||
(fs/mkdirSync (backup-root-path graphs-dir repo) #js {:recursive true})
|
||||
(loop [suffix 0]
|
||||
(let [backup-name (if (zero? suffix)
|
||||
base-name
|
||||
(str base-name "-" suffix))
|
||||
dir-path (backup-dir-path graphs-dir repo backup-name)
|
||||
result (try
|
||||
(fs/mkdirSync dir-path)
|
||||
{:backup-name backup-name
|
||||
:dir-path dir-path
|
||||
:db-path (node-path/join dir-path backup-db-file-name)}
|
||||
(catch :default e
|
||||
(if (= "EEXIST" (.-code e))
|
||||
::retry
|
||||
(throw e))))]
|
||||
(if (= ::retry result)
|
||||
(recur (inc suffix))
|
||||
result))))
|
||||
|
||||
(defn- cleanup-reserved-target!
|
||||
[dir-path tmp-db-path db-path]
|
||||
(when (and (seq tmp-db-path)
|
||||
(fs/existsSync tmp-db-path))
|
||||
(fs/rmSync tmp-db-path #js {:force true}))
|
||||
(when-not (fs/existsSync db-path)
|
||||
(fs/rmSync dir-path #js {:recursive true :force true})))
|
||||
|
||||
(defn- write-metadata!
|
||||
[{:keys [graphs-dir repo backup-name source created-at-ms db-path]}]
|
||||
(fs/writeFileSync
|
||||
(backup-metadata-path graphs-dir repo backup-name)
|
||||
(pr-str {:schema-version metadata-schema-version
|
||||
:name backup-name
|
||||
:repo repo
|
||||
:source source
|
||||
:created-at-ms created-at-ms
|
||||
:db-path db-path})
|
||||
"utf8"))
|
||||
|
||||
(defn- throttled?
|
||||
[{:keys [graphs-dir repo source throttle-ms now-ms]}]
|
||||
(when (and (pos-int? throttle-ms)
|
||||
(some? source))
|
||||
(when-let [latest (latest-backup-info graphs-dir repo source)]
|
||||
(let [created-at-ms (get-in latest [:metadata :created-at-ms])]
|
||||
(and (number? created-at-ms)
|
||||
(< (- now-ms created-at-ms) throttle-ms))))))
|
||||
|
||||
(defn <create-backup!
|
||||
[{:keys [graphs-dir repo backup-name source snapshot! now-ms keep-versions]
|
||||
:as opts}]
|
||||
(try
|
||||
(required! graphs-dir "graphs-dir")
|
||||
(required! repo "repo")
|
||||
(required! backup-name "backup-name")
|
||||
(required! source "source")
|
||||
(when-not (fn? snapshot!)
|
||||
(throw (js/Error. "snapshot! is required")))
|
||||
(let [created-at-ms (or now-ms (js/Date.now))]
|
||||
(if (throttled? (assoc opts :now-ms created-at-ms))
|
||||
(p/resolved {:backup-name nil
|
||||
:path nil
|
||||
:created? false
|
||||
:reason :too-soon})
|
||||
(let [{:keys [backup-name dir-path db-path]} (reserve-next-backup-target!
|
||||
graphs-dir repo backup-name)
|
||||
tmp-db-path (node-path/join dir-path
|
||||
(str "db."
|
||||
(.-pid js/process)
|
||||
"."
|
||||
(random-uuid)
|
||||
".tmp.sqlite"))]
|
||||
(-> (p/let [_ (snapshot! tmp-db-path)
|
||||
_ (when-not (stat-file tmp-db-path)
|
||||
(throw (ex-info "snapshot did not create sqlite backup"
|
||||
{:code :missing-snapshot
|
||||
:path tmp-db-path})))
|
||||
_ (fs/renameSync tmp-db-path db-path)
|
||||
_ (write-metadata! {:graphs-dir graphs-dir
|
||||
:repo repo
|
||||
:backup-name backup-name
|
||||
:source source
|
||||
:created-at-ms created-at-ms
|
||||
:db-path db-path})
|
||||
_ (when (some? keep-versions)
|
||||
(prune-backups! {:graphs-dir graphs-dir
|
||||
:repo repo
|
||||
:source source
|
||||
:keep-versions keep-versions}))]
|
||||
{:backup-name backup-name
|
||||
:path db-path
|
||||
:created? true})
|
||||
(p/catch (fn [e]
|
||||
(cleanup-reserved-target! dir-path tmp-db-path db-path)
|
||||
(throw e)))))))
|
||||
(catch :default e
|
||||
(p/rejected e))))
|
||||
@@ -1,5 +1,6 @@
|
||||
(ns electron.db-test
|
||||
(:require ["node:sqlite" :as node-sqlite]
|
||||
[cljs.reader :as reader]
|
||||
[cljs.test :refer [async deftest is]]
|
||||
[electron.backup-file :as backup-file]
|
||||
[electron.db :as electron-db]
|
||||
@@ -10,6 +11,7 @@
|
||||
[logseq.cli.transport :as cli-transport]
|
||||
[logseq.db.common.sqlite :as common-sqlite]
|
||||
[logseq.db.sqlite.backup :as sqlite-backup]
|
||||
[logseq.db-worker.graph-backup :as graph-backup]
|
||||
[promesa.core :as p]
|
||||
["fs-extra" :as fs]
|
||||
["path" :as node-path]))
|
||||
@@ -42,6 +44,26 @@
|
||||
(is (= "db-data"
|
||||
(.toString (electron-db/get-db db-name)))))))
|
||||
|
||||
(defn- read-edn-file
|
||||
[file-path]
|
||||
(reader/read-string (fs/readFileSync file-path "utf8")))
|
||||
|
||||
(defn- write-backup!
|
||||
[graphs-dir repo backup-name source created-at-ms]
|
||||
(let [db-path (graph-backup/backup-db-path graphs-dir repo backup-name)
|
||||
metadata-path (graph-backup/backup-metadata-path graphs-dir repo backup-name)]
|
||||
(fs/ensureDirSync (node-path/dirname db-path))
|
||||
(fs/writeFileSync db-path (str "sqlite-" backup-name) "utf8")
|
||||
(fs/writeFileSync metadata-path
|
||||
(pr-str {:schema-version 1
|
||||
:name backup-name
|
||||
:repo repo
|
||||
:source source
|
||||
:created-at-ms created-at-ms
|
||||
:db-path db-path})
|
||||
"utf8")
|
||||
db-path))
|
||||
|
||||
(deftest backup-db-creates-sqlite-copy-from-existing-disk-db
|
||||
(async done
|
||||
(let [graphs-dir (node-helper/create-tmp-dir "electron-db-backup")
|
||||
@@ -49,38 +71,33 @@
|
||||
DatabaseSync (resolve-database-sync-ctor)
|
||||
original-get-db-graphs-dir cli-common-graph/get-db-graphs-dir]
|
||||
(set! cli-common-graph/get-db-graphs-dir (fn [] graphs-dir))
|
||||
(let [[_ db-path] (common-sqlite/get-db-full-path graphs-dir db-name)
|
||||
backups-path (common-sqlite/get-db-backups-path graphs-dir db-name)]
|
||||
(let [[_ db-path] (common-sqlite/get-db-full-path graphs-dir db-name)]
|
||||
(fs/ensureDirSync (node-path/dirname db-path))
|
||||
(let [source-db (new DatabaseSync db-path)]
|
||||
(.exec source-db "create table kvs (addr text primary key, content text);")
|
||||
(.exec source-db "insert into kvs (addr, content) values ('a', 'alpha')")
|
||||
(.close source-db))
|
||||
(-> (p/with-redefs [backup-file/backup-file (fn [_repo _dir _relative-path _ext content & {:keys [backups-dir]}]
|
||||
(fs/ensureDirSync backups-dir)
|
||||
(fs/writeFileSync (node-path/join backups-dir "copy.sqlite") content))]
|
||||
(p/let [_ (electron-db/backup-db! db-name nil)
|
||||
backup-files (vec (fs/readdirSync backups-path))
|
||||
backup-path (node-path/join backups-path (first backup-files))
|
||||
(-> (p/let [_ (electron-db/backup-db! db-name nil)
|
||||
backups (graph-backup/list-backups graphs-dir db-name)
|
||||
backup-path (graph-backup/backup-db-path graphs-dir db-name (:name (first backups)))
|
||||
backup-db (new DatabaseSync backup-path)
|
||||
stmt (.prepare ^js backup-db "select addr, content from kvs order by addr")
|
||||
rows (.all stmt)
|
||||
_ (.close backup-db)]
|
||||
(is (= 1 (count backup-files)))
|
||||
(is (= 1 (count backups)))
|
||||
(is (= "a" (gobj/get (first rows) "addr")))
|
||||
(is (= "alpha" (gobj/get (first rows) "content")))))
|
||||
(is (= "alpha" (gobj/get (first rows) "content"))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally (fn []
|
||||
(set! cli-common-graph/get-db-graphs-dir original-get-db-graphs-dir)
|
||||
(done))))))))
|
||||
|
||||
(deftest backup-db-uses-backup-file-rules
|
||||
(deftest backup-db-uses-shared-backup-layout-and-metadata
|
||||
(async done
|
||||
(let [graphs-dir (node-helper/create-tmp-dir "electron-db-backup-rules")
|
||||
db-name "logseq_db_demo"
|
||||
[_ db-path] (common-sqlite/get-db-full-path graphs-dir db-name)
|
||||
backups-path (common-sqlite/get-db-backups-path graphs-dir db-name)
|
||||
sqlite-calls (atom [])
|
||||
backup-calls (atom [])]
|
||||
(fs/ensureDirSync (node-path/dirname db-path))
|
||||
@@ -101,17 +118,16 @@
|
||||
(p/let [_ (electron-db/backup-db! db-name nil)]
|
||||
(is (= 1 (count @sqlite-calls)))
|
||||
(is (= db-path (first (first @sqlite-calls))))
|
||||
(is (= 1 (count @backup-calls)))
|
||||
(let [{:keys [repo dir relative-path ext content opts]} (first @backup-calls)]
|
||||
(is (= db-name repo))
|
||||
(is (= nil dir))
|
||||
(is (= nil relative-path))
|
||||
(is (= ".sqlite" ext))
|
||||
(is (= "copied" content))
|
||||
(is (= backups-path (:backups-dir opts)))
|
||||
(is (= 12 (:keep-versions opts)))
|
||||
(is (contains? opts :force-backup?))
|
||||
(is (nil? (:force-backup? opts))))))
|
||||
(is (empty? @backup-calls))
|
||||
(let [backups (graph-backup/list-backups graphs-dir db-name)
|
||||
backup-name (:name (first backups))
|
||||
backup-db-path (graph-backup/backup-db-path graphs-dir db-name backup-name)
|
||||
metadata (read-edn-file (graph-backup/backup-metadata-path graphs-dir db-name backup-name))]
|
||||
(is (= 1 (count backups)))
|
||||
(is (= "copied" (fs/readFileSync backup-db-path "utf8")))
|
||||
(is (= db-name (:repo metadata)))
|
||||
(is (= :electron-auto (:source metadata)))
|
||||
(is (= backup-name (:name metadata))))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally done)))))
|
||||
@@ -121,7 +137,6 @@
|
||||
(let [graphs-dir (node-helper/create-tmp-dir "electron-db-backup-custom-snapshot")
|
||||
db-name "logseq_db_demo"
|
||||
[_ db-path] (common-sqlite/get-db-full-path graphs-dir db-name)
|
||||
backups-path (common-sqlite/get-db-backups-path graphs-dir db-name)
|
||||
custom-calls (atom [])
|
||||
fallback-calls (atom [])
|
||||
backup-calls (atom [])]
|
||||
@@ -147,13 +162,111 @@
|
||||
(is (= [[db-path (second (first @custom-calls))]]
|
||||
@custom-calls))
|
||||
(is (empty? @fallback-calls))
|
||||
(is (= 1 (count @backup-calls)))
|
||||
(let [{:keys [repo ext content opts]} (first @backup-calls)]
|
||||
(is (= db-name repo))
|
||||
(is (= ".sqlite" ext))
|
||||
(is (= "worker-copy" content))
|
||||
(is (= backups-path (:backups-dir opts)))
|
||||
(is (= true (:force-backup? opts))))))
|
||||
(is (empty? @backup-calls))
|
||||
(let [backups (graph-backup/list-backups graphs-dir db-name)
|
||||
backup-name (:name (first backups))
|
||||
backup-db-path (graph-backup/backup-db-path graphs-dir db-name backup-name)
|
||||
metadata (read-edn-file (graph-backup/backup-metadata-path graphs-dir db-name backup-name))]
|
||||
(is (= 1 (count backups)))
|
||||
(is (= "worker-copy" (fs/readFileSync backup-db-path "utf8")))
|
||||
(is (= :electron-manual (:source metadata))))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally done)))))
|
||||
|
||||
(deftest backup-db-via-worker-uses-shared-layout-and-worker-snapshot
|
||||
(async done
|
||||
(let [graphs-dir (node-helper/create-tmp-dir "electron-db-worker-backup")
|
||||
db-name "logseq_db_demo"
|
||||
runtime {:runtime true}
|
||||
worker-calls (atom [])
|
||||
backup-calls (atom [])]
|
||||
(-> (p/with-redefs [cli-common-graph/get-db-graphs-dir (fn [] graphs-dir)
|
||||
db-worker/ensure-runtime! (fn [repo window-id]
|
||||
(swap! worker-calls conj [:ensure-runtime repo window-id])
|
||||
(p/resolved runtime))
|
||||
cli-transport/invoke (fn [runtime' method args]
|
||||
(swap! worker-calls conj [:invoke runtime' method args])
|
||||
(fs/writeFileSync (second args) "worker-copy" "utf8")
|
||||
(p/resolved {:path (second args)}))
|
||||
backup-file/backup-file (fn [& args]
|
||||
(swap! backup-calls conj args)
|
||||
(p/resolved nil))]
|
||||
(p/let [result (electron-db/backup-db-via-worker! db-name 7 {:force-backup? true})
|
||||
backups (graph-backup/list-backups graphs-dir db-name)
|
||||
backup-name (:name (first backups))
|
||||
final-db-path (graph-backup/backup-db-path graphs-dir db-name backup-name)
|
||||
metadata (read-edn-file (graph-backup/backup-metadata-path graphs-dir db-name backup-name))
|
||||
[_ runtime' method args] (second @worker-calls)
|
||||
[repo snapshot-path] args]
|
||||
(is (= true (:created? result)))
|
||||
(is (= final-db-path (:path result)))
|
||||
(is (= [[:ensure-runtime db-name 7]
|
||||
[:invoke runtime :thread-api/backup-db-sqlite [db-name snapshot-path]]]
|
||||
@worker-calls))
|
||||
(is (= runtime runtime'))
|
||||
(is (= :thread-api/backup-db-sqlite method))
|
||||
(is (= db-name repo))
|
||||
(is (not= final-db-path snapshot-path))
|
||||
(is (= "worker-copy" (fs/readFileSync final-db-path "utf8")))
|
||||
(is (= :electron-manual (:source metadata)))
|
||||
(is (empty? @backup-calls))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally done)))))
|
||||
|
||||
(deftest automatic-worker-backup-throttles-recent-auto-backups
|
||||
(async done
|
||||
(let [graphs-dir (node-helper/create-tmp-dir "electron-db-worker-throttle")
|
||||
db-name "logseq_db_demo"
|
||||
runtime {:runtime true}
|
||||
worker-calls (atom [])]
|
||||
(-> (p/with-redefs [cli-common-graph/get-db-graphs-dir (fn [] graphs-dir)
|
||||
db-worker/ensure-runtime! (fn [repo window-id]
|
||||
(swap! worker-calls conj [:ensure-runtime repo window-id])
|
||||
(p/resolved runtime))
|
||||
cli-transport/invoke (fn [runtime' method args]
|
||||
(swap! worker-calls conj [:invoke runtime' method args])
|
||||
(fs/writeFileSync (second args) "worker-copy" "utf8")
|
||||
(p/resolved {:path (second args)}))]
|
||||
(p/let [first-result (electron-db/backup-db-via-worker! db-name 7 {})
|
||||
second-result (electron-db/backup-db-via-worker! db-name 7 {})]
|
||||
(is (= true (:created? first-result)))
|
||||
(is (= {:backup-name nil
|
||||
:path nil
|
||||
:created? false
|
||||
:reason :too-soon}
|
||||
second-result))
|
||||
(is (= 1 (count (filter #(= :invoke (first %)) @worker-calls))))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally done)))))
|
||||
|
||||
(deftest automatic-worker-backup-retains-only-twelve-auto-backups
|
||||
(async done
|
||||
(let [graphs-dir (node-helper/create-tmp-dir "electron-db-worker-retention")
|
||||
db-name "logseq_db_demo"
|
||||
runtime {:runtime true}
|
||||
worker-calls (atom [])]
|
||||
(doseq [idx (range 12)]
|
||||
(write-backup! graphs-dir db-name (str "auto-" idx) :electron-auto idx))
|
||||
(write-backup! graphs-dir db-name "manual-old" :electron-manual 0)
|
||||
(write-backup! graphs-dir db-name "cli-old" :cli 0)
|
||||
(-> (p/with-redefs [cli-common-graph/get-db-graphs-dir (fn [] graphs-dir)
|
||||
db-worker/ensure-runtime! (fn [repo window-id]
|
||||
(swap! worker-calls conj [:ensure-runtime repo window-id])
|
||||
(p/resolved runtime))
|
||||
cli-transport/invoke (fn [runtime' method args]
|
||||
(swap! worker-calls conj [:invoke runtime' method args])
|
||||
(fs/writeFileSync (second args) "worker-copy" "utf8")
|
||||
(p/resolved {:path (second args)}))]
|
||||
(p/let [result (electron-db/backup-db-via-worker! db-name 7 {})
|
||||
backup-names (set (map :name (graph-backup/list-backups graphs-dir db-name)))]
|
||||
(is (= true (:created? result)))
|
||||
(is (not (contains? backup-names "auto-0")))
|
||||
(is (contains? backup-names "manual-old"))
|
||||
(is (contains? backup-names "cli-old"))
|
||||
(is (= 14 (count backup-names)))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally done)))))
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
(is (= "/tmp/home/logseq/graphs/foo~2Fbar"
|
||||
(config/get-local-dir (str common-config/db-version-prefix "foo/bar"))))))
|
||||
|
||||
(deftest get-electron-backup-dir-uses-unified-backup-directory
|
||||
(with-redefs [state/state (atom {:system/info {:home-dir "/tmp/home"}})]
|
||||
(is (= "/tmp/home/logseq/graphs/foo~2Fbar/backup"
|
||||
(config/get-electron-backup-dir (str common-config/db-version-prefix "foo/bar"))))))
|
||||
|
||||
(deftest custom-url->ws-url-test
|
||||
(testing "https URL becomes wss"
|
||||
(is (= "wss://my-server.example.com/sync/%s"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
(ns logseq.cli.command.graph-test
|
||||
(:require ["fs" :as fs]
|
||||
["path" :as node-path]
|
||||
[cljs.reader :as reader]
|
||||
[cljs.test :refer [async deftest is]]
|
||||
[clojure.string :as string]
|
||||
[frontend.test.node-helper :as node-helper]
|
||||
@@ -10,6 +11,7 @@
|
||||
[logseq.cli.server :as cli-server]
|
||||
[logseq.cli.transport :as transport]
|
||||
[logseq.common.graph-dir :as graph-dir]
|
||||
[logseq.db-worker.graph-backup :as graph-backup]
|
||||
[promesa.core :as p]))
|
||||
|
||||
(deftest test-graph-validate-result
|
||||
@@ -264,6 +266,7 @@
|
||||
(p/resolved (assoc config :base-url "http://example")))
|
||||
transport/invoke (fn [_ method args]
|
||||
(swap! invoke-calls conj [method args])
|
||||
(fs/writeFileSync (second args) "sqlite-copy" "utf8")
|
||||
(p/resolved {:path (second args)}))]
|
||||
(p/let [result (commands/execute {:type :graph-backup-create
|
||||
:repo "logseq_db_demo"
|
||||
@@ -273,34 +276,71 @@
|
||||
(is (= :ok (:status result)))
|
||||
(is (= 1 (count @invoke-calls)))
|
||||
(let [[method [repo backup-db-path]] (first @invoke-calls)
|
||||
expected-segment (node-path/join
|
||||
(graph-dir/repo->encoded-graph-dir-name repo)
|
||||
"backup"
|
||||
(graph-dir/graph-dir-key->encoded-dir-name "demo-nightly-20260101T000000Z")
|
||||
"db.sqlite")]
|
||||
final-db-path (graph-backup/backup-db-path
|
||||
(cli-server/graphs-dir {:root-dir root-dir})
|
||||
repo
|
||||
"demo-nightly-20260101T000000Z")
|
||||
metadata-path (graph-backup/backup-metadata-path
|
||||
(cli-server/graphs-dir {:root-dir root-dir})
|
||||
repo
|
||||
"demo-nightly-20260101T000000Z")
|
||||
metadata (reader/read-string (fs/readFileSync metadata-path "utf8"))]
|
||||
(is (= :thread-api/backup-db-sqlite method))
|
||||
(is (= "logseq_db_demo" repo))
|
||||
(is (and (string? backup-db-path)
|
||||
(string/includes? backup-db-path expected-segment))))))
|
||||
(string/starts-with? backup-db-path
|
||||
(node-path/dirname final-db-path))))
|
||||
(is (not= final-db-path backup-db-path))
|
||||
(is (= final-db-path (get-in result [:data :path])))
|
||||
(is (= "sqlite-copy" (fs/readFileSync final-db-path "utf8")))
|
||||
(is (= :cli (:source metadata)))
|
||||
(is (= "demo-nightly-20260101T000000Z" (:name metadata))))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally done)))))
|
||||
|
||||
(deftest test-execute-graph-backup-create-cleans-reserved-target-after-worker-failure
|
||||
(async done
|
||||
(let [root-dir (node-helper/create-tmp-dir "cli-backup-create-cleanup")
|
||||
graphs-dir (cli-server/graphs-dir {:root-dir root-dir})
|
||||
repo "logseq_db_demo"
|
||||
backup-name "demo-failure"
|
||||
backup-dir (graph-backup/backup-dir-path graphs-dir repo backup-name)
|
||||
invoke-calls (atom [])]
|
||||
(-> (p/with-redefs [cli-server/list-graphs (fn [_] ["demo"])
|
||||
cli-server/ensure-server! (fn [config _repo]
|
||||
(p/resolved (assoc config :base-url "http://example")))
|
||||
transport/invoke (fn [_ method args]
|
||||
(swap! invoke-calls conj [method args])
|
||||
(fs/writeFileSync (second args) "partial" "utf8")
|
||||
(p/rejected (ex-info "snapshot failed"
|
||||
{:code :snapshot-failed})))]
|
||||
(graph-command/execute-graph-backup-create {:repo repo
|
||||
:graph "demo"
|
||||
:backup-name backup-name}
|
||||
{:root-dir root-dir}))
|
||||
(p/then (fn [_]
|
||||
(is false "expected graph backup create to fail")))
|
||||
(p/catch (fn [e]
|
||||
(is (= :snapshot-failed (:code (ex-data e))))
|
||||
(is (= 1 (count @invoke-calls)))
|
||||
(is (not (fs/existsSync backup-dir)))))
|
||||
(p/finally done)))))
|
||||
|
||||
(deftest test-execute-graph-backup-list-only-returns-current-graph-backups
|
||||
(async done
|
||||
(let [root-dir (node-helper/create-tmp-dir "cli-backup-list-scope")
|
||||
graphs-dir (cli-server/graphs-dir {:root-dir root-dir})
|
||||
demo-repo "logseq_db_demo"
|
||||
other-repo "logseq_db_other"
|
||||
demo-backup "demo-nightly"
|
||||
other-backup "other-nightly"
|
||||
demo-db-path (node-path/join root-dir
|
||||
"graphs"
|
||||
demo-db-path (node-path/join graphs-dir
|
||||
(graph-dir/repo->encoded-graph-dir-name demo-repo)
|
||||
"backup"
|
||||
(graph-dir/graph-dir-key->encoded-dir-name demo-backup)
|
||||
"db.sqlite")
|
||||
other-db-path (node-path/join root-dir
|
||||
"graphs"
|
||||
other-db-path (node-path/join graphs-dir
|
||||
(graph-dir/repo->encoded-graph-dir-name other-repo)
|
||||
"backup"
|
||||
(graph-dir/graph-dir-key->encoded-dir-name other-backup)
|
||||
@@ -366,9 +406,9 @@
|
||||
stop-calls (atom [])
|
||||
restart-calls (atom [])
|
||||
root-dir (node-helper/create-tmp-dir "cli-backup-restore-flow")
|
||||
graphs-dir (cli-server/graphs-dir {:root-dir root-dir})
|
||||
sqlite-payload (js/Buffer.from "sqlite" "utf8")
|
||||
backup-db-path (node-path/join root-dir
|
||||
"graphs"
|
||||
backup-db-path (node-path/join graphs-dir
|
||||
(graph-dir/repo->encoded-graph-dir-name "logseq_db_demo")
|
||||
"backup"
|
||||
(graph-dir/graph-dir-key->encoded-dir-name "demo-nightly")
|
||||
|
||||
@@ -996,18 +996,28 @@
|
||||
(testing "graph backup list renders metadata table"
|
||||
(let [result (format/format-result {:status :ok
|
||||
:command :graph-backup-list
|
||||
:data {:backups [{:name "demo-nightly"
|
||||
:data {:backups [{:name "demo-auto"
|
||||
:created-at 90000
|
||||
:size-bytes 2048}]}}
|
||||
:size-bytes 2048
|
||||
:source :electron-auto}
|
||||
{:name "demo-cli"
|
||||
:created-at 90000
|
||||
:size-bytes 4096
|
||||
:source :cli}
|
||||
{:name "demo-legacy"
|
||||
:created-at 90000
|
||||
:size-bytes 8192}]}}
|
||||
{:output-format nil
|
||||
:now-ms 100000})]
|
||||
(is (string/includes? result "NAME"))
|
||||
(is (string/includes? result "CREATED-AT"))
|
||||
(is (string/includes? result "SIZE-BYTES"))
|
||||
(is (string/includes? result "demo-nightly"))
|
||||
(is (string/includes? result "AUTO-SAVE"))
|
||||
(is (some? (re-find #"demo-auto\s+10 seconds ago\s+2048\s+yes" result)))
|
||||
(is (some? (re-find #"demo-cli\s+10 seconds ago\s+4096\s+no" result)))
|
||||
(is (some? (re-find #"demo-legacy\s+10 seconds ago\s+8192\s+-" result)))
|
||||
(is (string/includes? result "10 seconds ago"))
|
||||
(is (string/includes? result "2048"))
|
||||
(is (string/includes? result "Count: 1"))))
|
||||
(is (string/includes? result "Count: 3"))))
|
||||
|
||||
(testing "graph backup create renders a succinct success line"
|
||||
(let [result (format/format-result {:status :ok
|
||||
|
||||
266
src/test/logseq/db_worker/graph_backup_test.cljs
Normal file
266
src/test/logseq/db_worker/graph_backup_test.cljs
Normal file
@@ -0,0 +1,266 @@
|
||||
(ns logseq.db-worker.graph-backup-test
|
||||
(:require ["fs" :as fs]
|
||||
["path" :as node-path]
|
||||
[cljs.reader :as reader]
|
||||
[cljs.test :refer [async deftest is]]
|
||||
[frontend.test.node-helper :as node-helper]
|
||||
[logseq.common.graph-dir :as graph-dir]
|
||||
[logseq.db-worker.graph-backup :as graph-backup]
|
||||
[promesa.core :as p]))
|
||||
|
||||
(defn- read-edn-file
|
||||
[file-path]
|
||||
(reader/read-string (.toString (fs/readFileSync file-path) "utf8")))
|
||||
|
||||
(defn- write-backup!
|
||||
([graphs-dir repo backup-name]
|
||||
(write-backup! graphs-dir repo backup-name nil))
|
||||
([graphs-dir repo backup-name metadata]
|
||||
(let [db-path (graph-backup/backup-db-path graphs-dir repo backup-name)
|
||||
metadata-path (graph-backup/backup-metadata-path graphs-dir repo backup-name)]
|
||||
(fs/mkdirSync (node-path/dirname db-path) #js {:recursive true})
|
||||
(fs/writeFileSync db-path (str "sqlite-" backup-name) "utf8")
|
||||
(when metadata
|
||||
(fs/writeFileSync metadata-path (pr-str metadata) "utf8"))
|
||||
db-path)))
|
||||
|
||||
(deftest backup-paths-use-canonical-graph-backup-layout
|
||||
(let [graphs-dir "/tmp/logseq-graphs"
|
||||
repo "logseq_db_foo/bar"
|
||||
backup-name "daily:name/with space"
|
||||
encoded-graph (graph-dir/repo->encoded-graph-dir-name repo)
|
||||
encoded-backup (graph-dir/graph-dir-key->encoded-dir-name backup-name)
|
||||
backup-root (node-path/join graphs-dir encoded-graph "backup")
|
||||
backup-dir (node-path/join backup-root encoded-backup)]
|
||||
(is (= backup-root
|
||||
(graph-backup/backup-root-path graphs-dir repo)))
|
||||
(is (= encoded-backup
|
||||
(graph-backup/backup-dir-name backup-name)))
|
||||
(is (= backup-dir
|
||||
(graph-backup/backup-dir-path graphs-dir repo backup-name)))
|
||||
(is (= (node-path/join backup-dir "db.sqlite")
|
||||
(graph-backup/backup-db-path graphs-dir repo backup-name)))
|
||||
(is (= (node-path/join backup-dir "metadata.edn")
|
||||
(graph-backup/backup-metadata-path graphs-dir repo backup-name)))))
|
||||
|
||||
(deftest backup-paths-reject-directory-traversal-names
|
||||
(is (thrown-with-msg? js/Error
|
||||
#"invalid graph directory path"
|
||||
(graph-backup/backup-root-path "/tmp/logseq-graphs" "logseq_db_..")))
|
||||
(is (thrown-with-msg? js/Error
|
||||
#"invalid backup directory path"
|
||||
(graph-backup/backup-dir-path "/tmp/logseq-graphs" "logseq_db_demo" "..")))
|
||||
(is (thrown-with-msg? js/Error
|
||||
#"invalid backup directory path"
|
||||
(graph-backup/backup-dir-path "/tmp/logseq-graphs" "logseq_db_demo" "."))))
|
||||
|
||||
(deftest build-backup-name-preserves-cli-shape
|
||||
(is (= "demo-20260101T000000Z"
|
||||
(graph-backup/build-backup-name "logseq_db_demo" nil "20260101T000000Z")))
|
||||
(is (= "demo-nightly-20260101T000000Z"
|
||||
(graph-backup/build-backup-name "logseq_db_demo" " nightly " "20260101T000000Z"))))
|
||||
|
||||
(deftest next-backup-target-appends-numeric-suffix
|
||||
(let [graphs-dir (node-helper/create-tmp-dir "graph-backup-next-target")
|
||||
repo "logseq_db_demo"
|
||||
base-name "demo-nightly"
|
||||
existing-dir (graph-backup/backup-dir-path graphs-dir repo base-name)]
|
||||
(fs/mkdirSync existing-dir #js {:recursive true})
|
||||
(is (= {:backup-name "demo-nightly-1"
|
||||
:dir-path (graph-backup/backup-dir-path graphs-dir repo "demo-nightly-1")
|
||||
:db-path (graph-backup/backup-db-path graphs-dir repo "demo-nightly-1")}
|
||||
(graph-backup/next-backup-target graphs-dir repo base-name)))))
|
||||
|
||||
(deftest list-backups-only-returns-directories-with-sqlite-files
|
||||
(let [graphs-dir (node-helper/create-tmp-dir "graph-backup-list")
|
||||
repo "logseq_db_demo"
|
||||
valid-name "demo-nightly"
|
||||
incomplete-name "demo-incomplete"
|
||||
root-path (graph-backup/backup-root-path graphs-dir repo)
|
||||
incomplete-dir (graph-backup/backup-dir-path graphs-dir repo incomplete-name)]
|
||||
(write-backup! graphs-dir repo valid-name)
|
||||
(fs/mkdirSync incomplete-dir #js {:recursive true})
|
||||
(fs/writeFileSync (node-path/join root-path "not-a-directory") "ignored" "utf8")
|
||||
(is (= [valid-name]
|
||||
(mapv :name (graph-backup/list-backups graphs-dir repo))))))
|
||||
|
||||
(deftest list-backups-includes-source-when-metadata-exists
|
||||
(let [graphs-dir (node-helper/create-tmp-dir "graph-backup-list-source")
|
||||
repo "logseq_db_demo"]
|
||||
(write-backup! graphs-dir repo "demo-auto"
|
||||
{:schema-version 1
|
||||
:name "demo-auto"
|
||||
:repo repo
|
||||
:source :electron-auto
|
||||
:created-at-ms 1770000000000
|
||||
:db-path (graph-backup/backup-db-path graphs-dir repo "demo-auto")})
|
||||
(write-backup! graphs-dir repo "demo-cli"
|
||||
{:schema-version 1
|
||||
:name "demo-cli"
|
||||
:repo repo
|
||||
:source :cli
|
||||
:created-at-ms 1770000001000
|
||||
:db-path (graph-backup/backup-db-path graphs-dir repo "demo-cli")})
|
||||
(write-backup! graphs-dir repo "demo-legacy")
|
||||
(is (= [{:name "demo-auto"
|
||||
:source :electron-auto}
|
||||
{:name "demo-cli"
|
||||
:source :cli}
|
||||
{:name "demo-legacy"}]
|
||||
(mapv #(select-keys % [:name :source])
|
||||
(graph-backup/list-backups graphs-dir repo))))))
|
||||
|
||||
(deftest create-backup-snapshots-to-temp-file-before-publishing
|
||||
(async done
|
||||
(let [graphs-dir (node-helper/create-tmp-dir "graph-backup-create")
|
||||
repo "logseq_db_demo"
|
||||
backup-name "demo-nightly"
|
||||
snapshot-calls (atom [])
|
||||
final-db-path (graph-backup/backup-db-path graphs-dir repo backup-name)
|
||||
final-visible-during-snapshot? (atom nil)]
|
||||
(-> (p/let [result (graph-backup/<create-backup!
|
||||
{:graphs-dir graphs-dir
|
||||
:repo repo
|
||||
:backup-name backup-name
|
||||
:source :cli
|
||||
:now-ms 1770000000000
|
||||
:snapshot! (fn [tmp-db-path]
|
||||
(reset! final-visible-during-snapshot?
|
||||
(fs/existsSync final-db-path))
|
||||
(swap! snapshot-calls conj tmp-db-path)
|
||||
(fs/writeFileSync tmp-db-path "sqlite-copy" "utf8")
|
||||
(p/resolved {:path tmp-db-path}))})]
|
||||
(is (= {:backup-name backup-name
|
||||
:path final-db-path
|
||||
:created? true}
|
||||
result))
|
||||
(is (= 1 (count @snapshot-calls)))
|
||||
(is (false? @final-visible-during-snapshot?))
|
||||
(is (= (node-path/dirname final-db-path)
|
||||
(node-path/dirname (first @snapshot-calls))))
|
||||
(is (not= final-db-path (first @snapshot-calls)))
|
||||
(is (= "sqlite-copy" (fs/readFileSync final-db-path "utf8")))
|
||||
(let [metadata (read-edn-file (graph-backup/backup-metadata-path graphs-dir repo backup-name))]
|
||||
(is (= {:schema-version 1
|
||||
:name backup-name
|
||||
:repo repo
|
||||
:source :cli
|
||||
:created-at-ms 1770000000000
|
||||
:db-path final-db-path}
|
||||
metadata))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally done)))))
|
||||
|
||||
(deftest failed-snapshot-removes-reserved-backup-directory
|
||||
(async done
|
||||
(let [graphs-dir (node-helper/create-tmp-dir "graph-backup-failed-create")
|
||||
repo "logseq_db_demo"
|
||||
backup-name "demo-failure"
|
||||
target-dir (graph-backup/backup-dir-path graphs-dir repo backup-name)]
|
||||
(-> (graph-backup/<create-backup!
|
||||
{:graphs-dir graphs-dir
|
||||
:repo repo
|
||||
:backup-name backup-name
|
||||
:source :cli
|
||||
:snapshot! (fn [tmp-db-path]
|
||||
(fs/writeFileSync tmp-db-path "partial" "utf8")
|
||||
(p/rejected (ex-info "snapshot failed"
|
||||
{:code :snapshot-failed})))})
|
||||
(p/then (fn [_]
|
||||
(is false "expected snapshot failure")))
|
||||
(p/catch (fn [e]
|
||||
(is (= :snapshot-failed (:code (ex-data e))))
|
||||
(is (not (fs/existsSync target-dir)))
|
||||
(is (= [] (graph-backup/list-backups graphs-dir repo)))))
|
||||
(p/finally done)))))
|
||||
|
||||
(deftest cli-backups-do-not-use-desktop-automatic-throttling
|
||||
(async done
|
||||
(let [graphs-dir (node-helper/create-tmp-dir "graph-backup-cli-no-throttle")
|
||||
repo "logseq_db_demo"
|
||||
now-ms 1770000000000
|
||||
snapshot-calls (atom [])]
|
||||
(write-backup! graphs-dir repo "demo-auto-recent"
|
||||
{:schema-version 1
|
||||
:name "demo-auto-recent"
|
||||
:repo repo
|
||||
:source :electron-auto
|
||||
:created-at-ms (- now-ms 1000)
|
||||
:db-path (graph-backup/backup-db-path graphs-dir repo "demo-auto-recent")})
|
||||
(-> (p/let [result (graph-backup/<create-backup!
|
||||
{:graphs-dir graphs-dir
|
||||
:repo repo
|
||||
:backup-name "demo-cli"
|
||||
:source :cli
|
||||
:now-ms now-ms
|
||||
:snapshot! (fn [tmp-db-path]
|
||||
(swap! snapshot-calls conj tmp-db-path)
|
||||
(fs/writeFileSync tmp-db-path "cli" "utf8")
|
||||
(p/resolved nil))})]
|
||||
(is (= true (:created? result)))
|
||||
(is (= 1 (count @snapshot-calls)))
|
||||
(is (= #{"demo-auto-recent" "demo-cli"}
|
||||
(set (map :name (graph-backup/list-backups graphs-dir repo))))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally done)))))
|
||||
|
||||
(deftest desktop-automatic-backup-skips-when-recent-auto-backup-exists
|
||||
(async done
|
||||
(let [graphs-dir (node-helper/create-tmp-dir "graph-backup-auto-throttle")
|
||||
repo "logseq_db_demo"
|
||||
now-ms 1770000000000
|
||||
snapshot-calls (atom [])]
|
||||
(write-backup! graphs-dir repo "demo-auto-recent"
|
||||
{:schema-version 1
|
||||
:name "demo-auto-recent"
|
||||
:repo repo
|
||||
:source :electron-auto
|
||||
:created-at-ms (- now-ms 60000)
|
||||
:db-path (graph-backup/backup-db-path graphs-dir repo "demo-auto-recent")})
|
||||
(-> (p/let [result (graph-backup/<create-backup!
|
||||
{:graphs-dir graphs-dir
|
||||
:repo repo
|
||||
:backup-name "demo-auto-current"
|
||||
:source :electron-auto
|
||||
:now-ms now-ms
|
||||
:throttle-ms 3600000
|
||||
:snapshot! (fn [tmp-db-path]
|
||||
(swap! snapshot-calls conj tmp-db-path)
|
||||
(p/resolved nil))})]
|
||||
(is (= {:backup-name nil
|
||||
:path nil
|
||||
:created? false
|
||||
:reason :too-soon}
|
||||
result))
|
||||
(is (empty? @snapshot-calls))
|
||||
(is (not (fs/existsSync (graph-backup/backup-dir-path graphs-dir repo "demo-auto-current")))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally done)))))
|
||||
|
||||
(deftest automatic-retention-only-prunes-explicit-auto-backups
|
||||
(let [graphs-dir (node-helper/create-tmp-dir "graph-backup-auto-retention")
|
||||
repo "logseq_db_demo"
|
||||
metadata (fn [backup-name source created-at-ms]
|
||||
{:schema-version 1
|
||||
:name backup-name
|
||||
:repo repo
|
||||
:source source
|
||||
:created-at-ms created-at-ms
|
||||
:db-path (graph-backup/backup-db-path graphs-dir repo backup-name)})]
|
||||
(write-backup! graphs-dir repo "auto-old" (metadata "auto-old" :electron-auto 1000))
|
||||
(write-backup! graphs-dir repo "auto-middle" (metadata "auto-middle" :electron-auto 2000))
|
||||
(write-backup! graphs-dir repo "auto-new" (metadata "auto-new" :electron-auto 3000))
|
||||
(write-backup! graphs-dir repo "manual-old" (metadata "manual-old" :electron-manual 1))
|
||||
(write-backup! graphs-dir repo "cli-old" (metadata "cli-old" :cli 1))
|
||||
(write-backup! graphs-dir repo "unknown-old")
|
||||
(is (= ["auto-old"]
|
||||
(mapv :name (graph-backup/prune-backups! {:graphs-dir graphs-dir
|
||||
:repo repo
|
||||
:source :electron-auto
|
||||
:keep-versions 2}))))
|
||||
(is (not (fs/existsSync (graph-backup/backup-dir-path graphs-dir repo "auto-old"))))
|
||||
(is (= #{"auto-middle" "auto-new" "manual-old" "cli-old" "unknown-old"}
|
||||
(set (map :name (graph-backup/list-backups graphs-dir repo)))))))
|
||||
Reference in New Issue
Block a user