mirror of
https://github.com/logseq/logseq.git
synced 2026-05-14 16:02:31 +00:00
feat(cli): add cmd 'sync asset download'
This commit is contained in:
249
cli-e2e/scripts/sync_asset_download.py
Normal file
249
cli-e2e/scripts/sync_asset_download.py
Normal file
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise `logseq sync asset download` in an isolated sync e2e graph."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List
|
||||
|
||||
|
||||
def fail(message: str, **context: object) -> None:
|
||||
payload = {"status": "error", "message": message}
|
||||
if context:
|
||||
payload["context"] = context
|
||||
print(json.dumps(payload), file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def cli_base(cli: Path, root_dir: Path, config: Path) -> List[str]:
|
||||
return [
|
||||
"node",
|
||||
str(cli),
|
||||
"--root-dir",
|
||||
str(root_dir),
|
||||
"--config",
|
||||
str(config),
|
||||
"--output",
|
||||
"json",
|
||||
]
|
||||
|
||||
|
||||
def run_cli_json(command: List[str], *, allow_error: bool = False) -> Dict[str, Any]:
|
||||
result = subprocess.run(command, capture_output=True, text=True)
|
||||
if result.returncode != 0 and not allow_error:
|
||||
fail(
|
||||
"cli command failed",
|
||||
command=command,
|
||||
exit=result.returncode,
|
||||
stdout=result.stdout,
|
||||
stderr=result.stderr,
|
||||
)
|
||||
|
||||
try:
|
||||
payload = json.loads(result.stdout)
|
||||
except json.JSONDecodeError as error:
|
||||
fail(
|
||||
"cli command did not return valid JSON",
|
||||
command=command,
|
||||
exit=result.returncode,
|
||||
stdout=result.stdout,
|
||||
stderr=result.stderr,
|
||||
detail=str(error),
|
||||
)
|
||||
|
||||
if payload.get("status") != "ok" and not allow_error:
|
||||
fail("cli command returned non-ok status", command=command, payload=payload)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def contains_key(value: Any, key: str) -> bool:
|
||||
if isinstance(value, dict):
|
||||
return key in value or any(contains_key(child, key) for child in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(contains_key(child, key) for child in value)
|
||||
return False
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(8192), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def wait_for_checksum(path: Path, checksum: str, timeout_s: float = 60.0) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
last_state = "missing"
|
||||
while time.time() < deadline:
|
||||
if path.exists():
|
||||
current = sha256_file(path)
|
||||
if current == checksum:
|
||||
return
|
||||
last_state = f"checksum:{current}"
|
||||
time.sleep(1.0)
|
||||
fail("asset file did not reach expected checksum", path=str(path), expected=checksum, last_state=last_state)
|
||||
|
||||
|
||||
def get_asset(cli: Path, root_dir: Path, config: Path, graph: str, title: str) -> Dict[str, Any]:
|
||||
query = (
|
||||
'[:find (pull ?e [:db/id :block/uuid '
|
||||
':logseq.property.asset/type :logseq.property.asset/checksum '
|
||||
':logseq.property.asset/remote-metadata]) . '
|
||||
f':where [?e :block/title "{title}"]]'
|
||||
)
|
||||
payload = run_cli_json(
|
||||
cli_base(cli, root_dir, config)
|
||||
+ ["query", "--graph", graph, "--query", query]
|
||||
)
|
||||
asset = (payload.get("data") or {}).get("result")
|
||||
if not isinstance(asset, dict):
|
||||
fail("asset query did not return an entity", payload=payload)
|
||||
required = [
|
||||
"db/id",
|
||||
"block/uuid",
|
||||
"logseq.property.asset/type",
|
||||
"logseq.property.asset/checksum",
|
||||
"logseq.property.asset/remote-metadata",
|
||||
]
|
||||
missing = [key for key in required if not asset.get(key)]
|
||||
if missing:
|
||||
fail("asset entity is missing required fields", asset=asset, missing=missing)
|
||||
return asset
|
||||
|
||||
|
||||
def sync_asset_download_by_id(cli: Path, root_dir: Path, config: Path, graph: str, asset_id: Any) -> Dict[str, Any]:
|
||||
return run_cli_json(
|
||||
cli_base(cli, root_dir, config)
|
||||
+ ["sync", "asset", "download", "--graph", graph, "--id", str(asset_id)]
|
||||
)
|
||||
|
||||
|
||||
def sync_asset_download_by_uuid(cli: Path, root_dir: Path, config: Path, graph: str, asset_uuid: str) -> Dict[str, Any]:
|
||||
return run_cli_json(
|
||||
cli_base(cli, root_dir, config)
|
||||
+ ["sync", "asset", "download", "--graph", graph, "--uuid", asset_uuid]
|
||||
)
|
||||
|
||||
|
||||
def assert_data(payload: Dict[str, Any], expected: Dict[str, Any]) -> None:
|
||||
data = payload.get("data") or {}
|
||||
for key, value in expected.items():
|
||||
if data.get(key) != value:
|
||||
fail("unexpected sync asset download data", expected=expected, actual=data, payload=payload)
|
||||
if contains_key(payload, "local-path"):
|
||||
fail("sync asset download output leaked local-path", payload=payload)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Run sync asset download e2e assertions")
|
||||
parser.add_argument("--cli", required=True)
|
||||
parser.add_argument("--graph", required=True)
|
||||
parser.add_argument("--asset-title", required=True)
|
||||
parser.add_argument("--config-b", required=True)
|
||||
parser.add_argument("--root-dir-b", required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
cli = Path(args.cli).expanduser().resolve()
|
||||
root_b = Path(args.root_dir_b).expanduser().resolve()
|
||||
config_b = Path(args.config_b).expanduser().resolve()
|
||||
|
||||
asset = get_asset(cli, root_b, config_b, args.graph, args.asset_title)
|
||||
asset_id = asset["db/id"]
|
||||
asset_uuid = asset["block/uuid"]
|
||||
asset_type = asset["logseq.property.asset/type"]
|
||||
checksum = asset["logseq.property.asset/checksum"]
|
||||
asset_path = root_b / "graphs" / args.graph / "assets" / f"{asset_uuid}.{asset_type}"
|
||||
|
||||
if asset_path.exists():
|
||||
asset_path.unlink()
|
||||
|
||||
requested = sync_asset_download_by_uuid(cli, root_b, config_b, args.graph, asset_uuid)
|
||||
assert_data(
|
||||
requested,
|
||||
{
|
||||
"asset-id": asset_id,
|
||||
"asset-uuid": asset_uuid,
|
||||
"asset-type": asset_type,
|
||||
"download-requested?": True,
|
||||
"checksum-status": "missing",
|
||||
},
|
||||
)
|
||||
wait_for_checksum(asset_path, checksum)
|
||||
|
||||
skipped = sync_asset_download_by_id(cli, root_b, config_b, args.graph, asset_id)
|
||||
assert_data(
|
||||
skipped,
|
||||
{
|
||||
"asset-id": asset_id,
|
||||
"asset-uuid": asset_uuid,
|
||||
"asset-type": asset_type,
|
||||
"download-requested?": False,
|
||||
"checksum-status": "match",
|
||||
"skipped-reason": "already-downloaded",
|
||||
},
|
||||
)
|
||||
|
||||
asset_path.write_text("corrupted local asset", encoding="utf-8")
|
||||
mismatched = sync_asset_download_by_id(cli, root_b, config_b, args.graph, asset_id)
|
||||
assert_data(
|
||||
mismatched,
|
||||
{
|
||||
"asset-id": asset_id,
|
||||
"asset-uuid": asset_uuid,
|
||||
"asset-type": asset_type,
|
||||
"download-requested?": True,
|
||||
"checksum-status": "mismatch",
|
||||
},
|
||||
)
|
||||
hint = (mismatched.get("data") or {}).get("hint") or ""
|
||||
if "checksum" not in hint:
|
||||
fail("checksum mismatch hint is missing", payload=mismatched)
|
||||
wait_for_checksum(asset_path, checksum)
|
||||
|
||||
run_cli_json(cli_base(cli, root_b, config_b) + ["sync", "stop", "--graph", args.graph])
|
||||
inactive = run_cli_json(
|
||||
cli_base(cli, root_b, config_b)
|
||||
+ ["sync", "asset", "download", "--graph", args.graph, "--id", str(asset_id)],
|
||||
allow_error=True,
|
||||
)
|
||||
if inactive.get("status") != "error":
|
||||
fail("inactive sync command unexpectedly succeeded", payload=inactive)
|
||||
error = inactive.get("error") or {}
|
||||
if error.get("code") != "sync-not-started":
|
||||
fail("inactive sync command returned wrong error", payload=inactive)
|
||||
if "sync start" not in (error.get("hint") or ""):
|
||||
fail("inactive sync command did not include start hint", payload=inactive)
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"asset-id": asset_id,
|
||||
"asset-uuid": asset_uuid,
|
||||
"asset-type": asset_type,
|
||||
"download-requested?": True,
|
||||
"checksum-status": "mismatch",
|
||||
"pending-local": 0,
|
||||
"pending-asset": 0,
|
||||
"pending-server": 0,
|
||||
"last-error": None,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -189,6 +189,21 @@
|
||||
{:random-seed "424242",
|
||||
:random-page "SyncRandomOpsHome",
|
||||
:rounds-per-client "40"}}
|
||||
{:tags [:happy-path :asset-download :a-to-b],
|
||||
:extends :sync/common,
|
||||
:setup
|
||||
["printf '{{asset-payload}}' > '{{tmp-dir}}/sync-asset-download.txt'"
|
||||
"{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert asset --graph {{graph-arg}} --path '{{tmp-dir}}/sync-asset-download.txt' --content '{{asset-title}}' --target-page Home >/dev/null"],
|
||||
:cmds
|
||||
["HOME='{{tmp-dir}}/home' python3 '{{repo-root}}/cli-e2e/scripts/sync_asset_download.py' --cli '{{repo-root}}/static/logseq-cli.js' --graph '{{graph}}' --asset-title '{{asset-title}}' --config-b '{{tmp-dir}}/cli-b.edn' --root-dir-b '{{tmp-dir}}/graphs-b'"],
|
||||
:id "sync-asset-download-a-to-b",
|
||||
:graph "sync-e2e-asset-download-a-to-b",
|
||||
:vars
|
||||
{:asset-title "Sync Asset Download Asset"
|
||||
:asset-payload "sync-asset-download-payload"},
|
||||
:covers
|
||||
{:commands ["upsert asset" "query" "sync asset download"],
|
||||
:options {:sync ["--id" "--uuid"]}}}
|
||||
{:tags [:stress :offline :bidirectional :random :block-ops],
|
||||
:extends :sync/common,
|
||||
:setup
|
||||
|
||||
@@ -9,5 +9,6 @@
|
||||
:sync
|
||||
{:commands ["sync upload"
|
||||
"sync download"
|
||||
"sync asset download"
|
||||
"sync status"]
|
||||
:options []}}}
|
||||
|
||||
@@ -54,7 +54,11 @@
|
||||
"while ! mkdir \"$LOCK_DIR\" 2>/dev/null; do [ -f \"$DONE_FILE\" ] && exit 0; sleep 0.1; done; "
|
||||
"trap 'rmdir \"$LOCK_DIR\" 2>/dev/null || true' EXIT; "
|
||||
"if [ ! -f \"$DONE_FILE\" ]; then "
|
||||
"{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json sync ensure-keys --graph {{user-keys-graph-arg}} --e2ee-password {{e2ee-password-arg}} --upload-keys >/dev/null && touch \"$DONE_FILE\"; "
|
||||
"{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph create --graph {{user-keys-graph-arg}} >/dev/null 2>/dev/null || true; "
|
||||
"if {{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json sync ensure-keys --graph {{user-keys-graph-arg}} --e2ee-password {{e2ee-password-arg}} --upload-keys >/dev/null; then "
|
||||
"{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json server stop --graph {{user-keys-graph-arg}} >/dev/null 2>/dev/null || true; "
|
||||
"touch \"$DONE_FILE\"; "
|
||||
"else exit 1; fi; "
|
||||
"fi")
|
||||
lock-dir
|
||||
done-file)))
|
||||
|
||||
@@ -100,6 +100,7 @@ The top-level help groups commands into graph inspection/editing, graph manageme
|
||||
- `sync stop`
|
||||
- `sync upload`
|
||||
- `sync download`
|
||||
- `sync asset download`
|
||||
- `sync remote-graphs`
|
||||
- `sync ensure-keys`
|
||||
- `sync grant-access`
|
||||
@@ -319,7 +320,7 @@ Backups store `db.sqlite` under `<root-dir>/graphs/<encoded-graph>/backup/<encod
|
||||
|
||||
Sync commands live in `command/sync.cljs`.
|
||||
|
||||
Authenticated sync actions are `sync start`, `sync upload`, `sync download`, `sync remote-graphs`, `sync ensure-keys`, and `sync grant-access`; they resolve runtime auth from `auth.json` unless token data is already present in runtime config. `sync status`, `sync stop`, and `sync config` commands do not require the CLI login resolution path in the same way.
|
||||
Authenticated sync actions are `sync start`, `sync upload`, `sync download`, `sync asset download`, `sync remote-graphs`, `sync ensure-keys`, and `sync grant-access`; they resolve runtime auth from `auth.json` unless token data is already present in runtime config. `sync status`, `sync stop`, and `sync config` commands do not require the CLI login resolution path in the same way.
|
||||
|
||||
Current sync commands:
|
||||
|
||||
@@ -328,12 +329,16 @@ Current sync commands:
|
||||
- `sync stop --graph <name>` stops db-sync for a graph worker.
|
||||
- `sync upload --graph <name> [--e2ee-password <password>]` uploads the local graph snapshot.
|
||||
- `sync download --graph <name> [--progress true|false] [--e2ee-password <password>]` downloads a remote graph into a new local graph. It creates an empty DB, requires the local graph to be missing, checks that the target DB is empty, uses a 30-minute download timeout by default, and cleans up a newly created graph on failure.
|
||||
- `sync asset download --graph <name> --id <asset-db-id>` requests one remote asset download by the `ID` shown by `list asset`.
|
||||
- `sync asset download --graph <name> --uuid <asset-uuid>` requests one remote asset download by asset block UUID.
|
||||
- `sync remote-graphs` lists remote graphs visible to the current login context.
|
||||
- `sync ensure-keys [--e2ee-password <password>] [--upload-keys]` ensures user RSA keys; `--upload-keys` asks the worker to ensure server-side presence.
|
||||
- `sync grant-access --graph <name> --graph-id <uuid> --email <email>` grants graph access to an email.
|
||||
- `sync config set|get|unset ws-url|http-base` manages non-auth sync config keys in `cli.edn`.
|
||||
|
||||
Sync config defaults are `wss://api.logseq.io/sync/%s` for `:ws-url` and `https://api.logseq.io` for `:http-base`. Missing required endpoint values for start/upload/download/grant-access return `:missing-sync-config`.
|
||||
Sync config defaults are `wss://api.logseq.io/sync/%s` for `:ws-url` and `https://api.logseq.io` for `:http-base`. Missing required endpoint values for start/upload/download/asset-download/grant-access return `:missing-sync-config`.
|
||||
|
||||
`sync asset download` reuses the existing db-worker-node asset request API `:thread-api/db-sync-request-asset-download`; it does not add a dedicated worker API. The command requires sync to already be active for the graph and returns `:sync-not-started` with `logseq sync start --graph <graph>` guidance when the worker sync client is inactive. Before enqueueing, it resolves the asset node, verifies asset UUID/type/checksum/remote metadata, rejects external URL assets, checks the local `assets/<asset-uuid>.<asset-type>` file, skips matching checksums, and requests a re-download on checksum mismatch. It returns immediately after enqueue and does not return local filesystem paths. This first version intentionally does not accept `--e2ee-password`.
|
||||
|
||||
For E2EE graphs, `--e2ee-password` verifies and persists the password through worker sync crypt logic. Missing required E2EE password state returns `:e2ee-password-not-found` with a hint to provide `--e2ee-password`.
|
||||
|
||||
|
||||
572
docs/agent-guide/logseq-cli/007-sync-asset-download.md
Normal file
572
docs/agent-guide/logseq-cli/007-sync-asset-download.md
Normal file
@@ -0,0 +1,572 @@
|
||||
# Sync Asset Download Implementation Plan
|
||||
|
||||
Goal: Add `logseq sync asset download --id <asset-db-id>|--uuid <asset-uuid>` so CLI users can request one remote asset download into the local graph `assets/` directory.
|
||||
|
||||
Architecture: Keep command parsing and orchestration in the existing Logseq CLI sync command namespace.
|
||||
Architecture: Reuse the existing `db-worker-node` thread API `:thread-api/db-sync-request-asset-download` instead of adding another worker API.
|
||||
Architecture: Resolve `--id` or `--uuid` to the asset UUID, asset type, and checksum before requesting the worker download, skip the request when the local file exists and checksum matches, and request a re-download when the local checksum mismatches.
|
||||
|
||||
Tech Stack: ClojureScript, Logseq CLI, `db-worker-node`, Datascript pull/query through existing thread APIs, Promesa, Node filesystem APIs and checksum helpers, CLI unit tests, worker sync asset tests, and CLI e2e sync manifests.
|
||||
|
||||
Related: Builds on `/Users/rcmerci/gh-repos/logseq/docs/agent-guide/logseq-cli/001-logseq-cli.md` and `/Users/rcmerci/gh-repos/logseq/docs/agent-guide/logseq-cli/005-graph-create-enable-sync.md`.
|
||||
|
||||
## Problem statement
|
||||
|
||||
The CLI can create and list assets through `upsert asset` and `list asset`.
|
||||
|
||||
The CLI can sync whole graphs through `sync upload`, `sync download`, and `sync start`.
|
||||
|
||||
The CLI cannot currently request a single remote asset download after graph metadata has already been synced locally.
|
||||
|
||||
The desktop UI already has an asset-demand path that calls `:thread-api/db-sync-request-asset-download` when an asset block has remote metadata and the local file is not ready.
|
||||
|
||||
The worker-side request path already checks `platform/asset-stat` before downloading and only calls `download-remote-asset!` when the local file is missing.
|
||||
|
||||
The new CLI command should expose the same capability for headless usage while preserving the existing worker ownership of sync asset download logic.
|
||||
|
||||
The command should not add a new `db-worker-node` thread API.
|
||||
|
||||
The command should not trigger a download request when the target local file exists and its checksum matches the asset metadata.
|
||||
|
||||
The command should trigger a new request when the target local file exists but its checksum mismatches, and the output should include a clear mismatch hint.
|
||||
|
||||
The command should return immediately after the worker accepts the enqueue request.
|
||||
|
||||
The command should fail fast when sync is not already running for the graph, with a hint to run `logseq sync start --graph <graph>` first.
|
||||
|
||||
The command should not accept `--e2ee-password` in this first version.
|
||||
|
||||
## Testing Plan
|
||||
|
||||
Implementation should follow @Test-Driven Development.
|
||||
|
||||
I will add parser and action-builder tests in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/command/sync_test.cljs` proving `sync asset download --graph demo --id 123` builds a `:sync-asset-download` action with `:id 123`, `:repo "logseq_db_demo"`, and `:graph "demo"`.
|
||||
|
||||
I will add parser and action-builder tests proving `sync asset download --graph demo --uuid <asset-uuid>` builds a `:sync-asset-download` action with `:uuid <asset-uuid>`, `:repo "logseq_db_demo"`, and `:graph "demo"`.
|
||||
|
||||
I will add parser and action-builder tests proving `sync asset download` without `--graph` fails with `:missing-graph`, without `--id` or `--uuid` fails with `:invalid-options` or `:missing-target`, and with both `--id` and `--uuid` fails with `:invalid-options`.
|
||||
|
||||
I will add execution tests in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/command/sync_test.cljs` proving the command resolves the asset entity through `:thread-api/pull` or `:thread-api/q`, checks the expected local file and checksum, syncs worker runtime auth/config, verifies sync is already active, and invokes `:thread-api/db-sync-request-asset-download` with `[repo asset-uuid]` only when the file is missing or checksum-mismatched.
|
||||
|
||||
I will add an execution test proving an already-present local file with matching checksum returns an ok result with `:download-requested? false` and does not call `:thread-api/db-sync-request-asset-download`.
|
||||
|
||||
I will add an execution test proving an already-present local file with mismatched checksum returns an ok result with `:download-requested? true`, includes a checksum mismatch hint, and calls `:thread-api/db-sync-request-asset-download`.
|
||||
|
||||
I will add an execution test proving an inactive sync client returns `:sync-not-started` with a hint to run `logseq sync start --graph <graph>`, and does not call `:thread-api/db-sync-request-asset-download`.
|
||||
|
||||
I will add execution tests proving non-asset selectors, missing selectors, missing `:block/uuid`, missing asset type, missing checksum, missing remote metadata, and external-url assets fail or skip with explicit error codes instead of silently reporting success.
|
||||
|
||||
I will add a worker sync asset unit test around `/Users/rcmerci/gh-repos/logseq/src/main/frontend/worker/sync/assets.cljs` proving `request-asset-download!` does not call `download-remote-asset!` when `platform/asset-stat` returns metadata for the expected `assets/<asset-uuid>.<asset-type>` file.
|
||||
|
||||
I will add a worker sync asset unit test proving `request-asset-download!` calls `download-remote-asset!` when the asset has remote metadata, has no external URL, has an asset type, and `platform/asset-stat` returns nil.
|
||||
|
||||
I will add human formatter tests in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/commands_test.cljs` or the existing format test namespace proving `:sync-asset-download` prints a useful message for both requested and skipped results.
|
||||
|
||||
I will add structured output assertions proving JSON and EDN include `:asset-id`, `:asset-uuid`, `:asset-type`, `:download-requested?`, `:checksum-status`, `:skipped-reason`, and `:hint` when applicable, and do not include `:local-path`.
|
||||
|
||||
I will add CLI e2e coverage in `/Users/rcmerci/gh-repos/logseq/cli-e2e/spec/sync_cases.edn` or a focused adjacent sync spec that uploads a graph with an asset, starts sync in a second root, runs `sync asset download --id <asset-db-id>`, and checks that the command returns immediately with an enqueue result.
|
||||
|
||||
I will add a second e2e assertion for the already-downloaded case that runs the same command again after the file exists with a matching checksum and verifies the command reports a skip without emitting a second download request observable through worker logs if checksum-only assertions are too brittle.
|
||||
|
||||
I will add a checksum mismatch e2e assertion that corrupts the local asset file, runs the command again, and verifies the command reports a checksum mismatch hint and requests a re-download.
|
||||
|
||||
I will update `/Users/rcmerci/gh-repos/logseq/cli-e2e/spec/sync_inventory.edn` so command coverage includes `sync asset download`.
|
||||
|
||||
I will run `bb dev:test -v logseq.cli.command.sync-test` after writing command tests and expect the new tests to fail before implementation.
|
||||
|
||||
I will run the worker sync asset test namespace after writing worker tests and expect the new tests to fail if the current test seam is missing or if the skip behavior is not directly covered.
|
||||
|
||||
I will run the formatter test after writing formatter coverage and expect the new `:sync-asset-download` human-output assertion to fail before the formatter is updated.
|
||||
|
||||
I will run `bb -f cli-e2e/bb.edn test --skip-build --case <new-sync-asset-download-case>` after adding the e2e case and expect it to fail before the built CLI supports the command.
|
||||
|
||||
After implementation, I will rerun `bb dev:test -v logseq.cli.command.sync-test` and expect all sync command tests to pass.
|
||||
|
||||
After implementation, I will rerun the worker sync asset test namespace and expect the asset skip and download-request tests to pass.
|
||||
|
||||
After implementation, I will rerun the formatter test namespace and expect human and structured output tests to pass.
|
||||
|
||||
After implementation, I will rebuild the CLI when needed and rerun `bb -f cli-e2e/bb.edn test --skip-build --case <new-sync-asset-download-case>`.
|
||||
|
||||
Before submitting, I will run `bb dev:lint-and-test` if the local environment can support the full test suite.
|
||||
|
||||
NOTE: I will write *all* tests before I add any implementation behavior.
|
||||
|
||||
## Current implementation snapshot
|
||||
|
||||
The current sync command implementation lives in `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/sync.cljs`.
|
||||
|
||||
The existing sync command table defines `sync status`, `sync start`, `sync stop`, `sync upload`, `sync download`, `sync remote-graphs`, `sync ensure-keys`, `sync grant-access`, and `sync config` subcommands.
|
||||
|
||||
The existing sync action builder routes sync command keywords through `logseq.cli.command.sync/build-action`.
|
||||
|
||||
The existing sync executor routes sync command action types through `logseq.cli.command.sync/execute`.
|
||||
|
||||
The top-level command table in `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/commands.cljs` includes `sync-command/entries`, delegates sync action building to `sync-command/build-action`, and delegates sync execution to `sync-command/execute`.
|
||||
|
||||
The existing authenticated sync action set includes `:sync-start`, `:sync-upload`, `:sync-download`, `:sync-remote-graphs`, `:sync-ensure-keys`, and `:sync-grant-access`.
|
||||
|
||||
The existing sync runtime setup calls `:thread-api/sync-app-state` and `:thread-api/set-db-sync-config` before worker sync operations.
|
||||
|
||||
The existing `sync download` command uses `:thread-api/db-sync-list-remote-graphs` and `:thread-api/db-sync-download-graph-by-id`.
|
||||
|
||||
The new command should not use the graph snapshot download API because it only needs one asset.
|
||||
|
||||
The existing worker thread API is exposed in `/Users/rcmerci/gh-repos/logseq/src/main/frontend/worker/db_core.cljs`.
|
||||
|
||||
The relevant API is:
|
||||
|
||||
```text
|
||||
:thread-api/db-sync-request-asset-download [repo asset-uuid]
|
||||
-> frontend.worker.sync/request-asset-download!
|
||||
-> frontend.worker.sync.apply-txs/request-asset-download!
|
||||
-> frontend.worker.sync.assets/request-asset-download!
|
||||
```
|
||||
|
||||
The worker-side download implementation lives in `/Users/rcmerci/gh-repos/logseq/src/main/frontend/worker/sync/assets.cljs`.
|
||||
|
||||
That implementation builds the remote asset URL from the configured `http-base`, graph id, asset UUID, and asset type.
|
||||
|
||||
That implementation decrypts the payload when graph E2EE is enabled.
|
||||
|
||||
That implementation writes the local file through the active worker platform as `assets/<asset-uuid>.<asset-type>`.
|
||||
|
||||
That implementation already checks `platform/asset-stat` before calling `download-remote-asset!`.
|
||||
|
||||
The desktop UI demand path lives in `/Users/rcmerci/gh-repos/logseq/src/main/frontend/handler/assets.cljs`.
|
||||
|
||||
The UI demand path only requests a remote asset download when the asset has remote metadata, has no external URL, has an asset type, the local file is not ready, and no asset transfer is already in progress.
|
||||
|
||||
The CLI asset creation path lives in `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/upsert.cljs`.
|
||||
|
||||
That path stores local assets under the graph `assets/` directory as `<block-uuid>.<ext>` and stores asset metadata on the asset block.
|
||||
|
||||
The CLI asset listing path lives in `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/list.cljs`.
|
||||
|
||||
That path treats `--id` in human output as the Datascript `:db/id`, not the block UUID.
|
||||
|
||||
## Proposed command contract
|
||||
|
||||
The command syntax should be:
|
||||
|
||||
```text
|
||||
logseq sync asset download --graph <graph-name> --id <asset-db-id>
|
||||
logseq sync asset download --graph <graph-name> --uuid <asset-uuid>
|
||||
```
|
||||
|
||||
The `--id` option should identify the asset node by Datascript `:db/id` because current CLI table output labels db/id as `ID`.
|
||||
|
||||
The `--uuid` option should identify the asset node by `:block/uuid` for scripts that already know the asset UUID.
|
||||
|
||||
The command should require exactly one of `--id` or `--uuid`.
|
||||
|
||||
The command should require `--graph` because it needs a local graph, a local assets directory, and a graph-specific worker server.
|
||||
|
||||
The command should resolve runtime auth like other authenticated sync commands because remote asset downloads use sync HTTP auth headers.
|
||||
|
||||
The command should apply sync config defaults in the same way as other sync commands.
|
||||
|
||||
The command should require `http-base` because the worker asset download URL is HTTP based.
|
||||
|
||||
The command should not accept `--e2ee-password`; users should persist the E2EE password through existing sync flows before using this command.
|
||||
|
||||
The command should fail fast when sync is not active for the graph, with a hint to run `logseq sync start --graph <graph>`.
|
||||
|
||||
The command should return immediately after the worker accepts the request enqueue.
|
||||
|
||||
The command should return `:status :ok` when it successfully enqueues or requests the worker download.
|
||||
|
||||
The command should return `:status :ok` with `:download-requested? false` when the local file exists and its checksum matches asset metadata.
|
||||
|
||||
The command should return `:status :ok` with `:download-requested? true` and a checksum mismatch hint when the local file exists but its checksum differs from asset metadata.
|
||||
|
||||
The command should return `:status :error` when the selected id or uuid is missing, is not an asset, has no UUID, has no asset type, has no checksum, has no remote metadata, or points to an external URL asset.
|
||||
|
||||
The command should not return the local path because this first version only requests enqueue and returns immediately.
|
||||
|
||||
## Command flow
|
||||
|
||||
```text
|
||||
CLI args
|
||||
-> commands/parse
|
||||
-> sync-command/build-action
|
||||
-> sync-command/execute
|
||||
-> resolve runtime auth
|
||||
-> ensure db-worker-node server for repo
|
||||
-> sync worker runtime auth and sync config
|
||||
-> fetch and validate asset entity by db/id or block uuid
|
||||
-> ensure db-sync status is active for this graph
|
||||
-> compute assets/<asset-uuid>.<asset-type>
|
||||
-> check local file exists
|
||||
-> compute local checksum when the file exists
|
||||
-> return skipped result when local checksum matches
|
||||
-> return requested result with mismatch hint when local checksum mismatches
|
||||
-> invoke :thread-api/db-sync-request-asset-download [repo asset-uuid]
|
||||
-> return requested result immediately
|
||||
```
|
||||
|
||||
The local file and checksum preflight should happen before the worker API call.
|
||||
|
||||
The command should not wait for the worker file write to finish.
|
||||
|
||||
The worker-side `platform/asset-stat` check should remain as the second line of defense.
|
||||
|
||||
## Phase 1. Add failing command parse and action tests.
|
||||
|
||||
1. Open `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/command/sync_test.cljs`.
|
||||
|
||||
2. Add a test that calls `sync-command/build-action` with `:sync-asset-download`, `{:id 123}`, no positional args, and repo `logseq_db_demo`.
|
||||
|
||||
3. Assert the result is ok and the action includes `:type :sync-asset-download`, `:repo "logseq_db_demo"`, `:graph "demo"`, and `:id 123`.
|
||||
|
||||
4. Add a test that calls `sync-command/build-action` with `:sync-asset-download`, `{:uuid "asset-uuid"}`, no positional args, and repo `logseq_db_demo`.
|
||||
|
||||
5. Assert the result is ok and the action includes `:type :sync-asset-download`, `:repo "logseq_db_demo"`, `:graph "demo"`, and `:uuid "asset-uuid"`.
|
||||
|
||||
6. Add a test for missing repo and assert `:missing-repo`.
|
||||
|
||||
7. Add a test for missing selector and assert an explicit validation error.
|
||||
|
||||
8. Add a test for both `--id` and `--uuid` and assert an explicit validation error.
|
||||
|
||||
9. If parser-level tests exist in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/commands_test.cljs`, add parse tests for `sync asset download --graph demo --id 123` and `sync asset download --graph demo --uuid asset-uuid`.
|
||||
|
||||
10. Run `bb dev:test -v logseq.cli.command.sync-test`.
|
||||
|
||||
11. Confirm the new tests fail because the command entry and build-action branch do not exist yet.
|
||||
|
||||
## Phase 2. Add the command entry and action builder.
|
||||
|
||||
1. Edit `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/sync.cljs`.
|
||||
|
||||
2. Add a `sync-asset-download-spec` with `:id` described as the target asset node db/id and `:uuid` described as the target asset block UUID.
|
||||
|
||||
3. Coerce `:id` with the same numeric id style used by other CLI id options and coerce `:uuid` as string.
|
||||
|
||||
4. Do not add `:e2ee-password` to this command spec.
|
||||
|
||||
5. Add `(core/command-entry ["sync" "asset" "download"] :sync-asset-download "Download remote asset" sync-asset-download-spec ...)` to `entries`.
|
||||
|
||||
6. Include examples for `logseq sync asset download --graph my-graph --id 123` and `logseq sync asset download --graph my-graph --uuid <asset-uuid>`.
|
||||
|
||||
7. Add `:sync-asset-download` to `authenticated-sync-actions` because the worker uses sync auth headers for asset HTTP requests.
|
||||
|
||||
8. Add `:sync-asset-download [:http-base]` to `required-sync-config-keys-by-action`.
|
||||
|
||||
9. Add `build-sync-asset-download-action` that requires repo and exactly one of id or uuid.
|
||||
|
||||
10. Route `:sync-asset-download` inside `build-action`.
|
||||
|
||||
11. Edit `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/commands.cljs`.
|
||||
|
||||
12. Add `:sync-asset-download` to the sync command groups in `build-action` and `execute-action` routing.
|
||||
|
||||
13. Add validation for missing `--graph`, missing selector, and conflicting `--id` plus `--uuid` in command finalization if the generic action builder error is not enough for parser-level behavior.
|
||||
|
||||
14. Run `bb dev:test -v logseq.cli.command.sync-test`.
|
||||
|
||||
15. Confirm the parser and action tests now pass while execution tests still fail.
|
||||
|
||||
## Phase 3. Add asset entity resolution and local-file preflight tests.
|
||||
|
||||
1. Add execution tests in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/command/sync_test.cljs` using `p/with-redefs` around `cli-server/ensure-server!`, `transport/invoke`, and filesystem helpers.
|
||||
|
||||
2. Stub `transport/invoke` for `:thread-api/pull` or `:thread-api/q` so the command can fetch an asset entity containing `:db/id`, `:block/uuid`, `:block/tags`, `:logseq.property.asset/type`, `:logseq.property.asset/checksum`, `:logseq.property.asset/remote-metadata`, and `:logseq.property.asset/external-url`.
|
||||
|
||||
3. Test the missing-local-file case and assert `:thread-api/db-sync-request-asset-download` is called with repo and asset UUID.
|
||||
|
||||
4. Test the existing-local-file with matching checksum case and assert `:thread-api/db-sync-request-asset-download` is not called.
|
||||
|
||||
5. Test that the matching-checksum case returns structured data with `:download-requested? false`, `:checksum-status :match`, and `:skipped-reason :already-downloaded`.
|
||||
|
||||
6. Test the existing-local-file with checksum mismatch case and assert `:thread-api/db-sync-request-asset-download` is called with repo and asset UUID.
|
||||
|
||||
7. Test that the checksum-mismatch case returns structured data with `:download-requested? true`, `:checksum-status :mismatch`, and a hint explaining that the local file checksum mismatched and a re-download was requested.
|
||||
|
||||
8. Test inactive sync status and assert `:sync-not-started` with a `logseq sync start --graph demo` hint.
|
||||
|
||||
9. Test a non-asset selector and assert `:asset-not-found` or `:not-asset`.
|
||||
|
||||
10. Test an asset with `:logseq.property.asset/external-url` and assert `:external-asset` because remote sync download should not fetch external URLs.
|
||||
|
||||
11. Test an asset without `:logseq.property.asset/remote-metadata` and assert `:asset-not-remote` or `:asset-remote-metadata-not-found`.
|
||||
|
||||
12. Run `bb dev:test -v logseq.cli.command.sync-test`.
|
||||
|
||||
13. Confirm the execution tests fail before implementation.
|
||||
|
||||
## Phase 4. Implement asset entity validation and local-file preflight.
|
||||
|
||||
1. Edit `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/sync.cljs`.
|
||||
|
||||
2. Add a private asset selector that fetches the fields required for validation and output.
|
||||
|
||||
3. Resolve the selected entity by db/id or block UUID through existing worker APIs such as `:thread-api/pull` or `:thread-api/q`.
|
||||
|
||||
4. Validate that the entity exists.
|
||||
|
||||
5. Validate that it is tagged as `:logseq.class/Asset` or otherwise matches the same asset classification used by `list asset` and `upsert asset`.
|
||||
|
||||
6. Validate that `:block/uuid` is present.
|
||||
|
||||
7. Validate that `:logseq.property.asset/type` is present and non-blank.
|
||||
|
||||
8. Validate that `:logseq.property.asset/checksum` is present and non-blank.
|
||||
|
||||
9. Validate that `:logseq.property.asset/remote-metadata` is present.
|
||||
|
||||
10. Validate that `:logseq.property.asset/external-url` is blank or nil.
|
||||
|
||||
11. Compute the expected file name as `<asset-uuid>.<asset-type>`.
|
||||
|
||||
12. Compute the local path internally as `<graphs-dir>/<repo>/assets/<asset-uuid>.<asset-type>` using the same root-dir and repo conventions as `db-worker-node` and `upsert asset`.
|
||||
|
||||
13. Check local file existence with Node filesystem APIs before invoking the worker request API.
|
||||
|
||||
14. Compute the local file checksum when the file exists.
|
||||
|
||||
15. Return an ok skip result when the file exists and the local checksum matches asset metadata.
|
||||
|
||||
16. Do not call `:thread-api/db-sync-request-asset-download` in the matching-checksum skip path.
|
||||
|
||||
17. Return an ok requested result with a checksum mismatch hint when the file exists and the local checksum mismatches asset metadata.
|
||||
|
||||
18. Do not include the internally computed local path in structured output.
|
||||
|
||||
19. Keep the worker's internal `platform/asset-stat` check unchanged.
|
||||
|
||||
20. Run `bb dev:test -v logseq.cli.command.sync-test`.
|
||||
|
||||
21. Confirm entity validation and preflight tests pass.
|
||||
|
||||
## Phase 5. Implement sync runtime handling and worker request execution.
|
||||
|
||||
1. Reuse `resolve-runtime-config!` so auth is loaded from runtime config or `auth.json`.
|
||||
|
||||
2. Reuse `missing-required-sync-config-keys` with `:sync-asset-download` so missing `http-base` returns `:missing-sync-config`.
|
||||
|
||||
3. Reuse `cli-server/ensure-server!` for the target repo.
|
||||
|
||||
4. Reuse `<sync-worker-runtime!` before any worker sync calls.
|
||||
|
||||
5. Check `:thread-api/db-sync-status` before requesting an asset download.
|
||||
|
||||
6. Require the status to indicate an active sync client that can service the asset queue.
|
||||
|
||||
7. Return `:status :error` with `:code :sync-not-started` when sync is inactive, stopped, missing graph id, or otherwise unable to service the request.
|
||||
|
||||
8. Include a hint such as `Run logseq sync start --graph <graph> first.` in the `:sync-not-started` error.
|
||||
|
||||
9. Do not auto-start sync from this command.
|
||||
|
||||
10. Invoke `transport/invoke` with `:thread-api/db-sync-request-asset-download` and `[repo asset-uuid]` only after local preflight passes or checksum mismatch is detected.
|
||||
|
||||
11. Return ok data containing `:asset-id` when available, `:asset-uuid`, `:asset-type`, `:download-requested? true`, and `:checksum-status`.
|
||||
|
||||
12. Do not include `:local-path` in returned data.
|
||||
|
||||
13. Treat a nil worker result as success because the current worker API returns nil for enqueued work.
|
||||
|
||||
14. Return immediately after the worker request call resolves.
|
||||
|
||||
15. Do not add polling or waiting for the file to exist.
|
||||
|
||||
16. Run `bb dev:test -v logseq.cli.command.sync-test`.
|
||||
|
||||
17. Confirm the request execution tests pass.
|
||||
|
||||
## Phase 6. Add worker-side regression coverage for local skip behavior.
|
||||
|
||||
1. Open or create the appropriate worker sync asset test namespace under `/Users/rcmerci/gh-repos/logseq/src/test/frontend/worker/`.
|
||||
|
||||
2. Prefer a focused namespace for `frontend.worker.sync.assets` if one already exists or if current test setup makes a focused unit test simple.
|
||||
|
||||
3. Stub `current-client-f` to return a client map with `:graph-id` and an asset queue promise chain.
|
||||
|
||||
4. Stub `worker-state/get-datascript-conn` with a Datascript conn containing one asset entity.
|
||||
|
||||
5. Stub `platform/current` so `platform/asset-stat` returns file metadata in the skip test.
|
||||
|
||||
6. Stub `download-remote-asset!` and record calls.
|
||||
|
||||
7. Assert the skip test records no `download-remote-asset!` call.
|
||||
|
||||
8. Add a missing-local-file test where `platform/asset-stat` returns nil.
|
||||
|
||||
9. Assert the missing-local-file test records exactly one `download-remote-asset!` call with repo, graph id, asset UUID, and asset type.
|
||||
|
||||
10. Run the worker test namespace.
|
||||
|
||||
11. Confirm both tests pass.
|
||||
|
||||
## Phase 7. Add human and structured output support.
|
||||
|
||||
1. Edit `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/format.cljs`.
|
||||
|
||||
2. Add `:sync-asset-download` to the ok-result command routing.
|
||||
|
||||
3. Render the requested case as a concise human message such as `Sync asset download requested: <asset-uuid> (repo: <repo>)`.
|
||||
|
||||
4. Render the checksum mismatch requested case with a hint such as `Local asset checksum mismatched; requested re-download: <asset-uuid>`.
|
||||
|
||||
5. Render the skipped case as a concise human message such as `Sync asset already downloaded: <asset-uuid>`.
|
||||
|
||||
6. Do not print or return the local path.
|
||||
|
||||
7. Keep JSON and EDN output as the raw structured `:data` map.
|
||||
|
||||
8. Add tests for requested, checksum-mismatch requested, and skipped human output cases.
|
||||
|
||||
9. Run the formatter tests.
|
||||
|
||||
10. Confirm output tests pass.
|
||||
|
||||
## Phase 8. Update CLI docs and examples.
|
||||
|
||||
1. Edit `/Users/rcmerci/gh-repos/logseq/docs/cli/logseq-cli.md`.
|
||||
|
||||
2. Add `sync asset download --graph <name> --id <asset-db-id>` and `sync asset download --graph <name> --uuid <asset-uuid>` to the sync command list.
|
||||
|
||||
3. Document that `--id` is the `ID` returned by `list asset`.
|
||||
|
||||
4. Document that `--uuid` is the asset block UUID.
|
||||
|
||||
5. Document that the command skips work when `assets/<asset-uuid>.<asset-type>` already exists locally and its checksum matches.
|
||||
|
||||
6. Document that checksum mismatch requests a re-download and prints a hint.
|
||||
|
||||
7. Document that the command requires sync to already be started and fails with a hint otherwise.
|
||||
|
||||
8. Document that the command returns immediately after enqueue and does not support `--e2ee-password`.
|
||||
|
||||
9. Edit `/Users/rcmerci/gh-repos/logseq/docs/agent-guide/logseq-cli/001-logseq-cli.md`.
|
||||
|
||||
10. Add the new command to the CLI guide sync command list and explain that it uses the existing worker asset request API.
|
||||
|
||||
11. Run any docs checks that the repository normally uses if available.
|
||||
|
||||
## Phase 9. Add CLI e2e coverage.
|
||||
|
||||
1. Read `/Users/rcmerci/gh-repos/logseq/cli-e2e/AGENTS.md` before editing `cli-e2e` files.
|
||||
|
||||
2. Add a sync case in `/Users/rcmerci/gh-repos/logseq/cli-e2e/spec/sync_cases.edn` that creates or imports a graph with one asset.
|
||||
|
||||
3. Upload that graph with `sync upload`.
|
||||
|
||||
4. Download the graph into a second root with `sync download`.
|
||||
|
||||
5. Start sync in the second root before requesting the asset download.
|
||||
|
||||
6. Use `list asset --output json` or `query --output json` in the second root to get the asset db/id, UUID, asset type, and checksum.
|
||||
|
||||
7. Remove the local asset file from the second root if snapshot download currently materializes it automatically.
|
||||
|
||||
8. Run `sync asset download --graph <graph> --id <asset-db-id> --output json`.
|
||||
|
||||
9. Assert the returned JSON has `status ok` and `download-requested? true` when the file was missing.
|
||||
|
||||
10. Assert the command output does not include `local-path`.
|
||||
|
||||
11. Poll for the expected file under the second root graph `assets/` directory only as e2e verification of the asynchronous request.
|
||||
|
||||
12. Run the same command again after the file exists with matching checksum.
|
||||
|
||||
13. Assert the returned JSON has `status ok`, `download-requested? false`, `checksum-status match`, and `skipped-reason already-downloaded`.
|
||||
|
||||
14. Corrupt the local asset file.
|
||||
|
||||
15. Run the same command again.
|
||||
|
||||
16. Assert the returned JSON has `status ok`, `download-requested? true`, `checksum-status mismatch`, and a checksum mismatch hint.
|
||||
|
||||
17. Add an adjacent case that stops sync or uses an inactive second root and asserts `:sync-not-started` with the start-sync hint.
|
||||
|
||||
18. Update `/Users/rcmerci/gh-repos/logseq/cli-e2e/spec/sync_inventory.edn` to list `sync asset download`.
|
||||
|
||||
19. Run the focused sync e2e case.
|
||||
|
||||
20. Confirm the new e2e coverage passes.
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Expected behavior |
|
||||
|------|-------------------|
|
||||
| Missing `--graph` | Fail with `:missing-graph`. |
|
||||
| Missing both `--id` and `--uuid` | Fail with a clear validation error. |
|
||||
| Both `--id` and `--uuid` are provided | Fail with `:invalid-options`. |
|
||||
| `--id` points to no entity | Fail with `:asset-not-found`. |
|
||||
| `--uuid` points to no entity | Fail with `:asset-not-found`. |
|
||||
| Selector points to a non-asset entity | Fail with `:not-asset`. |
|
||||
| Asset has no UUID | Fail with `:asset-uuid-missing`. |
|
||||
| Asset has no type | Fail with `:asset-type-missing`. |
|
||||
| Asset has no checksum | Fail with `:asset-checksum-missing`. |
|
||||
| Asset has no remote metadata | Fail with `:asset-not-remote`. |
|
||||
| Asset has external URL | Fail with `:external-asset` because sync asset download should not fetch external URLs. |
|
||||
| Local asset file is missing | Request worker download and return immediately with `:download-requested? true`. |
|
||||
| Local asset file exists and checksum matches | Return ok with `:download-requested? false` and do not invoke `:thread-api/db-sync-request-asset-download`. |
|
||||
| Local asset file exists and checksum mismatches | Return ok with `:download-requested? true`, include a mismatch hint, and invoke `:thread-api/db-sync-request-asset-download`. |
|
||||
| Local assets directory does not exist | Treat the file as missing and let worker create the directory when writing. |
|
||||
| Missing `http-base` after config defaults are applied | Fail with `:missing-sync-config`. |
|
||||
| Missing auth | Fail with existing auth resolution error and hint to run `logseq login`. |
|
||||
| E2EE graph without persisted password | Do not accept `--e2ee-password`; users must run an existing sync command that persists the password before requesting asset download. |
|
||||
| Sync client is not active | Fail fast with `:sync-not-started` and a hint to run `logseq sync start --graph <graph>`. |
|
||||
| Worker request returns nil | Treat as ok because the current request API is enqueue-oriented. |
|
||||
| Worker download fails asynchronously | Preserve worker logging and status reporting, and do not block this command for completion. |
|
||||
|
||||
## Non-goals
|
||||
|
||||
This plan does not add a new worker thread API.
|
||||
|
||||
This plan does not add a bulk asset download command.
|
||||
|
||||
This plan does not change the remote asset storage protocol.
|
||||
|
||||
This plan does not change snapshot graph download behavior.
|
||||
|
||||
This plan does not add `--e2ee-password` to `sync asset download`.
|
||||
|
||||
This plan does not make the command wait for the local file write to finish.
|
||||
|
||||
This plan does not auto-start sync when the sync client is inactive.
|
||||
|
||||
This plan does not return local filesystem paths from command output.
|
||||
|
||||
This plan does not add default values to hide invalid graph, auth, or asset state.
|
||||
|
||||
## Testing Details
|
||||
|
||||
The parser tests verify the public command contract rather than internal command table shape.
|
||||
|
||||
The execution tests verify behavior by observing whether the worker request API is called or not called under missing-file, matching-checksum, and mismatched-checksum conditions.
|
||||
|
||||
The execution tests verify fail-fast sync-state behavior by asserting an inactive sync status returns `:sync-not-started` and never invokes the worker request API.
|
||||
|
||||
The worker tests verify behavior by observing whether `download-remote-asset!` is called or not called after `platform/asset-stat`, rather than only asserting mock return data.
|
||||
|
||||
The e2e test verifies real CLI behavior by creating a synced graph, starting sync, requesting one asset download, and polling for the local file system result after the command returns.
|
||||
|
||||
The skipped e2e assertion verifies the requirement that already-downloaded assets with matching checksum do not trigger another download.
|
||||
|
||||
The checksum mismatch e2e assertion verifies that corrupt local files trigger a new request and show a clear hint.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
- Add command path `["sync" "asset" "download"]` with action type `:sync-asset-download`.
|
||||
- Support exactly one of `--id` and `--uuid` as the asset selector.
|
||||
- Treat `--id` as the asset node Datascript db/id because `list asset` exposes db/id as `ID`.
|
||||
- Resolve the asset entity before making any worker request.
|
||||
- Validate asset identity, UUID, type, checksum, remote metadata, and external URL state explicitly.
|
||||
- Compute the local asset path internally as `assets/<asset-uuid>.<asset-type>` under the graph directory.
|
||||
- Skip before invoking the worker API when the local file exists and checksum matches.
|
||||
- Request a re-download when the local file exists and checksum mismatches.
|
||||
- Reuse `:thread-api/db-sync-request-asset-download` for the actual worker request.
|
||||
- Fail fast when sync is not active instead of auto-starting sync.
|
||||
- Return immediately after enqueue and omit local paths from output.
|
||||
|
||||
## Question
|
||||
|
||||
No open product questions remain for this plan.
|
||||
|
||||
Resolved decisions are: return immediately after enqueue, fail fast when sync is inactive, do not support `--e2ee-password`, support both `--id` and `--uuid`, check local file existence plus checksum, request re-download on checksum mismatch with a hint, and omit local paths from output.
|
||||
|
||||
---
|
||||
@@ -207,6 +207,8 @@ Sync commands:
|
||||
- `sync stop --graph <name>` - stop db-sync client on a graph daemon
|
||||
- `sync upload --graph <name>` - upload local graph snapshot to remote
|
||||
- `sync download --graph <name> [--progress true|false] [--e2ee-password <password>]` - download remote graph `<name>` into a same-name local graph directory
|
||||
- `sync asset download --graph <name> --id <asset-db-id>` - request one remote asset download by the `ID` shown by `list asset`
|
||||
- `sync asset download --graph <name> --uuid <asset-uuid>` - request one remote asset download by asset block UUID
|
||||
- `sync remote-graphs [--graph <name>]` - list remote graphs visible to the current login context
|
||||
- `sync ensure-keys [--graph <name>]` - ensure user RSA keys for sync/e2ee
|
||||
- `sync grant-access --graph <name> --graph-id <uuid> --email <email>` - grant encrypted graph access to a user
|
||||
@@ -245,6 +247,17 @@ Sync download behavior:
|
||||
- For e2ee remote graphs, provide `--e2ee-password` on `sync download` (or persist once via `sync start --e2ee-password`).
|
||||
- If e2ee password is required but missing, `sync start`, `sync download`, and `sync status` return `e2ee-password-not-found` with a hint to provide `--e2ee-password`.
|
||||
|
||||
Sync asset download behavior:
|
||||
- `sync asset download` requires `--graph` and exactly one of `--id` or `--uuid`.
|
||||
- `--id` selects the asset node by the Datascript db/id shown as `ID` in `list asset` human output.
|
||||
- `--uuid` selects the asset block UUID for scripts that already track UUIDs.
|
||||
- The command requires sync to already be running for the graph. If the graph's sync client is not active, it returns `sync-not-started` with a hint to run `logseq sync start --graph <name>` first.
|
||||
- The command uses the existing worker asset request API (`:thread-api/db-sync-request-asset-download`) and returns immediately after the worker accepts the enqueue request.
|
||||
- Before enqueueing, the CLI checks the local `assets/<asset-uuid>.<asset-type>` file. If the file exists and its checksum matches asset metadata, the command reports `download-requested? false` and skips the request.
|
||||
- If the local file exists but its checksum mismatches, the command reports `checksum-status mismatch`, prints a mismatch hint in human output, and requests a re-download.
|
||||
- The first version does not accept `--e2ee-password`; persist E2EE password state with existing `sync start` or `sync download` flows before requesting asset download.
|
||||
- Structured output includes asset identity and status fields such as `asset-id`, `asset-uuid`, `asset-type`, `download-requested?`, `checksum-status`, `skipped-reason`, and `hint` when applicable. It intentionally omits local filesystem paths.
|
||||
|
||||
Sync config persistence:
|
||||
- `sync config set/unset` writes non-auth sync config to the CLI config file selected by `--config`.
|
||||
- If `--config` is not provided, the default config path is `~/logseq/cli.edn`.
|
||||
|
||||
@@ -330,6 +330,13 @@
|
||||
:base base
|
||||
:graph-id graph-id})))))
|
||||
|
||||
(defn log-request-asset-download-failed!
|
||||
[repo asset-uuid error]
|
||||
(log/error :db-sync/request-asset-download-failed
|
||||
{:repo repo
|
||||
:asset-uuid asset-uuid
|
||||
:error error}))
|
||||
|
||||
(defn request-asset-download!
|
||||
[repo asset-uuid {:keys [current-client-f enqueue-asset-task-f broadcast-rtc-state!-f]}]
|
||||
(when-let [client (current-client-f repo)]
|
||||
@@ -355,4 +362,5 @@
|
||||
(broadcast-rtc-state!-f client))]
|
||||
nil)
|
||||
(p/catch (fn [e]
|
||||
(js/console.error e)))))))))))
|
||||
(log-request-asset-download-failed! repo asset-uuid e)
|
||||
(p/rejected e)))))))))))
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
(ns logseq.cli.command.sync
|
||||
"Sync-related CLI commands."
|
||||
(:require [clojure.string :as string]
|
||||
(:require ["crypto" :as crypto]
|
||||
["fs" :as fs]
|
||||
["path" :as node-path]
|
||||
[clojure.string :as string]
|
||||
[lambdaisland.glogi :as log]
|
||||
[logseq.cli.auth :as cli-auth]
|
||||
[logseq.cli.command.core :as core]
|
||||
@@ -10,6 +13,7 @@
|
||||
[logseq.cli.server :as cli-server]
|
||||
[logseq.cli.transport :as transport]
|
||||
[logseq.common.cognito-config :as cognito-config]
|
||||
[logseq.common.graph-dir :as graph-dir]
|
||||
[promesa.core :as p]))
|
||||
|
||||
(def ^:private sync-grant-access-spec
|
||||
@@ -30,6 +34,14 @@
|
||||
:e2ee-password {:desc "Verify and persist E2EE password before download"
|
||||
:coerce :string}})
|
||||
|
||||
(def ^:private sync-asset-download-spec
|
||||
{:id {:desc "Target asset node db/id"
|
||||
:coerce :long}
|
||||
:uuid {:desc "Target asset block UUID"
|
||||
:coerce :string
|
||||
:validate {:pred (comp parse-uuid str)
|
||||
:ex-msg (constantly "Option uuid must be a valid UUID string")}}})
|
||||
|
||||
(def ^:private sync-ensure-keys-spec
|
||||
{:e2ee-password {:desc "Verify and persist E2EE password before ensuring user RSA keys"
|
||||
:coerce :string}
|
||||
@@ -51,6 +63,9 @@
|
||||
{:examples ["logseq sync download --graph my-graph"
|
||||
"logseq sync download --graph my-graph --progress"
|
||||
"logseq sync download --graph my-graph --e2ee-password \"my-secret\""]})
|
||||
(core/command-entry ["sync" "asset" "download"] :sync-asset-download "Download remote asset" sync-asset-download-spec
|
||||
{:examples ["logseq sync asset download --graph my-graph --id 123"
|
||||
"logseq sync asset download --graph my-graph --uuid <asset-uuid>"]})
|
||||
(core/command-entry ["sync" "remote-graphs"] :sync-remote-graphs "List remote graphs" {})
|
||||
(core/command-entry ["sync" "ensure-keys"] :sync-ensure-keys "Ensure user RSA keys for sync/e2ee" sync-ensure-keys-spec
|
||||
{:examples ["logseq sync ensure-keys"
|
||||
@@ -77,6 +92,7 @@
|
||||
#{:sync-start
|
||||
:sync-upload
|
||||
:sync-download
|
||||
:sync-asset-download
|
||||
:sync-remote-graphs
|
||||
:sync-ensure-keys
|
||||
:sync-grant-access})
|
||||
@@ -97,6 +113,7 @@
|
||||
{:sync-start [:ws-url]
|
||||
:sync-upload [:http-base]
|
||||
:sync-download [:http-base]
|
||||
:sync-asset-download [:http-base]
|
||||
:sync-grant-access [:http-base]})
|
||||
|
||||
(defn- config-value-present?
|
||||
@@ -160,6 +177,18 @@
|
||||
[?e :db/ident :logseq.kv/graph-rtc-e2ee?]
|
||||
[?e :kv/value ?v]])
|
||||
|
||||
(def ^:private asset-tag-ident
|
||||
:logseq.class/Asset)
|
||||
|
||||
(def ^:private sync-asset-pull-selector
|
||||
[:db/id
|
||||
:block/uuid
|
||||
{:block/tags [:db/ident]}
|
||||
:logseq.property.asset/type
|
||||
:logseq.property.asset/checksum
|
||||
:logseq.property.asset/remote-metadata
|
||||
:logseq.property.asset/external-url])
|
||||
|
||||
(defn- missing-repo
|
||||
[label]
|
||||
{:ok? false
|
||||
@@ -305,6 +334,27 @@
|
||||
:progress-explicit? (contains? options :progress)
|
||||
:e2ee-password (:e2ee-password options)}}))
|
||||
|
||||
(defn- build-sync-asset-download-action
|
||||
[options repo]
|
||||
(let [id (:id options)
|
||||
asset-uuid (some-> (:uuid options) string/trim)
|
||||
id? (some? id)
|
||||
has-uuid? (seq asset-uuid)]
|
||||
(cond
|
||||
(not (seq repo))
|
||||
(missing-repo "sync asset download")
|
||||
|
||||
(not= 1 (count (filter true? [id? (boolean has-uuid?)])))
|
||||
(invalid-options "exactly one of --id or --uuid is required")
|
||||
|
||||
:else
|
||||
{:ok? true
|
||||
:action (cond-> {:type :sync-asset-download
|
||||
:repo repo
|
||||
:graph (core/repo->graph repo)}
|
||||
id? (assoc :id id)
|
||||
has-uuid? (assoc :uuid asset-uuid))})))
|
||||
|
||||
(defn- build-sync-ensure-keys-action
|
||||
[options]
|
||||
{:ok? true
|
||||
@@ -396,6 +446,9 @@
|
||||
:sync-download
|
||||
(build-sync-download-action options repo)
|
||||
|
||||
:sync-asset-download
|
||||
(build-sync-asset-download-action options repo)
|
||||
|
||||
:sync-remote-graphs
|
||||
{:ok? true
|
||||
:action {:type :sync-remote-graphs}}
|
||||
@@ -484,6 +537,167 @@
|
||||
result (transport/invoke cfg method args)]
|
||||
result))
|
||||
|
||||
(defn- asset-download-error
|
||||
[code message action extra]
|
||||
{:status :error
|
||||
:error (merge {:code code
|
||||
:message message
|
||||
:repo (:repo action)
|
||||
:graph (:graph action)}
|
||||
extra)})
|
||||
|
||||
(defn- sync-asset-lookup-ref
|
||||
[{:keys [id] :as action}]
|
||||
(let [asset-uuid (:uuid action)]
|
||||
(if (some? id)
|
||||
id
|
||||
[:block/uuid (uuid asset-uuid)])))
|
||||
|
||||
(defn- resolve-sync-asset
|
||||
[cfg action]
|
||||
(transport/invoke cfg :thread-api/pull
|
||||
[(:repo action)
|
||||
sync-asset-pull-selector
|
||||
(sync-asset-lookup-ref action)]))
|
||||
|
||||
(defn- asset-tag?
|
||||
[tag]
|
||||
(cond
|
||||
(= asset-tag-ident tag) true
|
||||
(map? tag) (= asset-tag-ident (:db/ident tag))
|
||||
:else false))
|
||||
|
||||
(defn- validate-sync-asset
|
||||
[action asset]
|
||||
(let [asset-uuid (:block/uuid asset)
|
||||
asset-type (:logseq.property.asset/type asset)
|
||||
checksum (:logseq.property.asset/checksum asset)]
|
||||
(cond
|
||||
(nil? asset)
|
||||
(asset-download-error :asset-not-found "asset not found" action nil)
|
||||
|
||||
(not-any? asset-tag? (:block/tags asset))
|
||||
(asset-download-error :not-asset "selected entity is not an asset" action {:asset-id (:db/id asset)})
|
||||
|
||||
(not (seq (some-> asset-uuid str string/trim)))
|
||||
(asset-download-error :asset-uuid-missing "asset uuid is missing" action {:asset-id (:db/id asset)})
|
||||
|
||||
(not (seq (some-> asset-type str string/trim)))
|
||||
(asset-download-error :asset-type-missing "asset type is missing" action {:asset-id (:db/id asset)
|
||||
:asset-uuid asset-uuid})
|
||||
|
||||
(not (seq (some-> checksum str string/trim)))
|
||||
(asset-download-error :asset-checksum-missing "asset checksum is missing" action {:asset-id (:db/id asset)
|
||||
:asset-uuid asset-uuid})
|
||||
|
||||
(nil? (:logseq.property.asset/remote-metadata asset))
|
||||
(asset-download-error :asset-not-remote "asset remote metadata is missing" action {:asset-id (:db/id asset)
|
||||
:asset-uuid asset-uuid})
|
||||
|
||||
(seq (some-> (:logseq.property.asset/external-url asset) str string/trim))
|
||||
(asset-download-error :external-asset "external URL assets cannot be downloaded through sync" action {:asset-id (:db/id asset)
|
||||
:asset-uuid asset-uuid})
|
||||
|
||||
:else
|
||||
{:status :ok
|
||||
:asset asset})))
|
||||
|
||||
(defn- sync-active?
|
||||
[status]
|
||||
(and (= :open (:ws-state status))
|
||||
(seq (some-> (:graph-id status) str string/trim))))
|
||||
|
||||
(defn- sync-not-started-error
|
||||
[action status]
|
||||
(asset-download-error :sync-not-started
|
||||
"sync is not started for this graph"
|
||||
action
|
||||
{:status status
|
||||
:hint (str "Run logseq sync start --graph " (:graph action) " first.")}))
|
||||
|
||||
(defn- asset-file-exists?
|
||||
[path]
|
||||
(and (seq path)
|
||||
(fs/existsSync path)))
|
||||
|
||||
(defn- asset-file-checksum
|
||||
[path]
|
||||
(-> (.createHash crypto "sha256")
|
||||
(.update (fs/readFileSync path))
|
||||
(.digest "hex")))
|
||||
|
||||
(defn- graph-asset-file-path
|
||||
[config repo asset-uuid asset-type]
|
||||
(if-let [graph-dir-name (graph-dir/repo->encoded-graph-dir-name repo)]
|
||||
(node-path/join (cli-server/graphs-dir config)
|
||||
graph-dir-name
|
||||
"assets"
|
||||
(str asset-uuid "." asset-type))
|
||||
(throw (ex-info "invalid repo"
|
||||
{:code :invalid-repo
|
||||
:repo repo}))))
|
||||
|
||||
(defn- asset-download-result-data
|
||||
[asset download-requested? checksum-status extra]
|
||||
(cond-> {:asset-uuid (str (:block/uuid asset))
|
||||
:asset-type (:logseq.property.asset/type asset)
|
||||
:download-requested? download-requested?
|
||||
:checksum-status checksum-status}
|
||||
(some? (:db/id asset)) (assoc :asset-id (:db/id asset))
|
||||
(seq extra) (merge extra)))
|
||||
|
||||
(defn- local-asset-checksum-status
|
||||
[config action asset]
|
||||
(let [asset-path (graph-asset-file-path config
|
||||
(:repo action)
|
||||
(:block/uuid asset)
|
||||
(:logseq.property.asset/type asset))]
|
||||
(if-not (asset-file-exists? asset-path)
|
||||
{:checksum-status :missing}
|
||||
(let [local-checksum (asset-file-checksum asset-path)]
|
||||
(if (= local-checksum (:logseq.property.asset/checksum asset))
|
||||
{:checksum-status :match}
|
||||
{:checksum-status :mismatch
|
||||
:local-path asset-path
|
||||
:local-checksum local-checksum})))))
|
||||
|
||||
(defn- remove-local-asset-file!
|
||||
[path]
|
||||
(when (asset-file-exists? path)
|
||||
(fs/rmSync path #js {:force true})))
|
||||
|
||||
(defn- request-asset-download-result
|
||||
[cfg action asset checksum-status extra]
|
||||
(p/let [_ (transport/invoke cfg :thread-api/db-sync-request-asset-download
|
||||
[(:repo action) (:block/uuid asset)])]
|
||||
{:status :ok
|
||||
:data (asset-download-result-data asset true checksum-status extra)}))
|
||||
|
||||
(defn- execute-sync-asset-download*
|
||||
[cfg config action asset]
|
||||
(let [validation (validate-sync-asset action asset)]
|
||||
(if (= :error (:status validation))
|
||||
(p/resolved validation)
|
||||
(p/let [status (transport/invoke cfg :thread-api/db-sync-status [(:repo action)])]
|
||||
(if-not (sync-active? status)
|
||||
(sync-not-started-error action status)
|
||||
(let [{:keys [checksum-status local-path]} (local-asset-checksum-status config action asset)]
|
||||
(case checksum-status
|
||||
:match
|
||||
{:status :ok
|
||||
:data (asset-download-result-data asset false :match {:skipped-reason :already-downloaded})}
|
||||
|
||||
:mismatch
|
||||
(do
|
||||
(remove-local-asset-file! local-path)
|
||||
(request-asset-download-result cfg
|
||||
action
|
||||
asset
|
||||
:mismatch
|
||||
{:hint "Local asset checksum mismatched; requested re-download."}))
|
||||
|
||||
(request-asset-download-result cfg action asset :missing nil))))))))
|
||||
|
||||
(defn- invoke-global
|
||||
[config method args]
|
||||
(let [base-url (:base-url config)]
|
||||
@@ -759,6 +973,21 @@
|
||||
(exception->error error {:repo (:repo action)
|
||||
:graph (:graph action)})))))
|
||||
|
||||
(defn- run-sync-asset-download
|
||||
[action config]
|
||||
(-> (p/let [config' (resolve-runtime-config! action config)
|
||||
missing-keys (missing-required-sync-config-keys (:type action) config')]
|
||||
(if (seq missing-keys)
|
||||
(missing-sync-config-error (:type action) missing-keys)
|
||||
(let [config* (assoc config' :http-base (effective-sync-config-value config' :http-base))]
|
||||
(p/let [cfg (cli-server/ensure-server! config* (:repo action))
|
||||
_ (<sync-worker-runtime! cfg config*)
|
||||
asset (resolve-sync-asset cfg action)]
|
||||
(execute-sync-asset-download* cfg config* action asset)))))
|
||||
(p/catch (fn [error]
|
||||
(exception->error error {:repo (:repo action)
|
||||
:graph (:graph action)})))))
|
||||
|
||||
(defn- run-sync-remote-graphs
|
||||
[action config]
|
||||
(-> (p/let [config' (resolve-runtime-config! action config)
|
||||
@@ -834,6 +1063,7 @@
|
||||
:sync-stop (run-sync-stop action config)
|
||||
:sync-upload (run-sync-upload action config)
|
||||
:sync-download (run-sync-download action config)
|
||||
:sync-asset-download (run-sync-asset-download action config)
|
||||
:sync-remote-graphs (run-sync-remote-graphs action config)
|
||||
:sync-ensure-keys (run-sync-ensure-keys action config)
|
||||
:sync-grant-access (run-sync-grant-access action config)
|
||||
|
||||
@@ -357,10 +357,15 @@
|
||||
(not (seq (:graph opts))))
|
||||
(missing-graph-result summary)
|
||||
|
||||
(and (= command :sync-download)
|
||||
(and (= :sync-download command)
|
||||
(not (seq (:graph opts))))
|
||||
(missing-graph-result summary)
|
||||
|
||||
(and (= command :sync-asset-download)
|
||||
(not= 1 (count (filter true? [(some? (:id opts))
|
||||
(boolean (seq (some-> (:uuid opts) string/trim)))]))))
|
||||
(command-core/invalid-options-result summary "exactly one of --id or --uuid is required")
|
||||
|
||||
(and (= command :completion)
|
||||
completion-shell-error)
|
||||
(command-core/invalid-options-result summary completion-shell-error)
|
||||
@@ -650,7 +655,7 @@
|
||||
(doctor-command/build-action options)
|
||||
|
||||
(:sync-status :sync-start :sync-stop :sync-upload :sync-download
|
||||
:sync-remote-graphs :sync-ensure-keys :sync-grant-access
|
||||
:sync-asset-download :sync-remote-graphs :sync-ensure-keys :sync-grant-access
|
||||
:sync-config-set :sync-config-get :sync-config-unset)
|
||||
(sync-command/build-action command options args repo)
|
||||
|
||||
@@ -740,7 +745,7 @@
|
||||
:server-stop (server-command/execute-stop action config)
|
||||
:server-restart (server-command/execute-restart action config)
|
||||
(:sync-status :sync-start :sync-stop :sync-upload :sync-download
|
||||
:sync-remote-graphs :sync-ensure-keys :sync-grant-access
|
||||
:sync-asset-download :sync-remote-graphs :sync-ensure-keys :sync-grant-access
|
||||
:sync-config-set :sync-config-get :sync-config-unset)
|
||||
(sync-command/execute action config)
|
||||
(:login :logout)
|
||||
|
||||
@@ -797,6 +797,19 @@
|
||||
:sync-grant-access (str "Sync access granted: " email " (repo: " repo ")")
|
||||
"Sync updated"))
|
||||
|
||||
(defn- format-sync-asset-download
|
||||
[{:keys [repo]} {:keys [asset-uuid download-requested? checksum-status hint]}]
|
||||
(cond
|
||||
(= :mismatch checksum-status)
|
||||
(str (or hint "Local asset checksum mismatched; requested re-download.")
|
||||
" " asset-uuid)
|
||||
|
||||
(false? download-requested?)
|
||||
(str "Sync asset already downloaded: " asset-uuid " (repo: " repo ")")
|
||||
|
||||
:else
|
||||
(str "Sync asset download requested: " asset-uuid " (repo: " repo ")")))
|
||||
|
||||
(defn- format-sync-config-get
|
||||
[{:keys [key value]}]
|
||||
(let [display-value (if (contains? #{:auth-token :e2ee-password} key)
|
||||
@@ -1001,6 +1014,7 @@
|
||||
:sync-remote-graphs (format-sync-remote-graphs (:graphs data))
|
||||
(:sync-start :sync-stop :sync-upload :sync-download :sync-ensure-keys :sync-grant-access)
|
||||
(format-sync-action command context)
|
||||
:sync-asset-download (format-sync-asset-download context data)
|
||||
:sync-config-get (format-sync-config-get data)
|
||||
:sync-config-set (format-sync-config-set data)
|
||||
:sync-config-unset (format-sync-config-unset data)
|
||||
|
||||
@@ -1,13 +1,137 @@
|
||||
(ns frontend.worker.sync.assets-test
|
||||
(:require [cljs.test :refer [async deftest is]]
|
||||
[datascript.core :as d]
|
||||
[frontend.common.crypt :as crypt]
|
||||
[frontend.worker.platform :as platform]
|
||||
[frontend.worker.shared-service :as shared-service]
|
||||
[frontend.worker.state :as worker-state]
|
||||
[frontend.worker.sync.assets :as sync-assets]
|
||||
[logseq.db :as ldb]
|
||||
[logseq.db.frontend.schema :as db-schema]
|
||||
[promesa.core :as p]))
|
||||
|
||||
(defn- asset-conn
|
||||
[asset-uuid]
|
||||
(let [conn (d/create-conn db-schema/schema)]
|
||||
(ldb/transact! conn [{:block/uuid asset-uuid
|
||||
:logseq.property.asset/type "png"
|
||||
:logseq.property.asset/checksum "sha-256-value"
|
||||
:logseq.property.asset/remote-metadata {:checksum "sha-256-value"
|
||||
:type "png"}}])
|
||||
conn))
|
||||
|
||||
(defn- execute-enqueued-asset-task!
|
||||
[task]
|
||||
(if (fn? task)
|
||||
(task)
|
||||
(p/resolved nil)))
|
||||
|
||||
(deftest request-asset-download-skips-existing-local-asset-test
|
||||
(async done
|
||||
(let [repo "asset-download-repo"
|
||||
graph-id "graph-1"
|
||||
asset-uuid (random-uuid)
|
||||
conn (asset-conn asset-uuid)
|
||||
download-calls (atom [])
|
||||
asset-stat-calls (atom [])
|
||||
enqueued-task (atom nil)
|
||||
broadcast-calls (atom [])]
|
||||
(-> (p/with-redefs [worker-state/get-datascript-conn (fn [_repo]
|
||||
conn)
|
||||
platform/current (fn [] {})
|
||||
platform/asset-stat (fn [_platform repo' file-name]
|
||||
(swap! asset-stat-calls conj [repo' file-name])
|
||||
(p/resolved {:size 10}))
|
||||
sync-assets/download-remote-asset! (fn [& args]
|
||||
(swap! download-calls conj args)
|
||||
(p/resolved nil))]
|
||||
(sync-assets/request-asset-download!
|
||||
repo
|
||||
asset-uuid
|
||||
{:current-client-f (fn [_repo]
|
||||
{:graph-id graph-id})
|
||||
:enqueue-asset-task-f (fn [_client task]
|
||||
(reset! enqueued-task task)
|
||||
(execute-enqueued-asset-task! task))
|
||||
:broadcast-rtc-state!-f (fn [& args]
|
||||
(swap! broadcast-calls conj args))}))
|
||||
(p/then (fn [_]
|
||||
(is (= [[repo (str asset-uuid ".png")]] @asset-stat-calls))
|
||||
(is (fn? @enqueued-task))
|
||||
(is (= [] @download-calls))
|
||||
(is (= [] @broadcast-calls))))
|
||||
(p/catch (fn [error]
|
||||
(is false (str "unexpected error: " error))))
|
||||
(p/finally done)))))
|
||||
|
||||
(deftest request-asset-download-downloads-missing-local-asset-test
|
||||
(async done
|
||||
(let [repo "asset-download-repo"
|
||||
graph-id "graph-1"
|
||||
asset-uuid (random-uuid)
|
||||
conn (asset-conn asset-uuid)
|
||||
download-calls (atom [])
|
||||
asset-stat-calls (atom [])
|
||||
broadcast-calls (atom [])]
|
||||
(-> (p/with-redefs [worker-state/get-datascript-conn (fn [_repo]
|
||||
conn)
|
||||
platform/current (fn [] {})
|
||||
platform/asset-stat (fn [_platform repo' file-name]
|
||||
(swap! asset-stat-calls conj [repo' file-name])
|
||||
(p/resolved nil))
|
||||
sync-assets/download-remote-asset! (fn [& args]
|
||||
(swap! download-calls conj args)
|
||||
(p/resolved nil))]
|
||||
(sync-assets/request-asset-download!
|
||||
repo
|
||||
asset-uuid
|
||||
{:current-client-f (fn [_repo]
|
||||
{:graph-id graph-id})
|
||||
:enqueue-asset-task-f (fn [_client task]
|
||||
(execute-enqueued-asset-task! task))
|
||||
:broadcast-rtc-state!-f (fn [& args]
|
||||
(swap! broadcast-calls conj args))}))
|
||||
(p/then (fn [_]
|
||||
(is (= [[repo (str asset-uuid ".png")]] @asset-stat-calls))
|
||||
(is (= [[repo graph-id asset-uuid "png"]] @download-calls))
|
||||
(is (= 1 (count @broadcast-calls)))))
|
||||
(p/catch (fn [error]
|
||||
(is false (str "unexpected error: " error))))
|
||||
(p/finally done)))))
|
||||
|
||||
(deftest request-asset-download-propagates-and-logs-download-failure-test
|
||||
(async done
|
||||
(let [repo "asset-download-repo"
|
||||
graph-id "graph-1"
|
||||
asset-uuid (random-uuid)
|
||||
conn (asset-conn asset-uuid)
|
||||
download-error (ex-info "download failed" {:type :rtc.exception/download-asset-failed})
|
||||
log-calls (atom [])]
|
||||
(-> (p/with-redefs [worker-state/get-datascript-conn (fn [_repo]
|
||||
conn)
|
||||
platform/current (fn [] {})
|
||||
platform/asset-stat (fn [_platform _repo _file-name]
|
||||
(p/resolved nil))
|
||||
sync-assets/download-remote-asset! (fn [& _args]
|
||||
(p/rejected download-error))
|
||||
sync-assets/log-request-asset-download-failed!
|
||||
(fn [repo' asset-uuid' error']
|
||||
(swap! log-calls conj [repo' asset-uuid' error']))]
|
||||
(sync-assets/request-asset-download!
|
||||
repo
|
||||
asset-uuid
|
||||
{:current-client-f (fn [_repo]
|
||||
{:graph-id graph-id})
|
||||
:enqueue-asset-task-f (fn [_client task]
|
||||
(execute-enqueued-asset-task! task))
|
||||
:broadcast-rtc-state!-f (fn [& _args] nil)}))
|
||||
(p/then (fn [_]
|
||||
(is false "expected download failure to reject")))
|
||||
(p/catch (fn [error]
|
||||
(is (= download-error error))
|
||||
(is (= [[repo asset-uuid download-error]] @log-calls))))
|
||||
(p/finally done)))))
|
||||
|
||||
(deftest upload-remote-asset-serializes-resolved-encrypted-payload-test
|
||||
(async done
|
||||
(let [repo "asset-upload-repo"
|
||||
|
||||
@@ -1,17 +1,122 @@
|
||||
(ns logseq.cli.command.sync-test
|
||||
(:require [cljs.test :refer [async deftest is testing]]
|
||||
(:require ["crypto" :as crypto]
|
||||
["fs" :as fs]
|
||||
["os" :as os]
|
||||
["path" :as node-path]
|
||||
[cljs.test :refer [async deftest is testing]]
|
||||
[logseq.cli.auth :as cli-auth]
|
||||
[logseq.cli.command.sync :as sync-command]
|
||||
[logseq.cli.common :as cli-common]
|
||||
[logseq.cli.config :as cli-config]
|
||||
[logseq.cli.server :as cli-server]
|
||||
[logseq.cli.transport :as transport]
|
||||
[logseq.common.graph-dir :as graph-dir]
|
||||
[promesa.core :as p]))
|
||||
|
||||
(defn- execute-with-runtime-auth
|
||||
[action config]
|
||||
(sync-command/execute action (assoc config :id-token "runtime-token")))
|
||||
|
||||
(def ^:private sync-asset-repo "logseq_db_demo")
|
||||
(def ^:private sync-asset-uuid "11111111-1111-1111-1111-111111111111")
|
||||
(def ^:private sync-asset-type "txt")
|
||||
|
||||
(defn- temp-root-dir
|
||||
[]
|
||||
(fs/mkdtempSync (node-path/join (os/tmpdir) "logseq-sync-asset-test-")))
|
||||
|
||||
(defn- remove-dir!
|
||||
[path]
|
||||
(when (and (seq path) (fs/existsSync path))
|
||||
(fs/rmSync path #js {:recursive true :force true})))
|
||||
|
||||
(defn- sha256
|
||||
[payload]
|
||||
(-> (.createHash crypto "sha256")
|
||||
(.update payload)
|
||||
(.digest "hex")))
|
||||
|
||||
(defn- graph-assets-dir
|
||||
[root-dir repo]
|
||||
(node-path/join (cli-server/graphs-dir {:root-dir root-dir})
|
||||
(graph-dir/repo->encoded-graph-dir-name repo)
|
||||
"assets"))
|
||||
|
||||
(defn- write-local-asset!
|
||||
[root-dir repo asset-uuid asset-type payload]
|
||||
(let [assets-dir (graph-assets-dir root-dir repo)
|
||||
asset-path (node-path/join assets-dir (str asset-uuid "." asset-type))]
|
||||
(fs/mkdirSync assets-dir #js {:recursive true})
|
||||
(fs/writeFileSync asset-path payload)
|
||||
asset-path))
|
||||
|
||||
(defn- remote-asset
|
||||
[checksum]
|
||||
{:db/id 123
|
||||
:block/uuid sync-asset-uuid
|
||||
:block/tags [{:db/ident :logseq.class/Asset}]
|
||||
:logseq.property.asset/type sync-asset-type
|
||||
:logseq.property.asset/checksum checksum
|
||||
:logseq.property.asset/remote-metadata {:checksum checksum
|
||||
:type sync-asset-type}})
|
||||
|
||||
(defn- active-sync-status
|
||||
[]
|
||||
{:repo sync-asset-repo
|
||||
:graph-id "graph-id"
|
||||
:ws-state :open
|
||||
:pending-local 0
|
||||
:pending-asset 0
|
||||
:pending-server 0})
|
||||
|
||||
(defn- sync-asset-download-action
|
||||
[]
|
||||
{:type :sync-asset-download
|
||||
:repo sync-asset-repo
|
||||
:graph "demo"
|
||||
:id 123})
|
||||
|
||||
(defn- run-sync-asset-download-scenario
|
||||
[{:keys [asset status local-payload action config]
|
||||
:or {status (active-sync-status)
|
||||
action (sync-asset-download-action)}}]
|
||||
(let [root-dir (temp-root-dir)
|
||||
calls (atom [])
|
||||
config' (merge {:root-dir root-dir
|
||||
:http-base "https://api.logseq.io"}
|
||||
config)]
|
||||
(when (some? local-payload)
|
||||
(write-local-asset! root-dir
|
||||
(:repo action)
|
||||
(or (:block/uuid asset) sync-asset-uuid)
|
||||
(or (:logseq.property.asset/type asset) sync-asset-type)
|
||||
local-payload))
|
||||
(-> (p/with-redefs [cli-server/ensure-server! (fn [config repo]
|
||||
(swap! calls conj [:ensure-server repo])
|
||||
(p/resolved (assoc config :base-url "http://example")))
|
||||
transport/invoke (fn [_ method args]
|
||||
(swap! calls conj [method args])
|
||||
(case method
|
||||
:thread-api/pull
|
||||
(p/resolved asset)
|
||||
|
||||
:thread-api/db-sync-status
|
||||
(p/resolved status)
|
||||
|
||||
:thread-api/db-sync-request-asset-download
|
||||
(p/resolved nil)
|
||||
|
||||
(p/resolved nil)))]
|
||||
(p/let [result (execute-with-runtime-auth action config')]
|
||||
{:result result
|
||||
:calls @calls}))
|
||||
(p/finally (fn []
|
||||
(remove-dir! root-dir))))))
|
||||
|
||||
(defn- called-method?
|
||||
[calls method]
|
||||
(boolean (some #(= method (first %)) calls)))
|
||||
|
||||
(deftest test-build-action-validation
|
||||
(testing "sync status requires repo"
|
||||
(let [result (sync-command/build-action :sync-status {} [] nil)]
|
||||
@@ -92,6 +197,190 @@
|
||||
(is (false? (:ok? missing-email)))
|
||||
(is (= :invalid-options (get-in missing-email [:error :code]))))))
|
||||
|
||||
(deftest test-build-sync-asset-download-action-validation
|
||||
(testing "sync asset download builds action with db id selector"
|
||||
(let [result (sync-command/build-action :sync-asset-download {:id 123} [] "logseq_db_demo")]
|
||||
(is (true? (:ok? result)))
|
||||
(is (= {:type :sync-asset-download
|
||||
:repo "logseq_db_demo"
|
||||
:graph "demo"
|
||||
:id 123}
|
||||
(:action result)))))
|
||||
|
||||
(testing "sync asset download builds action with uuid selector"
|
||||
(let [result (sync-command/build-action :sync-asset-download {:uuid sync-asset-uuid} [] "logseq_db_demo")]
|
||||
(is (true? (:ok? result)))
|
||||
(is (= {:type :sync-asset-download
|
||||
:repo "logseq_db_demo"
|
||||
:graph "demo"
|
||||
:uuid sync-asset-uuid}
|
||||
(:action result)))))
|
||||
|
||||
(testing "sync asset download requires repo"
|
||||
(let [result (sync-command/build-action :sync-asset-download {:id 123} [] nil)]
|
||||
(is (false? (:ok? result)))
|
||||
(is (= :missing-repo (get-in result [:error :code])))))
|
||||
|
||||
(testing "sync asset download requires one selector"
|
||||
(let [result (sync-command/build-action :sync-asset-download {} [] "logseq_db_demo")]
|
||||
(is (false? (:ok? result)))
|
||||
(is (= :invalid-options (get-in result [:error :code])))))
|
||||
|
||||
(testing "sync asset download rejects conflicting selectors"
|
||||
(let [result (sync-command/build-action :sync-asset-download {:id 123
|
||||
:uuid sync-asset-uuid}
|
||||
[]
|
||||
"logseq_db_demo")]
|
||||
(is (false? (:ok? result)))
|
||||
(is (= :invalid-options (get-in result [:error :code]))))))
|
||||
|
||||
(deftest test-execute-sync-asset-download-uses-uuid-lookup-value
|
||||
(async done
|
||||
(let [checksum (sha256 "remote asset payload")
|
||||
action {:type :sync-asset-download
|
||||
:repo sync-asset-repo
|
||||
:graph "demo"
|
||||
:uuid sync-asset-uuid}]
|
||||
(-> (run-sync-asset-download-scenario {:asset (remote-asset checksum)
|
||||
:action action})
|
||||
(p/then (fn [{:keys [calls]}]
|
||||
(is (some #(and (= :thread-api/pull (first %))
|
||||
(= [:block/uuid (uuid sync-asset-uuid)]
|
||||
(get-in % [1 2])))
|
||||
calls))
|
||||
(is (some #(= [:thread-api/db-sync-request-asset-download
|
||||
[sync-asset-repo sync-asset-uuid]]
|
||||
%)
|
||||
calls))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally done)))))
|
||||
|
||||
(deftest test-execute-sync-asset-download-requests-missing-local-file
|
||||
(async done
|
||||
(let [checksum (sha256 "remote asset payload")]
|
||||
(-> (run-sync-asset-download-scenario {:asset (remote-asset checksum)})
|
||||
(p/then (fn [{:keys [result calls]}]
|
||||
(is (= :ok (:status result)))
|
||||
(is (= {:asset-id 123
|
||||
:asset-uuid sync-asset-uuid
|
||||
:asset-type sync-asset-type
|
||||
:download-requested? true
|
||||
:checksum-status :missing}
|
||||
(:data result)))
|
||||
(is (called-method? calls :thread-api/sync-app-state))
|
||||
(is (called-method? calls :thread-api/set-db-sync-config))
|
||||
(is (called-method? calls :thread-api/pull))
|
||||
(is (called-method? calls :thread-api/db-sync-status))
|
||||
(is (some #(= [:thread-api/db-sync-request-asset-download
|
||||
[sync-asset-repo sync-asset-uuid]]
|
||||
%)
|
||||
calls))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally done)))))
|
||||
|
||||
(deftest test-execute-sync-asset-download-skips-matching-local-file
|
||||
(async done
|
||||
(let [payload "local asset payload"
|
||||
checksum (sha256 payload)]
|
||||
(-> (run-sync-asset-download-scenario {:asset (remote-asset checksum)
|
||||
:local-payload payload})
|
||||
(p/then (fn [{:keys [result calls]}]
|
||||
(is (= :ok (:status result)))
|
||||
(is (= {:asset-id 123
|
||||
:asset-uuid sync-asset-uuid
|
||||
:asset-type sync-asset-type
|
||||
:download-requested? false
|
||||
:checksum-status :match
|
||||
:skipped-reason :already-downloaded}
|
||||
(:data result)))
|
||||
(is (not (contains? (:data result) :local-path)))
|
||||
(is (not (called-method? calls :thread-api/db-sync-request-asset-download)))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally done)))))
|
||||
|
||||
(deftest test-execute-sync-asset-download-requests-mismatched-local-file
|
||||
(async done
|
||||
(let [checksum (sha256 "remote asset payload")]
|
||||
(-> (run-sync-asset-download-scenario {:asset (remote-asset checksum)
|
||||
:local-payload "corrupted local payload"})
|
||||
(p/then (fn [{:keys [result calls]}]
|
||||
(is (= :ok (:status result)))
|
||||
(is (= 123 (get-in result [:data :asset-id])))
|
||||
(is (= sync-asset-uuid (get-in result [:data :asset-uuid])))
|
||||
(is (= sync-asset-type (get-in result [:data :asset-type])))
|
||||
(is (= true (get-in result [:data :download-requested?])))
|
||||
(is (= :mismatch (get-in result [:data :checksum-status])))
|
||||
(let [hint (get-in result [:data :hint])]
|
||||
(is (string? hint))
|
||||
(is (boolean (when (string? hint)
|
||||
(re-find #"checksum" hint)))))
|
||||
(is (not (contains? (:data result) :local-path)))
|
||||
(is (some #(= [:thread-api/db-sync-request-asset-download
|
||||
[sync-asset-repo sync-asset-uuid]]
|
||||
%)
|
||||
calls))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally done)))))
|
||||
|
||||
(deftest test-execute-sync-asset-download-requires-active-sync
|
||||
(async done
|
||||
(let [checksum (sha256 "remote asset payload")]
|
||||
(-> (run-sync-asset-download-scenario {:asset (remote-asset checksum)
|
||||
:status {:repo sync-asset-repo
|
||||
:graph-id "graph-id"
|
||||
:ws-state :stopped}})
|
||||
(p/then (fn [{:keys [result calls]}]
|
||||
(is (= :error (:status result)))
|
||||
(is (= :sync-not-started (get-in result [:error :code])))
|
||||
(is (= "Run logseq sync start --graph demo first."
|
||||
(get-in result [:error :hint])))
|
||||
(is (not (called-method? calls :thread-api/db-sync-request-asset-download)))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally done)))))
|
||||
|
||||
(deftest test-execute-sync-asset-download-validates-asset-metadata
|
||||
(async done
|
||||
(let [checksum (sha256 "remote asset payload")
|
||||
base-asset (remote-asset checksum)
|
||||
cases [{:label "missing asset"
|
||||
:asset nil
|
||||
:code :asset-not-found}
|
||||
{:label "non asset"
|
||||
:asset (assoc base-asset :block/tags [])
|
||||
:code :not-asset}
|
||||
{:label "missing uuid"
|
||||
:asset (dissoc base-asset :block/uuid)
|
||||
:code :asset-uuid-missing}
|
||||
{:label "missing type"
|
||||
:asset (dissoc base-asset :logseq.property.asset/type)
|
||||
:code :asset-type-missing}
|
||||
{:label "missing checksum"
|
||||
:asset (dissoc base-asset :logseq.property.asset/checksum)
|
||||
:code :asset-checksum-missing}
|
||||
{:label "missing remote metadata"
|
||||
:asset (dissoc base-asset :logseq.property.asset/remote-metadata)
|
||||
:code :asset-not-remote}
|
||||
{:label "external asset"
|
||||
:asset (assoc base-asset :logseq.property.asset/external-url "https://example.com/a.txt")
|
||||
:code :external-asset}]]
|
||||
(-> (reduce (fn [chain {:keys [label asset code]}]
|
||||
(p/then chain
|
||||
(fn []
|
||||
(p/let [{:keys [result calls]} (run-sync-asset-download-scenario {:asset asset})]
|
||||
(is (= :error (:status result)) label)
|
||||
(is (= code (get-in result [:error :code])) label)
|
||||
(is (not (called-method? calls :thread-api/db-sync-request-asset-download)) label)))))
|
||||
(p/resolved nil)
|
||||
cases)
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally done)))))
|
||||
|
||||
(deftest test-execute-sync-start
|
||||
(async done
|
||||
(let [ensure-calls (atom [])
|
||||
|
||||
@@ -2304,6 +2304,48 @@
|
||||
(is (true? (:ok? enabled)))
|
||||
(is (= true (get-in enabled [:options :progress])))))
|
||||
|
||||
(testing "sync asset download parses db id selector"
|
||||
(let [result (commands/parse-args ["sync" "asset" "download" "--graph" "demo" "--id" "123"])]
|
||||
(is (true? (:ok? result)))
|
||||
(is (= :sync-asset-download (:command result)))
|
||||
(is (= "demo" (get-in result [:options :graph])))
|
||||
(is (= 123 (get-in result [:options :id])))))
|
||||
|
||||
(testing "sync asset download parses uuid selector"
|
||||
(let [asset-uuid "11111111-1111-1111-1111-111111111111"
|
||||
result (commands/parse-args ["sync" "asset" "download" "--graph" "demo" "--uuid" asset-uuid])]
|
||||
(is (true? (:ok? result)))
|
||||
(is (= :sync-asset-download (:command result)))
|
||||
(is (= "demo" (get-in result [:options :graph])))
|
||||
(is (= asset-uuid (get-in result [:options :uuid])))))
|
||||
|
||||
(testing "sync asset download rejects invalid uuid selector"
|
||||
(let [result (commands/parse-args ["sync" "asset" "download" "--graph" "demo" "--uuid" "asset-uuid"])]
|
||||
(is (false? (:ok? result)))
|
||||
(is (= :invalid-options (get-in result [:error :code])))))
|
||||
|
||||
(testing "sync asset download can use current graph"
|
||||
(let [parsed (commands/parse-args ["sync" "asset" "download" "--id" "123"])
|
||||
result (when (:ok? parsed)
|
||||
(commands/build-action parsed {:graph "demo"}))]
|
||||
(is (true? (:ok? parsed)))
|
||||
(is (true? (:ok? result)))
|
||||
(is (= {:type :sync-asset-download
|
||||
:repo "logseq_db_demo"
|
||||
:graph "demo"
|
||||
:id 123}
|
||||
(:action result)))))
|
||||
|
||||
(testing "sync asset download requires one selector"
|
||||
(let [missing-selector (commands/parse-args ["sync" "asset" "download" "--graph" "demo"])
|
||||
conflicting-selectors (commands/parse-args ["sync" "asset" "download" "--graph" "demo"
|
||||
"--id" "123"
|
||||
"--uuid" "11111111-1111-1111-1111-111111111111"])]
|
||||
(is (false? (:ok? missing-selector)))
|
||||
(is (= :invalid-options (get-in missing-selector [:error :code])))
|
||||
(is (false? (:ok? conflicting-selectors)))
|
||||
(is (= :invalid-options (get-in conflicting-selectors [:error :code])))))
|
||||
|
||||
(testing "sync ensure-keys accepts e2ee-password option"
|
||||
(let [result (commands/parse-args ["sync" "ensure-keys" "--e2ee-password" "pw"])]
|
||||
(is (true? (:ok? result)))
|
||||
|
||||
@@ -950,7 +950,72 @@
|
||||
:context {:repo "demo-graph"}}
|
||||
{:output-format nil})]
|
||||
(is (string/includes? result "Sync download"))
|
||||
(is (string/includes? result "demo-graph")))))
|
||||
(is (string/includes? result "demo-graph"))))
|
||||
|
||||
(testing "sync asset download renders requested output"
|
||||
(let [result (format/format-result {:status :ok
|
||||
:command :sync-asset-download
|
||||
:context {:repo "demo-graph"}
|
||||
:data {:asset-id 123
|
||||
:asset-uuid "asset-uuid"
|
||||
:asset-type "png"
|
||||
:download-requested? true
|
||||
:checksum-status :missing}}
|
||||
{:output-format nil})]
|
||||
(is (string/includes? result "Sync asset download requested"))
|
||||
(is (string/includes? result "asset-uuid"))
|
||||
(is (string/includes? result "demo-graph"))
|
||||
(is (not (string/includes? result "local-path")))))
|
||||
|
||||
(testing "sync asset download renders checksum mismatch hint"
|
||||
(let [result (format/format-result {:status :ok
|
||||
:command :sync-asset-download
|
||||
:context {:repo "demo-graph"}
|
||||
:data {:asset-id 123
|
||||
:asset-uuid "asset-uuid"
|
||||
:asset-type "png"
|
||||
:download-requested? true
|
||||
:checksum-status :mismatch
|
||||
:hint "Local asset checksum mismatched; requested re-download."}}
|
||||
{:output-format nil})]
|
||||
(is (string/includes? result "Local asset checksum mismatched"))
|
||||
(is (string/includes? result "asset-uuid"))
|
||||
(is (not (string/includes? result "local-path")))))
|
||||
|
||||
(testing "sync asset download renders skipped output"
|
||||
(let [result (format/format-result {:status :ok
|
||||
:command :sync-asset-download
|
||||
:context {:repo "demo-graph"}
|
||||
:data {:asset-id 123
|
||||
:asset-uuid "asset-uuid"
|
||||
:asset-type "png"
|
||||
:download-requested? false
|
||||
:checksum-status :match
|
||||
:skipped-reason :already-downloaded}}
|
||||
{:output-format nil})]
|
||||
(is (string/includes? result "Sync asset already downloaded"))
|
||||
(is (string/includes? result "asset-uuid"))
|
||||
(is (not (string/includes? result "local-path")))))
|
||||
|
||||
(testing "sync asset download structured output keeps raw data"
|
||||
(let [data {:asset-id 123
|
||||
:asset-uuid "asset-uuid"
|
||||
:asset-type "png"
|
||||
:download-requested? false
|
||||
:checksum-status :match
|
||||
:skipped-reason :already-downloaded}
|
||||
json-result (format/format-result {:status :ok
|
||||
:command :sync-asset-download
|
||||
:data data}
|
||||
{:output-format :json})
|
||||
edn-result (format/format-result {:status :ok
|
||||
:command :sync-asset-download
|
||||
:data data}
|
||||
{:output-format :edn})]
|
||||
(is (string/includes? json-result "download-requested?"))
|
||||
(is (string/includes? edn-result ":download-requested?"))
|
||||
(is (not (string/includes? json-result "local-path")))
|
||||
(is (not (string/includes? edn-result "local-path"))))))
|
||||
|
||||
(deftest test-human-output-sync-config-get-ws-url
|
||||
(testing "sync config get ws-url renders value in human output"
|
||||
|
||||
Reference in New Issue
Block a user