diff --git a/backend/alembic/env.py b/backend/alembic/env.py index d1cb440..b53a74d 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -6,7 +6,7 @@ from alembic import context from timeflow.data.database import Base -from timeflow.data.models import Schedule # noqa: F401 +from timeflow.data.models import Schedule, ScheduleOccurrenceOverride # noqa: F401 from timeflow.infrastructure.settings import get_settings config = context.config diff --git a/backend/alembic/versions/20260810_0003_schedule_storage_v3.py b/backend/alembic/versions/20260810_0003_schedule_storage_v3.py new file mode 100644 index 0000000..4759773 --- /dev/null +++ b/backend/alembic/versions/20260810_0003_schedule_storage_v3.py @@ -0,0 +1,382 @@ +"""Align schedule storage with the v3.10 architecture. + +Revision ID: 20260810_0003 +Revises: 20260729_0002 +Create Date: 2026-08-10 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "20260810_0003" +down_revision: str | Sequence[str] | None = "20260729_0002" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _create_v3_schedules_table() -> None: + op.create_table( + "schedules_v3", + sa.Column("id", sa.String(length=64), primary_key=True, nullable=False), + sa.Column("account_id", sa.String(length=64), nullable=False), + sa.Column("schedule_type", sa.String(length=16), nullable=False), + sa.Column( + "schedule_kind", + sa.String(length=16), + nullable=False, + server_default="once", + ), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column( + "is_all_day", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + sa.Column("start_time", sa.DateTime(timezone=True), nullable=True), + sa.Column("end_time", sa.DateTime(timezone=True), nullable=True), + sa.Column("timezone", sa.String(length=64), nullable=False), + sa.Column("recurrence_rule", sa.String(length=512), nullable=True), + sa.Column("location_name", sa.String(length=255), nullable=True), + sa.Column("latitude", sa.Numeric(precision=9, scale=6), nullable=True), + sa.Column("longitude", sa.Numeric(precision=9, scale=6), nullable=True), + sa.Column("reminder_type", sa.String(length=32), nullable=True), + sa.Column("reminder_trigger_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("reminder_offset_minutes", sa.Integer(), nullable=True), + sa.Column("reminder_strength", sa.String(length=16), nullable=True), + sa.Column("reminder_disposition_state", sa.String(length=16), nullable=True), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("revision", sa.BigInteger(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint( + "schedule_type IN ('time', 'location')", + name="ck_schedules_schedule_type", + ), + sa.CheckConstraint( + "schedule_kind IN ('once', 'recurring')", + name="ck_schedules_schedule_kind", + ), + sa.CheckConstraint( + "status IN ('active', 'deleted')", + name="ck_schedules_status", + ), + sa.CheckConstraint( + "reminder_type IS NULL OR reminder_type IN " + "('at_time', 'before_start', 'arrive_location', " + "'return_to_recorded_location')", + name="ck_schedules_reminder_type", + ), + sa.CheckConstraint( + "reminder_strength IS NULL OR reminder_strength IN ('low', 'medium', 'high')", + name="ck_schedules_reminder_strength", + ), + sa.CheckConstraint( + "reminder_disposition_state IS NULL OR reminder_disposition_state = 'confirmed'", + name="ck_schedules_reminder_disposition_state", + ), + sa.CheckConstraint("revision > 0", name="ck_schedules_revision_positive"), + sa.CheckConstraint( + "latitude IS NULL OR latitude BETWEEN -90 AND 90", + name="ck_schedules_latitude_range", + ), + sa.CheckConstraint( + "longitude IS NULL OR longitude BETWEEN -180 AND 180", + name="ck_schedules_longitude_range", + ), + sa.CheckConstraint( + "(schedule_type = 'time' AND start_time IS NOT NULL) " + "OR (schedule_type = 'location' AND start_time IS NULL " + "AND latitude IS NOT NULL AND longitude IS NOT NULL AND is_all_day = false)", + name="ck_schedules_schedule_type_requirements", + ), + sa.CheckConstraint( + "(schedule_kind = 'once' AND recurrence_rule IS NULL) " + "OR (schedule_kind = 'recurring' AND schedule_type = 'time' " + "AND recurrence_rule IS NOT NULL)", + name="ck_schedules_recurrence_requirements", + ), + sa.CheckConstraint( + "is_all_day = false " + "OR (schedule_type = 'time' AND start_time IS NOT NULL AND end_time IS NOT NULL)", + name="ck_schedules_all_day_requirements", + ), + sa.CheckConstraint( + "end_time IS NULL OR start_time IS NOT NULL", + name="ck_schedules_end_requires_start", + ), + sa.CheckConstraint( + "reminder_offset_minutes IS NULL OR reminder_offset_minutes >= 0", + name="ck_schedules_reminder_offset_nonnegative", + ), + sa.CheckConstraint( + "(reminder_type IS NULL AND reminder_trigger_at IS NULL " + "AND reminder_offset_minutes IS NULL AND reminder_strength IS NULL " + "AND reminder_disposition_state IS NULL) " + "OR (reminder_type IS NOT NULL AND reminder_strength IS NOT NULL)", + name="ck_schedules_reminder_presence", + ), + sa.CheckConstraint( + "reminder_type IS NULL " + "OR (reminder_type = 'at_time' AND reminder_trigger_at IS NOT NULL " + "AND reminder_offset_minutes IS NULL) " + "OR (reminder_type = 'before_start' AND reminder_trigger_at IS NULL " + "AND reminder_offset_minutes IS NOT NULL) " + "OR (reminder_type IN ('arrive_location', 'return_to_recorded_location') " + "AND reminder_trigger_at IS NULL AND reminder_offset_minutes IS NULL " + "AND latitude IS NOT NULL AND longitude IS NOT NULL)", + name="ck_schedules_reminder_configuration", + ), + sa.CheckConstraint( + "(status = 'active' AND deleted_at IS NULL) " + "OR (status = 'deleted' AND deleted_at IS NOT NULL)", + name="ck_schedules_deleted_at", + ), + ) + + +def _create_occurrence_overrides_table() -> None: + op.create_table( + "schedule_occurrence_overrides", + sa.Column("id", sa.String(length=64), primary_key=True, nullable=False), + sa.Column( + "schedule_id", + sa.String(length=64), + sa.ForeignKey("schedules.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column("occurrence_start", sa.DateTime(timezone=True), nullable=False), + sa.Column("action", sa.String(length=16), nullable=False), + sa.Column( + "replacement_schedule_id", + sa.String(length=64), + sa.ForeignKey("schedules.id", ondelete="RESTRICT"), + nullable=True, + ), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint( + "action IN ('cancel', 'replace')", + name="ck_schedule_occurrence_overrides_action", + ), + sa.CheckConstraint( + "(action = 'cancel' AND replacement_schedule_id IS NULL) " + "OR (action = 'replace' AND replacement_schedule_id IS NOT NULL)", + name="ck_schedule_occurrence_overrides_replacement", + ), + sa.UniqueConstraint( + "schedule_id", + "occurrence_start", + name="uq_schedule_occurrence_overrides_schedule_occurrence", + ), + ) + op.create_index( + "ix_schedule_occurrence_overrides_replacement_schedule_id", + "schedule_occurrence_overrides", + ["replacement_schedule_id"], + ) + + +def upgrade() -> None: + """Migrate the legacy schedule rows and create occurrence overrides.""" + op.drop_index("ix_schedules_system_alarm_ref_id", table_name="schedules") + op.drop_index("ix_schedules_system_schedule_ref_id", table_name="schedules") + op.drop_index("ix_schedules_user_status_start_time", table_name="schedules") + + _create_v3_schedules_table() + op.create_index( + "ix_schedules_account_status_start_time", + "schedules_v3", + ["account_id", "status", "start_time"], + ) + op.create_index( + "ix_schedules_account_revision", + "schedules_v3", + ["account_id", "revision"], + ) + + op.execute( + sa.text( + """ + INSERT INTO schedules_v3 ( + id, account_id, schedule_type, schedule_kind, title, is_all_day, + start_time, end_time, timezone, recurrence_rule, location_name, + latitude, longitude, reminder_type, reminder_trigger_at, + reminder_offset_minutes, reminder_strength, + reminder_disposition_state, status, revision, created_at, + updated_at, deleted_at + ) + SELECT + left(id, 64), + left(user_id, 64), + schedule_type, + 'once', + left(title, 255), + false, + NULLIF(start_time, '')::timestamptz, + NULLIF(end_time, '')::timestamptz, + left(COALESCE(NULLIF(timezone, ''), 'UTC'), 64), + NULL, + left(location_name, 255), + latitude::numeric(9, 6), + longitude::numeric(9, 6), + NULL, + NULL, + NULL, + NULL, + NULL, + CASE WHEN status = 'deleted' THEN 'deleted' ELSE 'active' END, + 1, + COALESCE(NULLIF(created_at, '')::timestamptz, now()), + COALESCE(NULLIF(updated_at, '')::timestamptz, now()), + CASE + WHEN status = 'deleted' + THEN COALESCE(NULLIF(updated_at, '')::timestamptz, now()) + ELSE NULL + END + FROM schedules + """ + ) + ) + + op.drop_table("schedules") + op.rename_table("schedules_v3", "schedules") + _create_occurrence_overrides_table() + + +def downgrade() -> None: + """Restore the legacy MVP schedule shape while retaining core row data.""" + op.create_table( + "schedules_v2", + sa.Column("id", sa.Text(), primary_key=True, nullable=False), + sa.Column("user_id", sa.Text(), nullable=False), + sa.Column("source_mode", sa.Text(), nullable=False), + sa.Column("schedule_type", sa.Text(), nullable=False), + sa.Column("status", sa.Text(), nullable=False), + sa.Column("title", sa.Text(), nullable=False), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("start_time", sa.Text(), nullable=True), + sa.Column("end_time", sa.Text(), nullable=True), + sa.Column("timezone", sa.Text(), nullable=True), + sa.Column("location_name", sa.Text(), nullable=True), + sa.Column("location_address", sa.Text(), nullable=True), + sa.Column("latitude", sa.Float(), nullable=True), + sa.Column("longitude", sa.Float(), nullable=True), + sa.Column("geofence_radius_meters", sa.Integer(), nullable=False), + sa.Column("geofence_armed", sa.Integer(), nullable=False), + sa.Column("time_remind_offset_minutes", sa.Integer(), nullable=False), + sa.Column("time_triggered_at", sa.Text(), nullable=True), + sa.Column("geo_triggered_at", sa.Text(), nullable=True), + sa.Column("system_schedule_ref_id", sa.Text(), nullable=True), + sa.Column("system_alarm_ref_id", sa.Text(), nullable=True), + sa.Column("created_at", sa.Text(), nullable=False), + sa.Column("updated_at", sa.Text(), nullable=False), + sa.CheckConstraint( + "source_mode IN ('manual', 'voice')", + name="ck_schedules_source_mode", + ), + sa.CheckConstraint( + "schedule_type IN ('time', 'location')", + name="ck_schedules_schedule_type", + ), + sa.CheckConstraint( + "status IN ('scheduled', 'done', 'deleted')", + name="ck_schedules_status", + ), + sa.CheckConstraint( + "geofence_armed IN (0, 1)", + name="ck_schedules_geofence_armed", + ), + sa.CheckConstraint( + "geofence_radius_meters > 0", + name="ck_schedules_geofence_radius_positive", + ), + sa.CheckConstraint( + "time_remind_offset_minutes >= 0", + name="ck_schedules_time_remind_offset_nonnegative", + ), + sa.CheckConstraint( + "latitude IS NULL OR latitude BETWEEN -90 AND 90", + name="ck_schedules_latitude_range", + ), + sa.CheckConstraint( + "longitude IS NULL OR longitude BETWEEN -180 AND 180", + name="ck_schedules_longitude_range", + ), + sa.CheckConstraint( + "(schedule_type = 'time' AND start_time IS NOT NULL) " + "OR (schedule_type = 'location' AND start_time IS NULL " + "AND latitude IS NOT NULL AND longitude IS NOT NULL)", + name="ck_schedules_schedule_type_requirements", + ), + sa.CheckConstraint( + "end_time IS NULL OR start_time IS NOT NULL", + name="ck_schedules_end_requires_start", + ), + ) + + op.execute( + sa.text( + """ + INSERT INTO schedules_v2 ( + id, user_id, source_mode, schedule_type, status, title, notes, + start_time, end_time, timezone, location_name, location_address, + latitude, longitude, geofence_radius_meters, geofence_armed, + time_remind_offset_minutes, time_triggered_at, geo_triggered_at, + system_schedule_ref_id, system_alarm_ref_id, created_at, updated_at + ) + SELECT + id, + account_id, + 'voice', + schedule_type, + CASE WHEN status = 'deleted' THEN 'deleted' ELSE 'scheduled' END, + title, + NULL, + start_time::text, + end_time::text, + timezone, + location_name, + NULL, + latitude::double precision, + longitude::double precision, + 100, + 0, + 0, + NULL, + NULL, + NULL, + NULL, + created_at::text, + updated_at::text + FROM schedules + """ + ) + ) + + op.drop_index( + "ix_schedule_occurrence_overrides_replacement_schedule_id", + table_name="schedule_occurrence_overrides", + ) + op.drop_table("schedule_occurrence_overrides") + op.drop_table("schedules") + op.rename_table("schedules_v2", "schedules") + op.create_index( + "ix_schedules_user_status_start_time", + "schedules", + ["user_id", "status", "start_time"], + ) + op.create_index( + "ix_schedules_system_schedule_ref_id", + "schedules", + ["system_schedule_ref_id"], + ) + op.create_index( + "ix_schedules_system_alarm_ref_id", + "schedules", + ["system_alarm_ref_id"], + ) diff --git a/backend/src/timeflow/data/__init__.py b/backend/src/timeflow/data/__init__.py index cbdf38a..8cccc51 100644 --- a/backend/src/timeflow/data/__init__.py +++ b/backend/src/timeflow/data/__init__.py @@ -1,6 +1,6 @@ """Database models and primitives for TimeFlow.""" from timeflow.data.database import Base -from timeflow.data.models import Schedule +from timeflow.data.models import Schedule, ScheduleOccurrenceOverride -__all__ = ["Base", "Schedule"] +__all__ = ["Base", "Schedule", "ScheduleOccurrenceOverride"] diff --git a/backend/src/timeflow/data/models.py b/backend/src/timeflow/data/models.py index 6a41d98..13d9c7f 100644 --- a/backend/src/timeflow/data/models.py +++ b/backend/src/timeflow/data/models.py @@ -1,40 +1,57 @@ """SQLAlchemy models for TimeFlow business data.""" -from sqlalchemy import CheckConstraint, Float, Index, Integer, Text +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import ( + BigInteger, + Boolean, + CheckConstraint, + DateTime, + ForeignKey, + Index, + Integer, + Numeric, + String, + UniqueConstraint, +) from sqlalchemy.orm import Mapped, mapped_column from timeflow.data.database import Base class Schedule(Base): - """Business schedule persisted by the backend.""" + """Cloud-authoritative schedule persisted by the backend.""" __tablename__ = "schedules" __table_args__ = ( - CheckConstraint( - "source_mode IN ('manual', 'voice')", - name="ck_schedules_source_mode", - ), CheckConstraint( "schedule_type IN ('time', 'location')", name="ck_schedules_schedule_type", ), CheckConstraint( - "status IN ('scheduled', 'done', 'deleted')", + "schedule_kind IN ('once', 'recurring')", + name="ck_schedules_schedule_kind", + ), + CheckConstraint( + "status IN ('active', 'deleted')", name="ck_schedules_status", ), CheckConstraint( - "geofence_armed IN (0, 1)", - name="ck_schedules_geofence_armed", + "reminder_type IS NULL OR reminder_type IN " + "('at_time', 'before_start', 'arrive_location', " + "'return_to_recorded_location')", + name="ck_schedules_reminder_type", ), CheckConstraint( - "geofence_radius_meters > 0", - name="ck_schedules_geofence_radius_positive", + "reminder_strength IS NULL OR reminder_strength IN ('low', 'medium', 'high')", + name="ck_schedules_reminder_strength", ), CheckConstraint( - "time_remind_offset_minutes >= 0", - name="ck_schedules_time_remind_offset_nonnegative", + "reminder_disposition_state IS NULL OR reminder_disposition_state = 'confirmed'", + name="ck_schedules_reminder_disposition_state", ), + CheckConstraint("revision > 0", name="ck_schedules_revision_positive"), CheckConstraint( "latitude IS NULL OR latitude BETWEEN -90 AND 90", name="ck_schedules_latitude_range", @@ -46,46 +63,127 @@ class Schedule(Base): CheckConstraint( "(schedule_type = 'time' AND start_time IS NOT NULL) " "OR (schedule_type = 'location' AND start_time IS NULL " - "AND latitude IS NOT NULL AND longitude IS NOT NULL)", + "AND latitude IS NOT NULL AND longitude IS NOT NULL AND is_all_day = false)", name="ck_schedules_schedule_type_requirements", ), + CheckConstraint( + "(schedule_kind = 'once' AND recurrence_rule IS NULL) " + "OR (schedule_kind = 'recurring' AND schedule_type = 'time' " + "AND recurrence_rule IS NOT NULL)", + name="ck_schedules_recurrence_requirements", + ), + CheckConstraint( + "is_all_day = false " + "OR (schedule_type = 'time' AND start_time IS NOT NULL AND end_time IS NOT NULL)", + name="ck_schedules_all_day_requirements", + ), CheckConstraint( "end_time IS NULL OR start_time IS NOT NULL", name="ck_schedules_end_requires_start", ), + CheckConstraint( + "reminder_offset_minutes IS NULL OR reminder_offset_minutes >= 0", + name="ck_schedules_reminder_offset_nonnegative", + ), + CheckConstraint( + "(reminder_type IS NULL AND reminder_trigger_at IS NULL " + "AND reminder_offset_minutes IS NULL AND reminder_strength IS NULL " + "AND reminder_disposition_state IS NULL) " + "OR (reminder_type IS NOT NULL AND reminder_strength IS NOT NULL)", + name="ck_schedules_reminder_presence", + ), + CheckConstraint( + "reminder_type IS NULL " + "OR (reminder_type = 'at_time' AND reminder_trigger_at IS NOT NULL " + "AND reminder_offset_minutes IS NULL) " + "OR (reminder_type = 'before_start' AND reminder_trigger_at IS NULL " + "AND reminder_offset_minutes IS NOT NULL) " + "OR (reminder_type IN ('arrive_location', 'return_to_recorded_location') " + "AND reminder_trigger_at IS NULL AND reminder_offset_minutes IS NULL " + "AND latitude IS NOT NULL AND longitude IS NOT NULL)", + name="ck_schedules_reminder_configuration", + ), + CheckConstraint( + "(status = 'active' AND deleted_at IS NULL) " + "OR (status = 'deleted' AND deleted_at IS NOT NULL)", + name="ck_schedules_deleted_at", + ), Index( - "ix_schedules_user_status_start_time", - "user_id", + "ix_schedules_account_status_start_time", + "account_id", "status", "start_time", ), - Index("ix_schedules_system_schedule_ref_id", "system_schedule_ref_id"), - Index("ix_schedules_system_alarm_ref_id", "system_alarm_ref_id"), + Index("ix_schedules_account_revision", "account_id", "revision"), ) - id: Mapped[str] = mapped_column(Text, primary_key=True) - user_id: Mapped[str] = mapped_column(Text, nullable=False) - source_mode: Mapped[str] = mapped_column(Text, nullable=False) - schedule_type: Mapped[str] = mapped_column(Text, nullable=False) - status: Mapped[str] = mapped_column(Text, nullable=False) - title: Mapped[str] = mapped_column(Text, nullable=False) - notes: Mapped[str | None] = mapped_column(Text, nullable=True) - start_time: Mapped[str | None] = mapped_column(Text, nullable=True) - end_time: Mapped[str | None] = mapped_column(Text, nullable=True) - timezone: Mapped[str | None] = mapped_column(Text, nullable=True) - location_name: Mapped[str | None] = mapped_column(Text, nullable=True) - location_address: Mapped[str | None] = mapped_column(Text, nullable=True) - latitude: Mapped[float | None] = mapped_column(Float, nullable=True) - longitude: Mapped[float | None] = mapped_column(Float, nullable=True) - geofence_radius_meters: Mapped[int] = mapped_column(Integer, nullable=False) - geofence_armed: Mapped[int] = mapped_column(Integer, nullable=False) - time_remind_offset_minutes: Mapped[int] = mapped_column(Integer, nullable=False) - time_triggered_at: Mapped[str | None] = mapped_column(Text, nullable=True) - geo_triggered_at: Mapped[str | None] = mapped_column(Text, nullable=True) - system_schedule_ref_id: Mapped[str | None] = mapped_column(Text, nullable=True) - system_alarm_ref_id: Mapped[str | None] = mapped_column(Text, nullable=True) - created_at: Mapped[str] = mapped_column(Text, nullable=False) - updated_at: Mapped[str] = mapped_column(Text, nullable=False) + id: Mapped[str] = mapped_column(String(64), primary_key=True) + account_id: Mapped[str] = mapped_column(String(64), nullable=False) + schedule_type: Mapped[str] = mapped_column(String(16), nullable=False) + schedule_kind: Mapped[str] = mapped_column( + String(16), nullable=False, default="once", server_default="once" + ) + title: Mapped[str] = mapped_column(String(255), nullable=False) + is_all_day: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="false" + ) + start_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + end_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + timezone: Mapped[str] = mapped_column(String(64), nullable=False) + recurrence_rule: Mapped[str | None] = mapped_column(String(512), nullable=True) + location_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + latitude: Mapped[Decimal | None] = mapped_column(Numeric(9, 6), nullable=True) + longitude: Mapped[Decimal | None] = mapped_column(Numeric(9, 6), nullable=True) + reminder_type: Mapped[str | None] = mapped_column(String(32), nullable=True) + reminder_trigger_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + reminder_offset_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True) + reminder_strength: Mapped[str | None] = mapped_column(String(16), nullable=True) + reminder_disposition_state: Mapped[str | None] = mapped_column(String(16), nullable=True) + status: Mapped[str] = mapped_column(String(16), nullable=False) + revision: Mapped[int] = mapped_column(BigInteger, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class ScheduleOccurrenceOverride(Base): + """Persisted exception for one occurrence of a recurring schedule.""" + + __tablename__ = "schedule_occurrence_overrides" + __table_args__ = ( + CheckConstraint( + "action IN ('cancel', 'replace')", + name="ck_schedule_occurrence_overrides_action", + ), + CheckConstraint( + "(action = 'cancel' AND replacement_schedule_id IS NULL) " + "OR (action = 'replace' AND replacement_schedule_id IS NOT NULL)", + name="ck_schedule_occurrence_overrides_replacement", + ), + UniqueConstraint( + "schedule_id", + "occurrence_start", + name="uq_schedule_occurrence_overrides_schedule_occurrence", + ), + Index( + "ix_schedule_occurrence_overrides_replacement_schedule_id", + "replacement_schedule_id", + ), + ) + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + schedule_id: Mapped[str] = mapped_column( + String(64), ForeignKey("schedules.id", ondelete="RESTRICT"), nullable=False + ) + occurrence_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + action: Mapped[str] = mapped_column(String(16), nullable=False) + replacement_schedule_id: Mapped[str | None] = mapped_column( + String(64), ForeignKey("schedules.id", ondelete="RESTRICT"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) -__all__ = ["Schedule"] +__all__ = ["Schedule", "ScheduleOccurrenceOverride"] diff --git a/backend/src/timeflow/data/repositories/__init__.py b/backend/src/timeflow/data/repositories/__init__.py new file mode 100644 index 0000000..3ee8d0a --- /dev/null +++ b/backend/src/timeflow/data/repositories/__init__.py @@ -0,0 +1,5 @@ +"""Concrete database repositories.""" + +from timeflow.data.repositories.schedule import ScheduleRepository + +__all__ = ["ScheduleRepository"] diff --git a/backend/src/timeflow/data/repositories/schedule.py b/backend/src/timeflow/data/repositories/schedule.py new file mode 100644 index 0000000..0447aa3 --- /dev/null +++ b/backend/src/timeflow/data/repositories/schedule.py @@ -0,0 +1,252 @@ +"""SQLAlchemy persistence adapter for schedules and occurrence overrides.""" + +from decimal import Decimal + +from sqlalchemy import select, update +from sqlalchemy.orm import Session + +from timeflow.business.calendar.contracts import ( + OccurrenceOverrideAction, + ReminderDispositionState, + ReminderStrength, + ReminderType, + ScheduleKind, + ScheduleOccurrenceOverrideSnapshot, + ScheduleSnapshot, + ScheduleStatus, + ScheduleType, +) +from timeflow.data.models import Schedule, ScheduleOccurrenceOverride + + +class ScheduleRepository: + """Account-scoped persistence primitives used by the schedule service. + + The repository flushes changes but never commits or rolls back. Transaction + ownership stays with the later application service implementation. + """ + + def __init__(self, session: Session) -> None: + self._session = session + + def add_schedule(self, snapshot: ScheduleSnapshot) -> ScheduleSnapshot: + """Insert one cloud schedule without committing the surrounding transaction.""" + model = Schedule(**_schedule_values(snapshot)) + self._session.add(model) + self._session.flush() + return _to_schedule_snapshot(model) + + def get_schedule( + self, + *, + account_id: str, + schedule_id: str, + include_deleted: bool = False, + ) -> ScheduleSnapshot | None: + """Return one schedule only when it belongs to the requested account.""" + statement = select(Schedule).where( + Schedule.account_id == account_id, + Schedule.id == schedule_id, + ) + if not include_deleted: + statement = statement.where(Schedule.status == ScheduleStatus.ACTIVE.value) + + model = self._session.scalar(statement) + return None if model is None else _to_schedule_snapshot(model) + + def list_schedules( + self, + *, + account_id: str, + include_deleted: bool = False, + ) -> tuple[ScheduleSnapshot, ...]: + """List schedules for exactly one account in deterministic order.""" + statement = select(Schedule).where(Schedule.account_id == account_id) + if not include_deleted: + statement = statement.where(Schedule.status == ScheduleStatus.ACTIVE.value) + statement = statement.order_by(Schedule.start_time, Schedule.created_at, Schedule.id) + + return tuple(_to_schedule_snapshot(model) for model in self._session.scalars(statement)) + + def update_schedule( + self, + *, + snapshot: ScheduleSnapshot, + expected_revision: int, + ) -> ScheduleSnapshot | None: + """Conditionally replace mutable persisted fields using optimistic revision.""" + values = _schedule_values(snapshot) + values.pop("id") + values.pop("account_id") + statement = ( + update(Schedule) + .where( + Schedule.id == snapshot.id, + Schedule.account_id == snapshot.account_id, + Schedule.revision == expected_revision, + ) + .values(**values) + .returning(Schedule) + ) + model = self._session.scalars(statement).one_or_none() + return None if model is None else _to_schedule_snapshot(model) + + def add_occurrence_override( + self, + *, + account_id: str, + snapshot: ScheduleOccurrenceOverrideSnapshot, + ) -> ScheduleOccurrenceOverrideSnapshot | None: + """Insert an override only for schedules owned by the requested account.""" + if not self._schedule_belongs_to_account(account_id, snapshot.schedule_id): + return None + if snapshot.replacement_schedule_id is not None and not self._schedule_belongs_to_account( + account_id, snapshot.replacement_schedule_id + ): + return None + + model = ScheduleOccurrenceOverride( + id=snapshot.id, + schedule_id=snapshot.schedule_id, + occurrence_start=snapshot.occurrence_start, + action=snapshot.action.value, + replacement_schedule_id=snapshot.replacement_schedule_id, + created_at=snapshot.created_at, + updated_at=snapshot.updated_at, + ) + self._session.add(model) + self._session.flush() + return _to_override_snapshot(model) + + def get_occurrence_override( + self, + *, + account_id: str, + override_id: str, + ) -> ScheduleOccurrenceOverrideSnapshot | None: + """Return one override through an account-scoped schedule join.""" + statement = ( + select(ScheduleOccurrenceOverride) + .join(Schedule, Schedule.id == ScheduleOccurrenceOverride.schedule_id) + .where( + Schedule.account_id == account_id, + ScheduleOccurrenceOverride.id == override_id, + ) + ) + model = self._session.scalar(statement) + return None if model is None else _to_override_snapshot(model) + + def list_occurrence_overrides( + self, + *, + account_id: str, + schedule_id: str | None = None, + ) -> tuple[ScheduleOccurrenceOverrideSnapshot, ...]: + """List overrides whose recurring schedules belong to one account.""" + statement = ( + select(ScheduleOccurrenceOverride) + .join(Schedule, Schedule.id == ScheduleOccurrenceOverride.schedule_id) + .where(Schedule.account_id == account_id) + ) + if schedule_id is not None: + statement = statement.where(ScheduleOccurrenceOverride.schedule_id == schedule_id) + statement = statement.order_by( + ScheduleOccurrenceOverride.occurrence_start, + ScheduleOccurrenceOverride.id, + ) + return tuple(_to_override_snapshot(model) for model in self._session.scalars(statement)) + + def _schedule_belongs_to_account(self, account_id: str, schedule_id: str) -> bool: + statement = select(Schedule.id).where( + Schedule.account_id == account_id, + Schedule.id == schedule_id, + ) + return self._session.scalar(statement) is not None + + +def _schedule_values(snapshot: ScheduleSnapshot) -> dict[str, object]: + """Map the framework-independent snapshot to ORM column values.""" + return { + "id": snapshot.id, + "account_id": snapshot.account_id, + "schedule_type": snapshot.schedule_type.value, + "schedule_kind": snapshot.schedule_kind.value, + "title": snapshot.title, + "is_all_day": snapshot.is_all_day, + "start_time": snapshot.start_time, + "end_time": snapshot.end_time, + "timezone": snapshot.timezone, + "recurrence_rule": snapshot.recurrence_rule, + "location_name": snapshot.location_name, + "latitude": None if snapshot.latitude is None else Decimal(str(snapshot.latitude)), + "longitude": None if snapshot.longitude is None else Decimal(str(snapshot.longitude)), + "reminder_type": None if snapshot.reminder_type is None else snapshot.reminder_type.value, + "reminder_trigger_at": snapshot.reminder_trigger_at, + "reminder_offset_minutes": snapshot.reminder_offset_minutes, + "reminder_strength": ( + None if snapshot.reminder_strength is None else snapshot.reminder_strength.value + ), + "reminder_disposition_state": ( + None + if snapshot.reminder_disposition_state is None + else snapshot.reminder_disposition_state.value + ), + "status": snapshot.status.value, + "revision": snapshot.revision, + "created_at": snapshot.created_at, + "updated_at": snapshot.updated_at, + "deleted_at": snapshot.deleted_at, + } + + +def _to_schedule_snapshot(model: Schedule) -> ScheduleSnapshot: + """Map one ORM row to the shared final cloud snapshot contract.""" + return ScheduleSnapshot( + id=model.id, + account_id=model.account_id, + schedule_type=ScheduleType(model.schedule_type), + schedule_kind=ScheduleKind(model.schedule_kind), + title=model.title, + is_all_day=model.is_all_day, + timezone=model.timezone, + status=ScheduleStatus(model.status), + revision=model.revision, + created_at=model.created_at, + updated_at=model.updated_at, + start_time=model.start_time, + end_time=model.end_time, + recurrence_rule=model.recurrence_rule, + location_name=model.location_name, + latitude=None if model.latitude is None else float(model.latitude), + longitude=None if model.longitude is None else float(model.longitude), + reminder_type=None if model.reminder_type is None else ReminderType(model.reminder_type), + reminder_trigger_at=model.reminder_trigger_at, + reminder_offset_minutes=model.reminder_offset_minutes, + reminder_strength=( + None if model.reminder_strength is None else ReminderStrength(model.reminder_strength) + ), + reminder_disposition_state=( + None + if model.reminder_disposition_state is None + else ReminderDispositionState(model.reminder_disposition_state) + ), + deleted_at=model.deleted_at, + ) + + +def _to_override_snapshot( + model: ScheduleOccurrenceOverride, +) -> ScheduleOccurrenceOverrideSnapshot: + """Map one occurrence override row to the shared snapshot contract.""" + return ScheduleOccurrenceOverrideSnapshot( + id=model.id, + schedule_id=model.schedule_id, + occurrence_start=model.occurrence_start, + action=OccurrenceOverrideAction(model.action), + replacement_schedule_id=model.replacement_schedule_id, + created_at=model.created_at, + updated_at=model.updated_at, + ) + + +__all__ = ["ScheduleRepository"] diff --git a/backend/tests/test_schedule_repository.py b/backend/tests/test_schedule_repository.py new file mode 100644 index 0000000..e7f1f54 --- /dev/null +++ b/backend/tests/test_schedule_repository.py @@ -0,0 +1,123 @@ +"""Repository tests for account isolation and optimistic persistence primitives.""" + +from dataclasses import replace +from datetime import UTC, datetime + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from timeflow.business.calendar import ( + OccurrenceOverrideAction, + ScheduleKind, + ScheduleOccurrenceOverrideSnapshot, + ScheduleSnapshot, + ScheduleStatus, + ScheduleType, +) +from timeflow.data.database import Base +from timeflow.data.repositories import ScheduleRepository + + +@pytest.fixture +def session() -> Session: + """Return an isolated SQLAlchemy session for repository behavior tests.""" + engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all(engine) + with Session(engine) as database_session: + yield database_session + + +def _schedule( + schedule_id: str, + account_id: str, + *, + revision: int = 1, +) -> ScheduleSnapshot: + now = datetime.now(UTC) + return ScheduleSnapshot( + id=schedule_id, + account_id=account_id, + schedule_type=ScheduleType.TIME, + schedule_kind=ScheduleKind.ONCE, + title=f"Schedule {schedule_id}", + is_all_day=False, + timezone="Asia/Shanghai", + status=ScheduleStatus.ACTIVE, + revision=revision, + created_at=now, + updated_at=now, + start_time=now, + ) + + +def test_schedule_reads_are_account_scoped(session: Session) -> None: + """An account can never load rows owned by another account.""" + repository = ScheduleRepository(session) + repository.add_schedule(_schedule("schedule-a", "account-a")) + repository.add_schedule(_schedule("schedule-b", "account-b")) + + assert repository.get_schedule(account_id="account-a", schedule_id="schedule-b") is None + account_schedules = repository.list_schedules(account_id="account-a") + assert [snapshot.id for snapshot in account_schedules] == ["schedule-a"] + + +def test_schedule_update_requires_matching_account_and_revision(session: Session) -> None: + """The persistence update is a single optimistic conditional statement.""" + repository = ScheduleRepository(session) + original = repository.add_schedule(_schedule("schedule-a", "account-a")) + updated = replace(original, title="Updated", revision=2, updated_at=datetime.now(UTC)) + + assert repository.update_schedule(snapshot=updated, expected_revision=0) is None + persisted = repository.update_schedule(snapshot=updated, expected_revision=1) + + assert persisted is not None + assert persisted.title == "Updated" + assert persisted.revision == 2 + + +def test_deleted_schedules_are_hidden_by_default(session: Session) -> None: + """Soft-deleted rows remain available only to explicit snapshot queries.""" + repository = ScheduleRepository(session) + original = repository.add_schedule(_schedule("schedule-a", "account-a")) + deleted = replace( + original, + status=ScheduleStatus.DELETED, + revision=2, + updated_at=datetime.now(UTC), + deleted_at=datetime.now(UTC), + ) + assert repository.update_schedule(snapshot=deleted, expected_revision=1) is not None + + assert repository.get_schedule(account_id="account-a", schedule_id="schedule-a") is None + assert ( + repository.get_schedule( + account_id="account-a", + schedule_id="schedule-a", + include_deleted=True, + ) + is not None + ) + + +def test_occurrence_overrides_are_account_scoped(session: Session) -> None: + """Override writes and reads follow ownership through their parent schedule.""" + repository = ScheduleRepository(session) + parent = repository.add_schedule(_schedule("series-a", "account-a")) + repository.add_schedule(_schedule("series-b", "account-b")) + now = datetime.now(UTC) + override = ScheduleOccurrenceOverrideSnapshot( + id="override-a", + schedule_id=parent.id, + occurrence_start=now, + action=OccurrenceOverrideAction.CANCEL, + created_at=now, + updated_at=now, + ) + + assert repository.add_occurrence_override(account_id="account-b", snapshot=override) is None + assert repository.add_occurrence_override(account_id="account-a", snapshot=override) == override + assert repository.list_occurrence_overrides(account_id="account-b") == () + account_overrides = repository.list_occurrence_overrides(account_id="account-a") + assert [snapshot.id for snapshot in account_overrides] == ["override-a"] + assert account_overrides[0].action is OccurrenceOverrideAction.CANCEL diff --git a/backend/tests/test_schedule_schema.py b/backend/tests/test_schedule_schema.py index dddec23..48b7122 100644 --- a/backend/tests/test_schedule_schema.py +++ b/backend/tests/test_schedule_schema.py @@ -1,33 +1,67 @@ -"""Schema tests for the MVP schedules table.""" +"""Schema tests for the v3.10 schedule persistence model.""" -from timeflow.data.models import Schedule +from timeflow.data.models import Schedule, ScheduleOccurrenceOverride -def test_schedule_table_has_mvp_columns() -> None: - """The schedules table exposes the fields required by the MVP design.""" - +def test_schedule_table_matches_cloud_snapshot_storage_fields() -> None: + """The cloud table stores only authoritative schedule and reminder fields.""" assert list(Schedule.__table__.columns.keys()) == [ "id", - "user_id", - "source_mode", + "account_id", "schedule_type", - "status", + "schedule_kind", "title", - "notes", + "is_all_day", "start_time", "end_time", "timezone", + "recurrence_rule", "location_name", - "location_address", "latitude", "longitude", - "geofence_radius_meters", - "geofence_armed", - "time_remind_offset_minutes", - "time_triggered_at", - "geo_triggered_at", - "system_schedule_ref_id", - "system_alarm_ref_id", + "reminder_type", + "reminder_trigger_at", + "reminder_offset_minutes", + "reminder_strength", + "reminder_disposition_state", + "status", + "revision", "created_at", "updated_at", + "deleted_at", ] + + +def test_schedule_table_keeps_device_runtime_state_out_of_cloud_storage() -> None: + """Geofence, snooze, and next-trigger state belong only to client SQLite.""" + columns = set(Schedule.__table__.columns.keys()) + + assert columns.isdisjoint( + { + "geofence_armed", + "next_trigger_at", + "snoozed_until", + "sync_status", + "time_triggered_at", + "geo_triggered_at", + } + ) + + +def test_occurrence_override_table_matches_v3_contract() -> None: + """Only exceptional recurring occurrences are persisted.""" + assert list(ScheduleOccurrenceOverride.__table__.columns.keys()) == [ + "id", + "schedule_id", + "occurrence_start", + "action", + "replacement_schedule_id", + "created_at", + "updated_at", + ] + + constraint_names = { + constraint.name for constraint in ScheduleOccurrenceOverride.__table__.constraints + } + assert "uq_schedule_occurrence_overrides_schedule_occurrence" in constraint_names + assert "ck_schedule_occurrence_overrides_replacement" in constraint_names diff --git a/frontend/app.json b/frontend/app.json index 5c52d0b..22004ff 100644 --- a/frontend/app.json +++ b/frontend/app.json @@ -17,6 +17,7 @@ "monochromeImage": "./assets/android-icon-monochrome.png" }, "predictiveBackGestureEnabled": false - } + }, + "plugins": ["expo-sqlite"] } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2628621..f769ea6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "expo": "~57.0.7", + "expo-sqlite": "~57.0.1", "expo-status-bar": "~57.0.1", "react": "19.2.3", "react-native": "0.86.0" @@ -3034,6 +3035,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/await-lock": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==", + "license": "MIT" + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.17", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", @@ -4818,6 +4825,20 @@ "node": ">=20.16.0" } }, + "node_modules/expo-sqlite": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-57.0.1.tgz", + "integrity": "sha512-I6KoUvfGIiROTKxr5D3H+jRIGA/iEvWEtqHK4XMukAA7tVTinz/YdS8zOz6/DdG6vgrNmvF7gyOcbhLinlfxzQ==", + "license": "MIT", + "dependencies": { + "await-lock": "^2.2.2" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-status-bar": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-57.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1b2211c..bd63c3c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "packageManager": "npm@10.8.2", "dependencies": { "expo": "~57.0.7", + "expo-sqlite": "~57.0.1", "expo-status-bar": "~57.0.1", "react": "19.2.3", "react-native": "0.86.0" diff --git a/frontend/src/database/index.ts b/frontend/src/database/index.ts new file mode 100644 index 0000000..18151e7 --- /dev/null +++ b/frontend/src/database/index.ts @@ -0,0 +1,6 @@ +export { + CREATE_SCHEDULE_STORAGE_SQL, + CURRENT_DATABASE_VERSION, + migrateScheduleDatabase, +} from './migrations'; +export { openTimeflowDatabase, TIMEFLOW_DATABASE_NAME } from './sqlite'; diff --git a/frontend/src/database/migrations.ts b/frontend/src/database/migrations.ts new file mode 100644 index 0000000..d1258d0 --- /dev/null +++ b/frontend/src/database/migrations.ts @@ -0,0 +1,134 @@ +import type { SQLiteDatabase } from 'expo-sqlite'; + +export const CURRENT_DATABASE_VERSION = 1; + +export const CREATE_SCHEDULE_STORAGE_SQL = ` +CREATE TABLE IF NOT EXISTS local_schedules ( + id TEXT PRIMARY KEY NOT NULL, + account_id TEXT NOT NULL, + schedule_type TEXT NOT NULL CHECK (schedule_type IN ('time', 'location')), + schedule_kind TEXT NOT NULL DEFAULT 'once' CHECK (schedule_kind IN ('once', 'recurring')), + title TEXT NOT NULL, + is_all_day INTEGER NOT NULL DEFAULT 0 CHECK (is_all_day IN (0, 1)), + start_time TEXT NULL, + end_time TEXT NULL, + timezone TEXT NOT NULL, + recurrence_rule TEXT NULL, + location_name TEXT NULL, + latitude REAL NULL CHECK (latitude IS NULL OR latitude BETWEEN -90 AND 90), + longitude REAL NULL CHECK (longitude IS NULL OR longitude BETWEEN -180 AND 180), + reminder_type TEXT NULL CHECK ( + reminder_type IS NULL OR reminder_type IN ( + 'at_time', + 'before_start', + 'arrive_location', + 'return_to_recorded_location' + ) + ), + reminder_trigger_at TEXT NULL, + reminder_offset_minutes INTEGER NULL CHECK ( + reminder_offset_minutes IS NULL OR reminder_offset_minutes >= 0 + ), + reminder_strength TEXT NULL CHECK ( + reminder_strength IS NULL OR reminder_strength IN ('low', 'medium', 'high') + ), + reminder_disposition_state TEXT NULL CHECK ( + reminder_disposition_state IS NULL + OR reminder_disposition_state IN ('confirmed', 'snoozed') + ), + next_trigger_at TEXT NULL, + snoozed_until TEXT NULL, + geofence_armed INTEGER NOT NULL DEFAULT 0 CHECK (geofence_armed IN (0, 1)), + disposition_updated_at TEXT NULL, + sync_status TEXT NOT NULL DEFAULT 'synced' CHECK (sync_status IN ('pending', 'synced')), + status TEXT NOT NULL CHECK (status IN ('active', 'deleted')), + cloud_revision INTEGER NOT NULL CHECK (cloud_revision > 0), + updated_at TEXT NOT NULL, + CHECK ( + (schedule_type = 'time' AND start_time IS NOT NULL) + OR ( + schedule_type = 'location' + AND start_time IS NULL + AND latitude IS NOT NULL + AND longitude IS NOT NULL + AND is_all_day = 0 + ) + ), + CHECK ( + (schedule_kind = 'once' AND recurrence_rule IS NULL) + OR ( + schedule_kind = 'recurring' + AND schedule_type = 'time' + AND recurrence_rule IS NOT NULL + ) + ), + CHECK ( + is_all_day = 0 + OR (schedule_type = 'time' AND start_time IS NOT NULL AND end_time IS NOT NULL) + ), + CHECK ( + (reminder_type IS NULL + AND reminder_trigger_at IS NULL + AND reminder_offset_minutes IS NULL + AND reminder_strength IS NULL) + OR (reminder_type IS NOT NULL AND reminder_strength IS NOT NULL) + ), + CHECK ( + reminder_type IS NULL + OR (reminder_type = 'at_time' + AND reminder_trigger_at IS NOT NULL + AND reminder_offset_minutes IS NULL) + OR (reminder_type = 'before_start' + AND reminder_trigger_at IS NULL + AND reminder_offset_minutes IS NOT NULL) + OR (reminder_type IN ('arrive_location', 'return_to_recorded_location') + AND reminder_trigger_at IS NULL + AND reminder_offset_minutes IS NULL + AND latitude IS NOT NULL + AND longitude IS NOT NULL) + ) +); + +CREATE INDEX IF NOT EXISTS ix_local_schedules_account_status_start_time + ON local_schedules (account_id, status, start_time); +CREATE INDEX IF NOT EXISTS ix_local_schedules_account_cloud_revision + ON local_schedules (account_id, cloud_revision); + +CREATE TABLE IF NOT EXISTS local_schedule_occurrence_overrides ( + id TEXT PRIMARY KEY NOT NULL, + schedule_id TEXT NOT NULL REFERENCES local_schedules(id) ON DELETE CASCADE, + occurrence_start TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('cancel', 'replace')), + replacement_schedule_id TEXT NULL REFERENCES local_schedules(id) ON DELETE RESTRICT, + UNIQUE (schedule_id, occurrence_start), + CHECK ( + (action = 'cancel' AND replacement_schedule_id IS NULL) + OR (action = 'replace' AND replacement_schedule_id IS NOT NULL) + ) +); + +CREATE INDEX IF NOT EXISTS ix_local_schedule_overrides_replacement_schedule_id + ON local_schedule_occurrence_overrides (replacement_schedule_id); +`; + +export async function migrateScheduleDatabase(database: SQLiteDatabase): Promise { + await database.execAsync('PRAGMA foreign_keys = ON'); + const versionRow = await database.getFirstAsync<{ user_version: number }>('PRAGMA user_version'); + const currentVersion = versionRow?.user_version ?? 0; + + if (currentVersion > CURRENT_DATABASE_VERSION) { + throw new Error( + `Unsupported Timeflow database version ${currentVersion}; expected at most ${CURRENT_DATABASE_VERSION}`, + ); + } + if (currentVersion === CURRENT_DATABASE_VERSION) { + return; + } + + await database.withExclusiveTransactionAsync(async (transaction) => { + if (currentVersion < 1) { + await transaction.execAsync(CREATE_SCHEDULE_STORAGE_SQL); + await transaction.execAsync('PRAGMA user_version = 1'); + } + }); +} diff --git a/frontend/src/database/sqlite.ts b/frontend/src/database/sqlite.ts new file mode 100644 index 0000000..1ed4263 --- /dev/null +++ b/frontend/src/database/sqlite.ts @@ -0,0 +1,12 @@ +import { openDatabaseAsync, type SQLiteDatabase } from 'expo-sqlite'; + +import { migrateScheduleDatabase } from './migrations'; + +export const TIMEFLOW_DATABASE_NAME = 'timeflow.db'; + +export async function openTimeflowDatabase(): Promise { + const database = await openDatabaseAsync(TIMEFLOW_DATABASE_NAME); + await database.execAsync('PRAGMA journal_mode = WAL'); + await migrateScheduleDatabase(database); + return database; +} diff --git a/frontend/src/features/schedule/data/index.ts b/frontend/src/features/schedule/data/index.ts new file mode 100644 index 0000000..b9e4e19 --- /dev/null +++ b/frontend/src/features/schedule/data/index.ts @@ -0,0 +1,7 @@ +export { + ScheduleLocalRepository, + type LocalReminderDispositionState, + type LocalReminderSyncStatus, + type LocalScheduleOccurrenceOverrideRow, + type LocalScheduleRow, +} from './local'; diff --git a/frontend/src/features/schedule/data/local/index.ts b/frontend/src/features/schedule/data/local/index.ts new file mode 100644 index 0000000..ca240ff --- /dev/null +++ b/frontend/src/features/schedule/data/local/index.ts @@ -0,0 +1,7 @@ +export { + ScheduleLocalRepository, + type LocalReminderDispositionState, + type LocalReminderSyncStatus, + type LocalScheduleOccurrenceOverrideRow, + type LocalScheduleRow, +} from './scheduleLocalRepository'; diff --git a/frontend/src/features/schedule/data/local/scheduleLocalRepository.ts b/frontend/src/features/schedule/data/local/scheduleLocalRepository.ts new file mode 100644 index 0000000..e69a9c1 --- /dev/null +++ b/frontend/src/features/schedule/data/local/scheduleLocalRepository.ts @@ -0,0 +1,218 @@ +import type { SQLiteDatabase } from 'expo-sqlite'; + +import type { + OccurrenceOverrideAction, + ReminderStrength, + ReminderType, + ScheduleKind, + ScheduleStatus, + ScheduleType, +} from '../../../../contracts/schedule'; + +export type LocalReminderDispositionState = 'confirmed' | 'snoozed'; +export type LocalReminderSyncStatus = 'pending' | 'synced'; + +export interface LocalScheduleRow { + id: string; + account_id: string; + schedule_type: ScheduleType; + schedule_kind: ScheduleKind; + title: string; + is_all_day: 0 | 1; + start_time: string | null; + end_time: string | null; + timezone: string; + recurrence_rule: string | null; + location_name: string | null; + latitude: number | null; + longitude: number | null; + reminder_type: ReminderType | null; + reminder_trigger_at: string | null; + reminder_offset_minutes: number | null; + reminder_strength: ReminderStrength | null; + reminder_disposition_state: LocalReminderDispositionState | null; + next_trigger_at: string | null; + snoozed_until: string | null; + geofence_armed: 0 | 1; + disposition_updated_at: string | null; + sync_status: LocalReminderSyncStatus; + status: ScheduleStatus; + cloud_revision: number; + updated_at: string; +} + +export interface LocalScheduleOccurrenceOverrideRow { + id: string; + schedule_id: string; + occurrence_start: string; + action: OccurrenceOverrideAction; + replacement_schedule_id: string | null; +} + +export class ScheduleLocalRepository { + public constructor(private readonly database: SQLiteDatabase) {} + + public getSchedule(accountId: string, scheduleId: string): Promise { + return this.database.getFirstAsync( + `SELECT * FROM local_schedules WHERE account_id = ? AND id = ?`, + accountId, + scheduleId, + ); + } + + public listSchedules(accountId: string): Promise { + return this.database.getAllAsync( + `SELECT * + FROM local_schedules + WHERE account_id = ? + ORDER BY start_time, updated_at, id`, + accountId, + ); + } + + public async upsertSchedule(row: LocalScheduleRow): Promise { + const result = await this.database.runAsync( + `INSERT INTO local_schedules ( + id, account_id, schedule_type, schedule_kind, title, is_all_day, + start_time, end_time, timezone, recurrence_rule, location_name, + latitude, longitude, reminder_type, reminder_trigger_at, + reminder_offset_minutes, reminder_strength, reminder_disposition_state, + next_trigger_at, snoozed_until, geofence_armed, disposition_updated_at, + sync_status, status, cloud_revision, updated_at + ) VALUES ( + $id, $account_id, $schedule_type, $schedule_kind, $title, $is_all_day, + $start_time, $end_time, $timezone, $recurrence_rule, $location_name, + $latitude, $longitude, $reminder_type, $reminder_trigger_at, + $reminder_offset_minutes, $reminder_strength, $reminder_disposition_state, + $next_trigger_at, $snoozed_until, $geofence_armed, $disposition_updated_at, + $sync_status, $status, $cloud_revision, $updated_at + ) + ON CONFLICT(id) DO UPDATE SET + schedule_type = excluded.schedule_type, + schedule_kind = excluded.schedule_kind, + title = excluded.title, + is_all_day = excluded.is_all_day, + start_time = excluded.start_time, + end_time = excluded.end_time, + timezone = excluded.timezone, + recurrence_rule = excluded.recurrence_rule, + location_name = excluded.location_name, + latitude = excluded.latitude, + longitude = excluded.longitude, + reminder_type = excluded.reminder_type, + reminder_trigger_at = excluded.reminder_trigger_at, + reminder_offset_minutes = excluded.reminder_offset_minutes, + reminder_strength = excluded.reminder_strength, + reminder_disposition_state = excluded.reminder_disposition_state, + next_trigger_at = excluded.next_trigger_at, + snoozed_until = excluded.snoozed_until, + geofence_armed = excluded.geofence_armed, + disposition_updated_at = excluded.disposition_updated_at, + sync_status = excluded.sync_status, + status = excluded.status, + cloud_revision = excluded.cloud_revision, + updated_at = excluded.updated_at + WHERE local_schedules.account_id = excluded.account_id`, + { + $id: row.id, + $account_id: row.account_id, + $schedule_type: row.schedule_type, + $schedule_kind: row.schedule_kind, + $title: row.title, + $is_all_day: row.is_all_day, + $start_time: row.start_time, + $end_time: row.end_time, + $timezone: row.timezone, + $recurrence_rule: row.recurrence_rule, + $location_name: row.location_name, + $latitude: row.latitude, + $longitude: row.longitude, + $reminder_type: row.reminder_type, + $reminder_trigger_at: row.reminder_trigger_at, + $reminder_offset_minutes: row.reminder_offset_minutes, + $reminder_strength: row.reminder_strength, + $reminder_disposition_state: row.reminder_disposition_state, + $next_trigger_at: row.next_trigger_at, + $snoozed_until: row.snoozed_until, + $geofence_armed: row.geofence_armed, + $disposition_updated_at: row.disposition_updated_at, + $sync_status: row.sync_status, + $status: row.status, + $cloud_revision: row.cloud_revision, + $updated_at: row.updated_at, + }, + ); + return result.changes === 1; + } + + public async deleteSchedule(accountId: string, scheduleId: string): Promise { + const result = await this.database.runAsync( + `DELETE FROM local_schedules WHERE account_id = ? AND id = ?`, + accountId, + scheduleId, + ); + return result.changes === 1; + } + + public async upsertOccurrenceOverride( + accountId: string, + row: LocalScheduleOccurrenceOverrideRow, + ): Promise { + const owner = await this.database.getFirstAsync<{ id: string }>( + `SELECT id FROM local_schedules WHERE account_id = ? AND id = ?`, + accountId, + row.schedule_id, + ); + if (owner === null) { + return false; + } + if (row.replacement_schedule_id !== null) { + const replacementOwner = await this.database.getFirstAsync<{ id: string }>( + `SELECT id FROM local_schedules WHERE account_id = ? AND id = ?`, + accountId, + row.replacement_schedule_id, + ); + if (replacementOwner === null) { + return false; + } + } + + const result = await this.database.runAsync( + `INSERT INTO local_schedule_occurrence_overrides ( + id, schedule_id, occurrence_start, action, replacement_schedule_id + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + occurrence_start = excluded.occurrence_start, + action = excluded.action, + replacement_schedule_id = excluded.replacement_schedule_id + WHERE schedule_id = excluded.schedule_id`, + row.id, + row.schedule_id, + row.occurrence_start, + row.action, + row.replacement_schedule_id, + ); + return result.changes === 1; + } + + public listOccurrenceOverrides( + accountId: string, + scheduleId?: string, + ): Promise { + const scheduleFilter = scheduleId === undefined ? '' : 'AND overrides.schedule_id = ?'; + const parameters = scheduleId === undefined ? [accountId] : [accountId, scheduleId]; + return this.database.getAllAsync( + `SELECT + overrides.id, + overrides.schedule_id, + overrides.occurrence_start, + overrides.action, + overrides.replacement_schedule_id + FROM local_schedule_occurrence_overrides AS overrides + INNER JOIN local_schedules AS schedules ON schedules.id = overrides.schedule_id + WHERE schedules.account_id = ? ${scheduleFilter} + ORDER BY overrides.occurrence_start, overrides.id`, + parameters, + ); + } +} diff --git a/frontend/tests/scheduleStorageContracts.test-d.ts b/frontend/tests/scheduleStorageContracts.test-d.ts new file mode 100644 index 0000000..b28460b --- /dev/null +++ b/frontend/tests/scheduleStorageContracts.test-d.ts @@ -0,0 +1,73 @@ +import type { ReminderDispositionState } from '../src/contracts/schedule'; +import type { + LocalReminderDispositionState, + LocalScheduleOccurrenceOverrideRow, + LocalScheduleRow, + ScheduleLocalRepository, +} from '../src/features/schedule/data'; + +type Equal = + (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 + ? true + : false; + +type Assert = Condition; + +export type LocalReminderStateContract = Assert< + Equal +>; + +export type LocalSnoozeDoesNotEnterCloudContract = Assert< + Equal, never> +>; + +export type LocalScheduleStorageColumnsContract = Assert< + Equal< + keyof LocalScheduleRow, + | 'id' + | 'account_id' + | 'schedule_type' + | 'schedule_kind' + | 'title' + | 'is_all_day' + | 'start_time' + | 'end_time' + | 'timezone' + | 'recurrence_rule' + | 'location_name' + | 'latitude' + | 'longitude' + | 'reminder_type' + | 'reminder_trigger_at' + | 'reminder_offset_minutes' + | 'reminder_strength' + | 'reminder_disposition_state' + | 'next_trigger_at' + | 'snoozed_until' + | 'geofence_armed' + | 'disposition_updated_at' + | 'sync_status' + | 'status' + | 'cloud_revision' + | 'updated_at' + > +>; + +export type LocalOccurrenceOverrideStorageColumnsContract = Assert< + Equal< + keyof LocalScheduleOccurrenceOverrideRow, + 'id' | 'schedule_id' | 'occurrence_start' | 'action' | 'replacement_schedule_id' + > +>; + +export type LocalRepositoryOperationsContract = Assert< + Equal< + keyof ScheduleLocalRepository, + | 'getSchedule' + | 'listSchedules' + | 'upsertSchedule' + | 'deleteSchedule' + | 'upsertOccurrenceOverride' + | 'listOccurrenceOverrides' + > +>;