diff --git a/alembic/versions/0015_sync_revision.py b/alembic/versions/0015_sync_revision.py new file mode 100644 index 0000000..c69e8d8 --- /dev/null +++ b/alembic/versions/0015_sync_revision.py @@ -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") diff --git a/src/thoughtsync/models/label.py b/src/thoughtsync/models/label.py index 91ea2b0..de60a3b 100644 --- a/src/thoughtsync/models/label.py +++ b/src/thoughtsync/models/label.py @@ -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): diff --git a/src/thoughtsync/models/note.py b/src/thoughtsync/models/note.py index 6563adc..3c3417c 100644 --- a/src/thoughtsync/models/note.py +++ b/src/thoughtsync/models/note.py @@ -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 {