attachments sync: attach offline, upload when linked, removals stick (#5168)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 35s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 2m23s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m17s

Desktop could not create an attachment at all, and a removed attachment or
dismissed preview came back on the next pull. Now:

- core: add_attachment keeps the bytes in the blob store and queues the row
  (schema v10: attachments.uploaded / upload_error). Push uploads it once its
  note has landed. A refusal that retrying won't fix (too large, id clash, hash
  mismatch) is recorded on the file and not re-sent every cycle; the editor
  shows it.
- core: removing a synced attachment or dismissing a preview leaves a tombstone
  in pending_deletes; push sends it as an `attachment`/`preview` delete, and a
  pull while it waits doesn't put the row back. A pull also keeps files still
  waiting to upload instead of replacing them wholesale.
- server: PUT /api/sync/attachments/<id> (raw body, sha256-checked, idempotent,
  size-capped) and child deletes in push, which apply regardless of LWW and
  answer noop for rows the caller can't see. One store_attachment helper for
  the upload route, the importer and sync. Protocol 5, feature attachment_sync;
  the client sends neither to a server without it.
- server: migration 0031 makes a link preview's insert/delete bump its note, so
  background-fetched previews and web dismissals reach linked devices.
- desktop: Attach and paste-image work offline (raw-bytes IPC command).
- SVG is served as a download by the desktop blob scheme too (as #1981 did for
  the web), and drawn as a file chip on both.
- autosync: drop the catch_unwind; release builds abort on panic, so it only
  ever worked in debug builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 10:07:52 -04:00
co-authored by Claude Opus 5.5
parent efb141e555
commit 2b2ceaa82e
30 changed files with 1391 additions and 151 deletions
+138 -1
View File
@@ -15,18 +15,21 @@ deployment has ever seen.
"""
from __future__ import annotations
import hashlib
import uuid
from datetime import datetime, timedelta, timezone
import pytest
import pytest_asyncio
from sqlalchemy import select, text
from sqlalchemy import func, select, text
from inkwell import ratelimit
from inkwell.app import create_app
from inkwell.config import Config
from inkwell.db import dispose_engine, session_scope
from inkwell.models.label import NoteLabel
from inkwell.models.note import Note
from inkwell.models.note_attachment import NoteAttachment
from inkwell.models.user import User
from inkwell.notes.tags import _lift_and_reconcile_tags
from inkwell.settings import get_setting, live, refresh_live, reset_live, set_settings
@@ -737,3 +740,137 @@ async def test_a_pushed_edit_keeps_the_clients_edit_time_when_a_tag_is_lifted(ap
)
note = await (await app_client.get(f"/api/notes/{nid}")).get_json()
assert datetime.fromisoformat(note["updated_at"]) == datetime(2026, 1, 1, tzinfo=timezone.utc)
# --- attachments as a sync entity (#5168) ---------------------------------------
async def _note_revision(app_client, nid: str) -> int:
feed = await (await app_client.get("/api/sync/changes?since=0")).get_json()
return next(n["sync_revision"] for n in feed["notes"] if n["id"] == nid)
async def _pushed_note(app_client, body: str = "with a file") -> str:
nid = str(uuid.uuid4())
resp = await app_client.post(
"/api/sync/push",
json={"changes": [{"entity": "note", "id": nid, "op": "upsert", "body": body,
"edited_at": "2026-01-01T00:00:00Z", "created_at": "2026-01-01T00:00:00Z"}]},
)
assert (await resp.get_json())["results"][0]["status"] == "created"
return nid
async def _put(app_client, aid: str, nid: str, raw: bytes, sha: str | None = None, name: str = "scan.pdf"):
return await app_client.put(
f"/api/sync/attachments/{aid}",
data=raw,
headers={"Content-Type": "application/pdf"},
query_string={"note_id": nid, "filename": name, "sha256": sha or hashlib.sha256(raw).hexdigest()},
)
async def test_an_offline_attachment_uploads_once_under_its_own_id(app_client, db):
await _signed_in(app_client, "upload")
nid = await _pushed_note(app_client)
aid = str(uuid.uuid4())
assert (await _put(app_client, aid, nid, b"%PDF-1", sha="0" * 64)).status_code == 400, "hash mismatch refused"
assert (await app_client.get(f"/api/notes/{nid}")).status_code == 200
assert (await (await app_client.get(f"/api/notes/{nid}")).get_json())["attachments"] == []
before = await _note_revision(app_client, nid)
first = await _put(app_client, aid, nid, b"%PDF-1")
assert first.status_code == 201
assert await _note_revision(app_client, nid) > before, "other devices hear about it"
again = await _put(app_client, aid, nid, b"%PDF-1")
assert again.status_code == 200 and (await again.get_json())["status"] == "exists", "a retried upload is a no-op"
atts = (await (await app_client.get(f"/api/notes/{nid}")).get_json())["attachments"]
assert [(a["id"], a["filename"], a["mime"], a["sha256"]) for a in atts] == [
(aid, "scan.pdf", "application/pdf", hashlib.sha256(b"%PDF-1").hexdigest())
]
other = await _pushed_note(app_client, "another note")
assert (await _put(app_client, aid, other, b"%PDF-1")).status_code == 409, "one id, one note"
async def test_an_upload_to_a_note_the_caller_does_not_own_is_not_found(app_client, db):
# Signed in first: registration is open only to the first account.
await _signed_in(app_client, "intruder")
stranger = User(email="stranger@example.test", display_name="Stranger")
db.add(stranger)
await db.flush()
theirs = Note(owner_id=stranger.id, body="theirs", display_title="theirs")
db.add(theirs)
await db.commit()
resp = await _put(app_client, str(uuid.uuid4()), str(theirs.id), b"x")
assert resp.status_code == 404
async def test_a_pushed_attachment_delete_removes_the_row_the_file_and_bumps_the_note(app_client, db):
await _signed_in(app_client, "remove")
nid = await _pushed_note(app_client)
aid = str(uuid.uuid4())
await _put(app_client, aid, nid, b"bytes")
stored = (await db.scalar(select(NoteAttachment).where(NoteAttachment.id == uuid.UUID(aid)))).path
assert (Config.media_root() / stored).is_file()
before = await _note_revision(app_client, nid)
resp = await app_client.post(
"/api/sync/push",
json={"changes": [{"entity": "attachment", "id": aid, "op": "delete", "edited_at": "2000-01-01T00:00:00Z"}]},
)
assert (await resp.get_json())["results"] == [{"id": aid, "entity": "attachment", "status": "applied"}]
assert (await (await app_client.get(f"/api/notes/{nid}")).get_json())["attachments"] == []
assert not (Config.media_root() / stored).exists()
# The edit time above is older than the note's: a removal is not a version
# competing under last-write-wins, so it applies anyway.
assert await _note_revision(app_client, nid) > before, "the note re-syncs without it"
async def test_a_child_delete_cannot_reach_another_owners_rows(app_client, db):
await _signed_in(app_client, "prober")
stranger = User(email="other@example.test", display_name="Other")
db.add(stranger)
await db.flush()
theirs = Note(owner_id=stranger.id, body="theirs", display_title="theirs")
db.add(theirs)
await db.flush()
att = NoteAttachment(note_id=theirs.id, path="x/y.bin", mime="application/octet-stream", size=1)
preview = NoteLinkPreview(note_id=theirs.id, url="https://example.com/")
db.add_all([att, preview])
await db.commit()
resp = await app_client.post(
"/api/sync/push",
json={"changes": [
{"entity": "attachment", "id": str(att.id), "op": "delete", "edited_at": "2030-01-01T00:00:00Z"},
{"entity": "preview", "id": str(preview.id), "op": "delete", "edited_at": "2030-01-01T00:00:00Z"},
{"entity": "preview", "id": str(uuid.uuid4()), "op": "upsert", "edited_at": "2030-01-01T00:00:00Z"},
]},
)
statuses = [r["status"] for r in (await resp.get_json())["results"]]
# Someone else's row answers exactly like a missing one: nothing to learn here.
assert statuses == ["noop", "noop", "rejected"]
assert await db.scalar(select(func.count()).select_from(NoteAttachment)) == 1
assert await db.scalar(select(func.count()).select_from(NoteLinkPreview)) == 1
async def test_a_preview_arriving_or_leaving_moves_its_note_in_the_feed(app_client, db):
"""0031: before it, a preview fetched after the save, or dismissed on the web,
never reached a device that had already pulled the note."""
await _signed_in(app_client, "previews")
nid = await _pushed_note(app_client, "read https://example.com/a")
before = await _note_revision(app_client, nid)
async with session_scope() as other: # as the background unfurl does
other.add(NoteLinkPreview(note_id=uuid.UUID(nid), url="https://example.com/a", title="A"))
await other.commit()
arrived = await _note_revision(app_client, nid)
assert arrived > before
preview_id = (await (await app_client.get(f"/api/notes/{nid}")).get_json())["previews"][0]["id"]
await app_client.delete(f"/api/notes/{nid}/previews/{preview_id}")
assert await _note_revision(app_client, nid) > arrived
+6
View File
@@ -34,6 +34,12 @@ async def test_push_requires_auth(app):
assert resp.status_code == 401
async def test_attachment_upload_requires_auth(app):
client = app.test_client()
resp = await client.put("/api/sync/attachments/00000000-0000-0000-0000-000000000000", data=b"x")
assert resp.status_code == 401
def test_client_wins():
older = datetime(2026, 7, 20, tzinfo=timezone.utc)
newer = datetime(2026, 7, 22, tzinfo=timezone.utc)