Sync 1: revision + tombstone schema (M8 sync hub foundation)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 34s

The delta-sync substrate for the local-first native clients (Tauri,
Android). No API behavior change — pure infrastructure; triggers are
operator-verified on deploy (no Postgres CI lane).

Migration 0015:
- CREATE SEQUENCE sync_revision_seq.
- notes + labels gain sync_revision (bigint) + purged_at (tombstone),
  with existing rows backfilled to distinct increasing revisions.
- Trigger ts_set_sync_revision() BEFORE INSERT OR UPDATE on notes+labels
  stamps a fresh monotonic revision from the sequence, so no mutation
  site can forget to bump it (robustness over app-level bumps).
- Trigger ts_bump_parent_note_revision() AFTER INS/UPD/DEL on note_items,
  note_attachments, note_labels re-bumps the parent note, since a note
  syncs as a whole (items/labels/attachments travel inline).
- Indexes (owner_id, sync_revision) on notes + labels for the delta pull
  WHERE sync_revision > cursor.

purged_at is the hard-delete tombstone (distinct from deleted_at = trash)
so an offline client learns a row is gone instead of resurrecting it.
Model columns added to Note + Label (nullable; trigger populates them).

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 22:53:34 -04:00
co-authored by Claude Opus 4.8
parent 333ab9ce74
commit 58b88d2622
3 changed files with 106 additions and 2 deletions
+6 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Text, UniqueConstraint, func
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -21,6 +21,11 @@ class Label(Base):
name: Mapped[str] = mapped_column(Text(), nullable=False)
color: Mapped[str] = mapped_column(Text(), nullable=False, default="default", server_default="default")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
# Sync (M8): monotonic per-row revision (from sync_revision_seq via DB trigger) so a
# label rename/recolor/merge/delete propagates to native clients independently of notes.
sync_revision: Mapped[int | None] = mapped_column(BigInteger(), nullable=True)
# Hard-delete tombstone (content-less) so a deleted label is removed on clients.
purged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class NoteLabel(Base):
+8 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, Text, func
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -59,6 +59,13 @@ class Note(Base):
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
# Sync (M8): a monotonic per-row revision drawn from sync_revision_seq and assigned
# by a DB trigger on every insert/update — the delta cursor native clients pull
# against. Nullable in the ORM because the trigger populates it server-side.
sync_revision: Mapped[int | None] = mapped_column(BigInteger(), nullable=True)
# Hard-delete tombstone: non-null => permanently deleted (content cleared), kept so
# offline clients learn the row is gone. Distinct from deleted_at (= recoverable trash).
purged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
def serialize(self) -> dict:
return {