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
86 lines
4.1 KiB
Python
86 lines
4.1 KiB
Python
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime
|
|
|
|
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, Text, func
|
|
from sqlalchemy.dialects.postgresql import UUID
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from . import Base
|
|
|
|
# The Keep-style palette. Stored as a key string, so the actual tints live in the
|
|
# frontend and can change without a schema migration.
|
|
NOTE_COLORS = {
|
|
"default",
|
|
"red",
|
|
"orange",
|
|
"yellow",
|
|
"green",
|
|
"teal",
|
|
"blue",
|
|
"purple",
|
|
"pink",
|
|
"gray",
|
|
}
|
|
|
|
|
|
class Note(Base):
|
|
__tablename__ = "notes"
|
|
__table_args__ = (
|
|
# Covers the board queries: a user's notes filtered by trash/archive state,
|
|
# pinned first.
|
|
Index("ix_notes_owner_board", "owner_id", "deleted_at", "archived", "pinned"),
|
|
)
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
owner_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
title: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
|
# The note's display NAME: explicit title if set, else the first non-empty body
|
|
# line (see notes.derive_display_title). Persisted + normalized-matched so every
|
|
# note — even a body-only one — is nameable, searchable, graphable, and
|
|
# [[wiki-link]]-able without forcing the user to type a title.
|
|
display_title: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
|
|
body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
|
|
color: Mapped[str] = mapped_column(Text(), nullable=False, server_default="default")
|
|
# 'text' (freeform body) or 'list' (a checklist of note_items).
|
|
kind: Mapped[str] = mapped_column(Text(), nullable=False, server_default="text")
|
|
# Manual drag order (higher = earlier); 0 until the user reorders.
|
|
position: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0")
|
|
pinned: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
|
|
archived: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
|
|
# Soft delete: non-null => in Trash. Restore sets it back to null.
|
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
# Optional reminder time (surfaced in the Reminders view; no push in M3).
|
|
remind_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
|
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 {
|
|
"id": str(self.id),
|
|
"title": self.title,
|
|
"display_title": self.display_title,
|
|
"body": self.body,
|
|
"color": self.color,
|
|
"kind": self.kind,
|
|
"position": self.position,
|
|
"pinned": self.pinned,
|
|
"archived": self.archived,
|
|
"trashed": self.deleted_at is not None,
|
|
"remind_at": self.remind_at.isoformat() if self.remind_at else None,
|
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
|
}
|