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
+92
View File
@@ -0,0 +1,92 @@
"""sync revision + tombstone infrastructure (M8 sync hub, step 1)
Revision ID: 0015
Revises: 0014
Create Date: 2026-07-23
The delta-sync substrate for the local-first native clients. A shared sequence
(sync_revision_seq) feeds a per-row monotonic `sync_revision` on notes + labels,
assigned by a DB TRIGGER on every insert/update so no mutation site can ever
forget to bump it. Child-row changes (items / attachments / label membership) bump
their parent note's revision, since a note syncs as a whole. `purged_at` is the
hard-delete tombstone (distinct from `deleted_at` = recoverable trash) so an
offline client learns a row is gone instead of resurrecting it.
No API behavior changes here — this is pure infrastructure. Triggers/behavior are
operator-verified on deploy (no Postgres CI lane).
"""
from alembic import op
import sqlalchemy as sa
revision = "0015"
down_revision = "0014"
branch_labels = None
depends_on = None
_REVISIONED = ("notes", "labels")
_CHILD_TABLES = ("note_items", "note_attachments", "note_labels")
def upgrade() -> None:
op.execute("CREATE SEQUENCE IF NOT EXISTS sync_revision_seq")
for table in _REVISIONED:
op.add_column(table, sa.Column("sync_revision", sa.BigInteger(), nullable=True))
op.add_column(table, sa.Column("purged_at", sa.DateTime(timezone=True), nullable=True))
# Backfill: give every existing row a distinct, increasing revision so it's
# pullable from a since=0 initial sync.
op.execute(f"UPDATE {table} SET sync_revision = nextval('sync_revision_seq')")
op.create_index("ix_notes_owner_sync_revision", "notes", ["owner_id", "sync_revision"])
op.create_index("ix_labels_owner_sync_revision", "labels", ["owner_id", "sync_revision"])
# A row stamps itself with a fresh revision on every insert/update.
op.execute(
"""
CREATE OR REPLACE FUNCTION ts_set_sync_revision() RETURNS trigger AS $$
BEGIN
NEW.sync_revision := nextval('sync_revision_seq');
RETURN NEW;
END;
$$ LANGUAGE plpgsql
"""
)
# A child-row change re-bumps its parent note (the parent's own BEFORE trigger then
# assigns the final, higher revision — a harmless double-bump; gaps are fine).
op.execute(
"""
CREATE OR REPLACE FUNCTION ts_bump_parent_note_revision() RETURNS trigger AS $$
BEGIN
UPDATE notes SET sync_revision = nextval('sync_revision_seq')
WHERE id = COALESCE(NEW.note_id, OLD.note_id);
RETURN NULL;
END;
$$ LANGUAGE plpgsql
"""
)
for table in _REVISIONED:
op.execute(
f"CREATE TRIGGER trg_{table}_sync_revision BEFORE INSERT OR UPDATE ON {table} "
"FOR EACH ROW EXECUTE PROCEDURE ts_set_sync_revision()"
)
for child in _CHILD_TABLES:
op.execute(
f"CREATE TRIGGER trg_{child}_bump_note AFTER INSERT OR UPDATE OR DELETE ON {child} "
"FOR EACH ROW EXECUTE PROCEDURE ts_bump_parent_note_revision()"
)
def downgrade() -> None:
for child in _CHILD_TABLES:
op.execute(f"DROP TRIGGER IF EXISTS trg_{child}_bump_note ON {child}")
for table in _REVISIONED:
op.execute(f"DROP TRIGGER IF EXISTS trg_{table}_sync_revision ON {table}")
op.execute("DROP FUNCTION IF EXISTS ts_bump_parent_note_revision()")
op.execute("DROP FUNCTION IF EXISTS ts_set_sync_revision()")
op.drop_index("ix_labels_owner_sync_revision", table_name="labels")
op.drop_index("ix_notes_owner_sync_revision", table_name="notes")
for table in _REVISIONED:
op.drop_column(table, "purged_at")
op.drop_column(table, "sync_revision")
op.execute("DROP SEQUENCE IF EXISTS sync_revision_seq")
+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 {