Sync 5: attachment blob sync — client id + content hash (M8)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 44s

Let native clients sync attachment blobs deterministically:
- note_attachments gains sha256 (migration 0018, nullable, no backfill).
  The delta feed's attachment metadata now carries size + sha256 so a
  client knows exactly which blobs it already has (dedupe) and can verify
  integrity after download.
- Upload accepts an optional client-supplied attachment id (multipart form
  field), so a file attached offline keeps its identity across sync;
  re-uploading an id the note already has is an idempotent no-op. The
  server hashes the stored bytes (sha256) on upload. Download by id already
  exists (owner/shared scoped).

Frontend Attachment type carries the new optional size/sha256.

(Still image-only mimes — broadening to any-file is task 1900. Blob sync
behavior is operator-verified on deploy.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-22 23:12:21 -04:00
co-authored by Claude Opus 4.8
parent 68abaa0f3f
commit 0ca244be3d
4 changed files with 63 additions and 2 deletions
@@ -0,0 +1,25 @@
"""note_attachments.sha256 (M8 sync hub, step 5 — attachment blob sync)
Revision ID: 0018
Revises: 0017
Create Date: 2026-07-23
A content hash so a native client can tell which attachment blobs it already has
(dedupe) and verify integrity after downloading. Nullable; existing rows are left
un-hashed (no backfill — the file bytes may not be reachable at migration time).
"""
from alembic import op
import sqlalchemy as sa
revision = "0018"
down_revision = "0017"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("note_attachments", sa.Column("sha256", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("note_attachments", "sha256")
+2
View File
@@ -27,6 +27,8 @@ export interface Attachment {
id: string;
url: string;
mime: string;
size?: number;
sha256?: string | null;
}
// A past version of a note's title+body (version history).
@@ -23,4 +23,7 @@ class NoteAttachment(Base):
path: Mapped[str] = mapped_column(Text(), nullable=False) # relative to media_root
mime: Mapped[str] = mapped_column(Text(), nullable=False)
size: Mapped[int] = mapped_column(BigInteger(), nullable=False)
# Content hash (sha256 hex) for client-side dedupe + integrity over sync. Nullable
# for rows created before this column (no backfill).
sha256: Mapped[str | None] = mapped_column(Text(), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+33 -2
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import hashlib
import io
import json
import os
@@ -159,7 +160,13 @@ async def _attachments_for_notes(db, note_ids: list) -> dict:
).all()
for att in rows:
result.setdefault(att.note_id, []).append(
{"id": str(att.id), "url": _attachment_url(att.note_id, att.id), "mime": att.mime}
{
"id": str(att.id),
"url": _attachment_url(att.note_id, att.id),
"mime": att.mime,
"size": att.size,
"sha256": att.sha256,
}
)
return result
@@ -1187,12 +1194,36 @@ async def upload_attachment(note_id: str):
ext = ALLOWED_IMAGE_MIMES.get(mime)
if ext is None:
return jsonify({"error": "unsupported image type (png, jpeg, gif, webp only)"}), 415
# A native client may supply the attachment's id so an offline-attached file
# keeps its identity across sync. Re-uploading an id it already has is a no-op.
form = await request.form
raw_id = (form.get("id") or "").strip()
att_id = uuid.uuid4()
if raw_id:
try:
att_id = uuid.UUID(raw_id)
except (ValueError, TypeError):
return jsonify({"error": "invalid attachment id"}), 400
existing = await db.scalar(
select(NoteAttachment).where(NoteAttachment.id == att_id, NoteAttachment.note_id == note.id)
)
if existing is not None:
return jsonify(await _serialize_note(db, note)) # already have this blob
rel = os.path.join(str(note.id), f"{att_id}{ext}")
dest = Config.media_root() / rel
dest.parent.mkdir(parents=True, exist_ok=True)
await upload.save(str(dest))
db.add(NoteAttachment(id=att_id, note_id=note.id, path=rel, mime=mime, size=dest.stat().st_size))
raw = dest.read_bytes()
db.add(
NoteAttachment(
id=att_id,
note_id=note.id,
path=rel,
mime=mime,
size=len(raw),
sha256=hashlib.sha256(raw).hexdigest(),
)
)
await db.commit()
return jsonify(await _serialize_note(db, note)), 201