Make workspace/project file sync propagate deletions instead of resurrecting them

The old sync compared only the two live sides (project rows vs workspace
files), so a file present in the project but missing on disk was
indistinguishable from "never materialized here yet" - it always got
re-exported, which is why deleting a file inside a container made it come
back. The mirror direction had the same bug: a file deleted from the
project's file editor was silently re-imported from the container's stale
copy on the next tick.

Fixes it with a persisted per-file sync baseline (new project_file_sync_state
table: db_epoch/fs_epoch as they stood right after the previous sync), the
same role a rsync/Unison state file plays in any real bidirectional sync.
Deleting on either side now propagates to the other, unless the deleted
side's counterpart was edited after the last sync, in which case the edit
wins and the file is restored. A read-only project always exports (never
imports, including on tie) and always removes a workspace's stale local
copy, so it stays a faithful mirror. Sync of an unchanged file is now a true
no-op (zero writes) instead of rewriting it every ~60s tick forever.

sync_dir_bidirectional's return dict gains deleted_in_project/
deleted_in_workspace alongside exported/imported; both API call sites
already pass the whole dict through untouched. The instance sync toast now
summarizes all four counts instead of just imports.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TjdKTWgWpW2SMNW8SFqxz5
This commit is contained in:
2026-09-03 08:47:57 +02:00
co-authored by Claude Sonnet 5
parent be77672c3e
commit 70ceb3cf81
6 changed files with 399 additions and 38 deletions
+166 -1
View File
@@ -1,6 +1,7 @@
# retoor <retoor@molodetz.nl>
from datetime import timedelta
from datetime import datetime, timedelta, timezone
import os
import pytest
import requests
from tests.conftest import BASE_URL, run_async
@@ -64,6 +65,14 @@ def env(tmp_path, monkeypatch):
for row in list(get_table("project_files").find()):
if str(row.get("project_uid", "")).startswith("ctest"):
get_table("project_files").delete(uid=row["uid"])
if "project_file_sync_state" in db.tables:
for row in list(get_table("project_file_sync_state").find()):
if str(row.get("project_uid", "")).startswith("ctest"):
get_table("project_file_sync_state").delete(
project_uid=row["project_uid"], path=row["path"]
)
if "projects" in db.tables:
get_table("projects").delete(uid=pid)
def _ready_instance(env, **kwargs):
return run_async(
api.create_instance(env["project"], name=kwargs.pop("name", "inst"), **kwargs)
@@ -405,6 +414,162 @@ def test_bidirectional_sync_newer_wins(env, tmp_path):
assert imported["content"] == "from fs\n"
def test_bidirectional_sync_propagates_a_workspace_deletion(env, tmp_path):
workspace = tmp_path / "sync-del-ws"
workspace.mkdir()
pid = env["project"]["uid"]
user = env["user"]
project_files.write_text_file(pid, user, "gone.txt", "bye\n")
project_files.sync_dir_bidirectional(pid, str(workspace), user)
assert (workspace / "gone.txt").exists()
(workspace / "gone.txt").unlink()
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
assert counts["deleted_in_project"] == 1
assert counts["exported"] == 0
assert project_files.get_node(pid, "gone.txt") is None
assert not (workspace / "gone.txt").exists()
def test_bidirectional_sync_propagates_a_project_deletion(env, tmp_path):
workspace = tmp_path / "sync-pdel-ws"
workspace.mkdir()
pid = env["project"]["uid"]
user = env["user"]
project_files.write_text_file(pid, user, "removeme.txt", "bye\n")
project_files.sync_dir_bidirectional(pid, str(workspace), user)
assert (workspace / "removeme.txt").exists()
project_files.delete_node(pid, "removeme.txt", deleted_by=user["uid"])
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
assert counts["deleted_in_workspace"] == 1
assert counts["imported"] == 0
assert not (workspace / "removeme.txt").exists()
def test_bidirectional_sync_a_later_edit_restores_a_workspace_deletion(env, tmp_path):
workspace = tmp_path / "sync-edit-restore-ws"
workspace.mkdir()
pid = env["project"]["uid"]
user = env["user"]
node = project_files.write_text_file(pid, user, "edited.txt", "v1\n")
project_files.sync_dir_bidirectional(pid, str(workspace), user)
(workspace / "edited.txt").unlink()
future = datetime.now(timezone.utc) + timedelta(seconds=10)
get_table("project_files").update(
{"uid": node["uid"], "content": "v2\n", "updated_at": future.isoformat()},
["uid"],
)
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
assert counts["deleted_in_project"] == 0
assert counts["exported"] == 1
assert (workspace / "edited.txt").read_text() == "v2\n"
def test_bidirectional_sync_a_later_local_edit_reimports_over_a_project_deletion(
env, tmp_path
):
workspace = tmp_path / "sync-edit-reimport-ws"
workspace.mkdir()
pid = env["project"]["uid"]
user = env["user"]
project_files.write_text_file(pid, user, "revived.txt", "v1\n")
project_files.sync_dir_bidirectional(pid, str(workspace), user)
project_files.delete_node(pid, "revived.txt", deleted_by=user["uid"])
target = workspace / "revived.txt"
target.write_text("v2\n")
future = (datetime.now(timezone.utc) + timedelta(seconds=10)).timestamp()
os.utime(target, (future, future))
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
assert counts["deleted_in_workspace"] == 0
assert counts["imported"] == 1
revived = project_files.read_file(pid, "revived.txt")
assert revived["content"] == "v2\n"
def test_bidirectional_sync_readonly_always_restores_a_workspace_deletion(
env, tmp_path
):
workspace = tmp_path / "sync-ro-restore-ws"
workspace.mkdir()
pid = env["project"]["uid"]
user = env["user"]
project_files.write_text_file(pid, user, "frozen.txt", "kept\n")
project_files.sync_dir_bidirectional(pid, str(workspace), user)
get_table("projects").upsert(
{"uid": pid, "slug": "ctest", "read_only": 1}, ["uid"]
)
(workspace / "frozen.txt").unlink()
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
assert counts["deleted_in_project"] == 0
assert counts["exported"] == 1
assert (workspace / "frozen.txt").read_text() == "kept\n"
assert project_files.get_node(pid, "frozen.txt") is not None
def test_bidirectional_sync_readonly_removes_a_stale_local_copy(env, tmp_path):
workspace = tmp_path / "sync-ro-remove-ws"
workspace.mkdir()
pid = env["project"]["uid"]
user = env["user"]
project_files.write_text_file(pid, user, "stale.txt", "stale\n")
project_files.sync_dir_bidirectional(pid, str(workspace), user)
project_files.delete_node(pid, "stale.txt", deleted_by=user["uid"])
get_table("projects").upsert(
{"uid": pid, "slug": "ctest", "read_only": 1}, ["uid"]
)
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
assert counts["deleted_in_workspace"] == 1
assert counts["imported"] == 0
assert not (workspace / "stale.txt").exists()
def test_bidirectional_sync_is_a_noop_once_both_sides_settle(env, tmp_path):
workspace = tmp_path / "sync-noop-ws"
workspace.mkdir()
pid = env["project"]["uid"]
user = env["user"]
project_files.write_text_file(pid, user, "settled.txt", "steady\n")
project_files.sync_dir_bidirectional(pid, str(workspace), user)
before = (workspace / "settled.txt").stat().st_mtime
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
assert counts == {
"exported": 0,
"imported": 0,
"deleted_in_project": 0,
"deleted_in_workspace": 0,
}
assert (workspace / "settled.txt").stat().st_mtime == before
def test_bidirectional_sync_manifest_is_pruned_after_both_sides_agree(env, tmp_path):
workspace = tmp_path / "sync-prune-ws"
workspace.mkdir()
pid = env["project"]["uid"]
user = env["user"]
project_files.write_text_file(pid, user, "prune.txt", "x\n")
project_files.sync_dir_bidirectional(pid, str(workspace), user)
(workspace / "prune.txt").unlink()
project_files.sync_dir_bidirectional(pid, str(workspace), user)
remaining = list(
get_table("project_file_sync_state").find(project_uid=pid, path="prune.txt")
)
assert remaining == []
def _proxy_scope(kind: str, headers: dict, scheme: str, query: str = "") -> dict:
return {