import logging from datetime import date, datetime, timezone from sqlalchemy import or_, select from scribe.models import async_session from scribe.models.milestone import Milestone from scribe.models.note import Note from scribe.models.note_draft import NoteDraft from scribe.models.note_supersession import NoteSupersession from scribe.models.note_version import NoteVersion from scribe.models.design_system import DesignSystem, DesignToken from scribe.models.note_usage import NoteUsageEvent from scribe.models.project import Project from scribe.models.repo_binding import RepoBinding from scribe.models.rulebook import ( Rule, Rulebook, RulebookTopic, project_rule_suppressions, project_rulebook_subscriptions, project_topic_suppressions, ) from scribe.models.setting import Setting from scribe.models.system import RecordSystem, System from scribe.models.task_log import TaskLog from scribe.models.user import User logger = logging.getLogger(__name__) # Backup format version. v3 (2026-06) added rulebooks/topics/rules + their # project subscription/suppression join tables. v4 (2026-07) dropped events # when the calendar surface was retired — old v3 events are skipped on restore. # v5 (2026-08) added the six tables that had accumulated outside the backup # entirely (#2293), and the coverage guard that stops the seventh. # v6 (2026-08) added note_supersessions — and the guard did stop the seventh: # the table shipped without a backup section and the coverage test failed the # build, which is the whole reason that list was written. # Bump when the serialized schema changes. BACKUP_VERSION = 6 # Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED # below, these two lists must together account for the entire schema — which is # what tests/test_services_backup.py asserts against Base.metadata. # # The point is the ABSENCE case. A new table gets a model and a migration, both # of which fail loudly if wrong, and then silently never gets a backup section: # no error, no warning, and a restore that reports success. Naming the coverage # explicitly turns "someone forgot" into a failing test (#2293). _BACKED_UP = [ "users", "projects", "milestones", "notes", "task_logs", "note_drafts", "note_versions", "settings", "rulebooks", "rulebook_topics", "rules", "project_rulebook_subscriptions", "project_rule_suppressions", "project_topic_suppressions", # v5 (2026-08): the five-year gap this list was written to stop. "systems", "record_systems", "design_systems", "design_tokens", "note_usage_events", "repo_bindings", "note_supersessions", ] # Tables intentionally NOT in the backup, surfaced in the payload so the gap is # explicit rather than silent. ACL (groups/shares) is a coherent follow-up; # note_embeddings are derived (regenerated from note bodies); api_keys are # sensitive credentials; retrieval_logs is observational telemetry that nothing # reads for correctness and that grows per query; the rest are # transient/operational. # # REAL table names, deliberately. This list used to read "embeddings", # "invitations", "password_resets" — none of which are tables — so it looked # like coverage while naming nothing the schema could confirm. _NOT_INCLUDED = [ "groups", "group_memberships", "project_shares", "note_shares", "api_keys", "note_embeddings", "app_logs", "notifications", "invitation_tokens", "password_reset_tokens", "user_profiles", "retrieval_logs", ] def _dt(val: str | None) -> datetime: return datetime.fromisoformat(val) if val else datetime.now(timezone.utc) def _d(val: str | None) -> date | None: return date.fromisoformat(val) if val else None def _subscription_rows(rows) -> list[dict]: return [{"project_id": r.project_id, "rulebook_id": r.rulebook_id} for r in rows] def _rule_suppression_rows(rows) -> list[dict]: return [{"project_id": r.project_id, "rule_id": r.rule_id} for r in rows] def _topic_suppression_rows(rows) -> list[dict]: return [{"project_id": r.project_id, "topic_id": r.topic_id} for r in rows] # The v5 sections. Pure row-builders like the join-table helpers above, for the # same reason: CI has no database, so a serialiser that is a plain function is # one that can actually be tested. def _system_rows(rows) -> list[dict]: return [ { "id": r.id, "user_id": r.user_id, "project_id": r.project_id, "name": r.name, "description": r.description, "color": r.color, "status": r.status, "order_index": r.order_index, } for r in rows ] def _record_system_rows(rows) -> list[dict]: return [{"note_id": r.note_id, "system_id": r.system_id} for r in rows] def _note_supersession_rows(rows) -> list[dict]: """Which record has overtaken which. Carried because it is a JUDGEMENT — someone decided this note replaced that one, and nothing in either note's text records the decision. Lose it and the corpus silently reverts to ranking stale material alongside current material.""" return [ {"superseder_id": r.superseder_id, "superseded_id": r.superseded_id} for r in rows ] def _design_system_rows(rows) -> list[dict]: return [ { "id": r.id, "owner_user_id": r.owner_user_id, "title": r.title, "description": r.description, "guidance": r.guidance, "parent_id": r.parent_id, } for r in rows ] def _design_token_rows(rows) -> list[dict]: return [ { "id": r.id, "design_system_id": r.design_system_id, "name": r.name, "value_by_mode": r.value_by_mode or {}, "group_name": r.group_name, "purpose": r.purpose, "rationale": r.rationale, "supersedes": r.supersedes or [], "order_index": r.order_index, } for r in rows ] def _usage_event_rows(rows) -> list[dict]: return [ { "user_id": r.user_id, "note_id": r.note_id, "event": r.event, "source": r.source, "created_at": r.created_at.isoformat() if r.created_at else None, } for r in rows ] def _repo_binding_rows(rows) -> list[dict]: return [ {"user_id": r.user_id, "project_id": r.project_id, "repo_key": r.repo_key} for r in rows ] # --------------------------------------------------------------------------- # Export # --------------------------------------------------------------------------- async def export_full_backup() -> dict: """Export all data as a version-5 JSON backup.""" async with async_session() as session: users = (await session.execute(select(User))).scalars().all() projects = (await session.execute(select(Project))).scalars().all() milestones = (await session.execute(select(Milestone))).scalars().all() notes = (await session.execute(select(Note))).scalars().all() task_logs = (await session.execute(select(TaskLog))).scalars().all() note_drafts = (await session.execute(select(NoteDraft))).scalars().all() note_versions = (await session.execute( select(NoteVersion).order_by(NoteVersion.note_id, NoteVersion.id) )).scalars().all() settings = (await session.execute(select(Setting))).scalars().all() systems = (await session.execute(select(System))).scalars().all() record_systems = (await session.execute(select(RecordSystem))).scalars().all() supersessions = ( await session.execute(select(NoteSupersession)) ).scalars().all() # Parent-first, so a restore can resolve parent_id as it goes rather # than needing a second pass — the self-FK is the only ordering # constraint in this payload. design_systems = (await session.execute( select(DesignSystem).order_by(DesignSystem.parent_id.nullsfirst(), DesignSystem.id) )).scalars().all() design_tokens = (await session.execute(select(DesignToken))).scalars().all() usage_events = (await session.execute(select(NoteUsageEvent))).scalars().all() repo_bindings = (await session.execute(select(RepoBinding))).scalars().all() rulebooks = (await session.execute(select(Rulebook))).scalars().all() topics = (await session.execute(select(RulebookTopic))).scalars().all() rules = (await session.execute(select(Rule))).scalars().all() subscriptions = (await session.execute( select(project_rulebook_subscriptions) )).all() rule_suppressions = (await session.execute( select(project_rule_suppressions) )).all() topic_suppressions = (await session.execute( select(project_topic_suppressions) )).all() return { "version": BACKUP_VERSION, "scope": "full", "exported_at": datetime.now(timezone.utc).isoformat(), "_security_notice": ( "This backup contains hashed passwords. " "Store it securely and restrict access." ), "_not_included": _NOT_INCLUDED, "users": [ { "id": u.id, "username": u.username, "email": u.email, "password_hash": u.password_hash, "oauth_sub": u.oauth_sub, "role": u.role, "session_version": u.session_version, "created_at": u.created_at.isoformat(), } for u in users ], "projects": [ { "id": p.id, "user_id": p.user_id, "title": p.title, "description": p.description, "goal": p.goal, "status": p.status, "color": p.color, "created_at": p.created_at.isoformat(), "updated_at": p.updated_at.isoformat(), } for p in projects ], "milestones": [ { "id": m.id, "user_id": m.user_id, "project_id": m.project_id, "title": m.title, "description": m.description, "status": m.status, "order_index": m.order_index, "created_at": m.created_at.isoformat(), "updated_at": m.updated_at.isoformat(), } for m in milestones ], "notes": [ { "id": n.id, "user_id": n.user_id, "title": n.title, "body": n.body, "tags": n.tags or [], "parent_id": n.parent_id, "project_id": n.project_id, "milestone_id": n.milestone_id, "status": n.status, "priority": n.priority, "due_date": n.due_date.isoformat() if n.due_date else None, "created_at": n.created_at.isoformat(), "updated_at": n.updated_at.isoformat(), } for n in notes ], "task_logs": [ { "id": tl.id, "user_id": tl.user_id, "task_id": tl.task_id, "content": tl.content, "duration_minutes": tl.duration_minutes, "created_at": tl.created_at.isoformat(), "updated_at": tl.updated_at.isoformat(), } for tl in task_logs ], "note_drafts": [ { "id": nd.id, "user_id": nd.user_id, "note_id": nd.note_id, "proposed_body": nd.proposed_body, "original_body": nd.original_body, "instruction": nd.instruction, "scope": nd.scope, "created_at": nd.created_at.isoformat(), "updated_at": nd.updated_at.isoformat(), } for nd in note_drafts ], "note_versions": [ { "id": nv.id, "user_id": nv.user_id, "note_id": nv.note_id, "title": nv.title, "body": nv.body, "tags": nv.tags or [], "pin_kind": nv.pin_kind, "pin_label": nv.pin_label, "created_at": nv.created_at.isoformat(), } for nv in note_versions ], "settings": [ {"user_id": s.user_id, "key": s.key, "value": s.value} for s in settings ], "rulebooks": [ { "id": rb.id, "owner_user_id": rb.owner_user_id, "title": rb.title, "description": rb.description, "always_on": rb.always_on, "created_at": rb.created_at.isoformat(), "updated_at": rb.updated_at.isoformat(), } for rb in rulebooks ], "rulebook_topics": [ { "id": t.id, "rulebook_id": t.rulebook_id, "title": t.title, "description": t.description, "order_index": t.order_index, "created_at": t.created_at.isoformat(), "updated_at": t.updated_at.isoformat(), } for t in topics ], "rules": [ { "id": r.id, "topic_id": r.topic_id, "project_id": r.project_id, "title": r.title, "statement": r.statement, "why": r.why, "how_to_apply": r.how_to_apply, "order_index": r.order_index, "created_at": r.created_at.isoformat(), "updated_at": r.updated_at.isoformat(), } for r in rules ], "rulebook_subscriptions": _subscription_rows(subscriptions), "rule_suppressions": _rule_suppression_rows(rule_suppressions), "topic_suppressions": _topic_suppression_rows(topic_suppressions), "systems": _system_rows(systems), "record_systems": _record_system_rows(record_systems), "design_systems": _design_system_rows(design_systems), "design_tokens": _design_token_rows(design_tokens), "note_usage_events": _usage_event_rows(usage_events), "repo_bindings": _repo_binding_rows(repo_bindings), "note_supersessions": _note_supersession_rows(supersessions), } async def export_user_backup(user_id: int) -> dict: """Export a single user's data as a version-5 JSON backup.""" async with async_session() as session: user = await session.get(User, user_id) projects = (await session.execute( select(Project).where(Project.user_id == user_id) )).scalars().all() project_ids = [p.id for p in projects] milestones = (await session.execute( select(Milestone).where(Milestone.user_id == user_id) )).scalars().all() notes = (await session.execute( select(Note).where(Note.user_id == user_id) )).scalars().all() task_logs = (await session.execute( select(TaskLog).where(TaskLog.user_id == user_id) )).scalars().all() note_drafts = (await session.execute( select(NoteDraft).where(NoteDraft.user_id == user_id) )).scalars().all() note_versions = (await session.execute( select(NoteVersion).where(NoteVersion.user_id == user_id) .order_by(NoteVersion.note_id, NoteVersion.id) )).scalars().all() settings = (await session.execute( select(Setting).where(Setting.user_id == user_id) )).scalars().all() systems = (await session.execute( select(System).where(System.user_id == user_id) )).scalars().all() system_ids = [sy.id for sy in systems] note_ids = [n.id for n in notes] # Scoped by the user's SYSTEMS, not their notes: a shared note carrying # this user's system tag belongs in their backup, and a note of theirs # tagged with someone else's system does not — that row is the other # user's to keep. record_systems = (await session.execute( select(RecordSystem).where(RecordSystem.system_id.in_(system_ids)) )).scalars().all() if system_ids else [] # BOTH ends must be this user's notes. A claim spanning out to someone # else's record cannot be restored into a single-user import — the far # id would not be in the map — so carrying it would export a row that # silently vanishes on the way back in. Whole-instance backups have no # such problem and take every row. supersessions = (await session.execute( select(NoteSupersession).where( NoteSupersession.superseder_id.in_(note_ids), NoteSupersession.superseded_id.in_(note_ids), ) )).scalars().all() if note_ids else [] design_systems = (await session.execute( select(DesignSystem).where(DesignSystem.owner_user_id == user_id) .order_by(DesignSystem.parent_id.nullsfirst(), DesignSystem.id) )).scalars().all() ds_ids = [d.id for d in design_systems] design_tokens = (await session.execute( select(DesignToken).where(DesignToken.design_system_id.in_(ds_ids)) )).scalars().all() if ds_ids else [] usage_events = (await session.execute( select(NoteUsageEvent).where(NoteUsageEvent.note_id.in_(note_ids)) )).scalars().all() if note_ids else [] repo_bindings = (await session.execute( select(RepoBinding).where(RepoBinding.user_id == user_id) )).scalars().all() rulebooks = (await session.execute( select(Rulebook).where(Rulebook.owner_user_id == user_id) )).scalars().all() rulebook_ids = [rb.id for rb in rulebooks] topics = (await session.execute( select(RulebookTopic).where(RulebookTopic.rulebook_id.in_(rulebook_ids)) )).scalars().all() if rulebook_ids else [] topic_ids = [t.id for t in topics] # Rules owned by the user = rules in the user's topics OR project-rules # on the user's projects. rule_filters = [] if topic_ids: rule_filters.append(Rule.topic_id.in_(topic_ids)) if project_ids: rule_filters.append(Rule.project_id.in_(project_ids)) rules = (await session.execute( select(Rule).where(or_(*rule_filters)) )).scalars().all() if rule_filters else [] if project_ids: subscriptions = (await session.execute( select(project_rulebook_subscriptions).where( project_rulebook_subscriptions.c.project_id.in_(project_ids) ) )).all() rule_suppressions = (await session.execute( select(project_rule_suppressions).where( project_rule_suppressions.c.project_id.in_(project_ids) ) )).all() topic_suppressions = (await session.execute( select(project_topic_suppressions).where( project_topic_suppressions.c.project_id.in_(project_ids) ) )).all() else: subscriptions = rule_suppressions = topic_suppressions = [] return { "version": BACKUP_VERSION, "scope": "user", "exported_at": datetime.now(timezone.utc).isoformat(), "_not_included": _NOT_INCLUDED, "user": { "id": user.id, "username": user.username, "email": user.email, "role": user.role, "created_at": user.created_at.isoformat(), } if user else None, "projects": [ { "id": p.id, "user_id": p.user_id, "title": p.title, "description": p.description, "goal": p.goal, "status": p.status, "color": p.color, "created_at": p.created_at.isoformat(), "updated_at": p.updated_at.isoformat(), } for p in projects ], "milestones": [ { "id": m.id, "user_id": m.user_id, "project_id": m.project_id, "title": m.title, "description": m.description, "status": m.status, "order_index": m.order_index, "created_at": m.created_at.isoformat(), "updated_at": m.updated_at.isoformat(), } for m in milestones ], "notes": [ { "id": n.id, "user_id": n.user_id, "title": n.title, "body": n.body, "tags": n.tags or [], "parent_id": n.parent_id, "project_id": n.project_id, "milestone_id": n.milestone_id, "status": n.status, "priority": n.priority, "due_date": n.due_date.isoformat() if n.due_date else None, "created_at": n.created_at.isoformat(), "updated_at": n.updated_at.isoformat(), } for n in notes ], "task_logs": [ { "id": tl.id, "user_id": tl.user_id, "task_id": tl.task_id, "content": tl.content, "duration_minutes": tl.duration_minutes, "created_at": tl.created_at.isoformat(), "updated_at": tl.updated_at.isoformat(), } for tl in task_logs ], "note_drafts": [ { "id": nd.id, "user_id": nd.user_id, "note_id": nd.note_id, "proposed_body": nd.proposed_body, "original_body": nd.original_body, "instruction": nd.instruction, "scope": nd.scope, "created_at": nd.created_at.isoformat(), "updated_at": nd.updated_at.isoformat(), } for nd in note_drafts ], "note_versions": [ { "id": nv.id, "user_id": nv.user_id, "note_id": nv.note_id, "title": nv.title, "body": nv.body, "tags": nv.tags or [], "pin_kind": nv.pin_kind, "pin_label": nv.pin_label, "created_at": nv.created_at.isoformat(), } for nv in note_versions ], "settings": [ {"user_id": s.user_id, "key": s.key, "value": s.value} for s in settings ], "rulebooks": [ { "id": rb.id, "owner_user_id": rb.owner_user_id, "title": rb.title, "description": rb.description, "always_on": rb.always_on, "created_at": rb.created_at.isoformat(), "updated_at": rb.updated_at.isoformat(), } for rb in rulebooks ], "rulebook_topics": [ { "id": t.id, "rulebook_id": t.rulebook_id, "title": t.title, "description": t.description, "order_index": t.order_index, "created_at": t.created_at.isoformat(), "updated_at": t.updated_at.isoformat(), } for t in topics ], "rules": [ { "id": r.id, "topic_id": r.topic_id, "project_id": r.project_id, "title": r.title, "statement": r.statement, "why": r.why, "how_to_apply": r.how_to_apply, "order_index": r.order_index, "created_at": r.created_at.isoformat(), "updated_at": r.updated_at.isoformat(), } for r in rules ], "rulebook_subscriptions": _subscription_rows(subscriptions), "rule_suppressions": _rule_suppression_rows(rule_suppressions), "topic_suppressions": _topic_suppression_rows(topic_suppressions), "systems": _system_rows(systems), "record_systems": _record_system_rows(record_systems), "design_systems": _design_system_rows(design_systems), "design_tokens": _design_token_rows(design_tokens), "note_usage_events": _usage_event_rows(usage_events), "repo_bindings": _repo_binding_rows(repo_bindings), "note_supersessions": _note_supersession_rows(supersessions), } # --------------------------------------------------------------------------- # Restore # --------------------------------------------------------------------------- async def restore_full_backup(data: dict) -> dict: """Restore from backup JSON. Dispatches by version.""" version = data.get("version", 1) if version == 1: return await _restore_v1(data) # v2 and v3 share one path; v3-only sections are guarded by data.get so a # v2 payload (without them) restores cleanly. return await _restore_v2(data) async def _restore_v1(data: dict) -> dict: """Restore legacy v1 backup (original format). Pre-pivot v1 backups included conversations + messages; those are skipped during restore now that the chat subsystem is gone. """ stats = {"users": 0, "notes": 0, "settings": 0} async with async_session() as session: user_id_map: dict[int, int] = {} for u_data in data.get("users", []): old_id = u_data["id"] user = User( username=u_data["username"], email=u_data.get("email"), password_hash=u_data["password_hash"], role=u_data.get("role", "user"), created_at=_dt(u_data.get("created_at")), ) session.add(user) await session.flush() user_id_map[old_id] = user.id stats["users"] += 1 note_id_map: dict[int, int] = {} for n_data in data.get("notes", []): old_id = n_data.get("id") mapped_user_id = user_id_map.get(n_data.get("user_id", 0)) if mapped_user_id is None: continue note = Note( user_id=mapped_user_id, title=n_data.get("title", ""), body=n_data.get("body", ""), tags=n_data.get("tags", []), parent_id=None, # patched below status=n_data.get("status"), priority=n_data.get("priority"), due_date=_d(n_data.get("due_date")), created_at=_dt(n_data.get("created_at")), updated_at=_dt(n_data.get("updated_at")), ) session.add(note) await session.flush() if old_id is not None: note_id_map[old_id] = note.id stats["notes"] += 1 # Patch parent_id now that all notes have new IDs for n_data in data.get("notes", []): old_id = n_data.get("id") old_parent = n_data.get("parent_id") if old_id and old_parent and old_id in note_id_map and old_parent in note_id_map: note_row = await session.get(Note, note_id_map[old_id]) if note_row: note_row.parent_id = note_id_map[old_parent] for s_data in data.get("settings", []): mapped_user_id = user_id_map.get(s_data.get("user_id", 0)) if mapped_user_id is None: continue session.add(Setting(user_id=mapped_user_id, key=s_data["key"], value=s_data.get("value", ""))) stats["settings"] += 1 await session.commit() logger.info("Restored v1 backup: %s", stats) return stats async def _restore_v2(data: dict) -> dict: """Restore v2/v3 backup with full FK re-mapping. Conversations, push subscriptions, and (as of v4) events in older backups are silently skipped — those subsystems were removed. v3+ sections (rulebooks/topics/rules/join-tables) are guarded by data.get so a v2 payload restores without them. """ stats: dict[str, int] = { "users": 0, "projects": 0, "milestones": 0, "notes": 0, "task_logs": 0, "note_drafts": 0, "note_versions": 0, "settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0, "rulebook_subscriptions": 0, "rule_suppressions": 0, "topic_suppressions": 0, "systems": 0, "record_systems": 0, "design_systems": 0, "design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0, "note_supersessions": 0, } async with async_session() as session: user_id_map: dict[int, int] = {} project_id_map: dict[int, int] = {} milestone_id_map: dict[int, int] = {} note_id_map: dict[int, int] = {} rulebook_id_map: dict[int, int] = {} topic_id_map: dict[int, int] = {} rule_id_map: dict[int, int] = {} # 1. Users for u_data in data.get("users", []): old_id = u_data["id"] user = User( username=u_data["username"], email=u_data.get("email"), password_hash=u_data.get("password_hash"), oauth_sub=u_data.get("oauth_sub"), role=u_data.get("role", "user"), session_version=u_data.get("session_version", 1), created_at=_dt(u_data.get("created_at")), ) session.add(user) await session.flush() user_id_map[old_id] = user.id stats["users"] += 1 # 2. Projects for p_data in data.get("projects", []): mapped_uid = user_id_map.get(p_data.get("user_id", 0)) if mapped_uid is None: continue proj = Project( user_id=mapped_uid, title=p_data.get("title", ""), description=p_data.get("description", ""), goal=p_data.get("goal", ""), status=p_data.get("status", "active"), color=p_data.get("color"), created_at=_dt(p_data.get("created_at")), updated_at=_dt(p_data.get("updated_at")), ) session.add(proj) await session.flush() project_id_map[p_data["id"]] = proj.id stats["projects"] += 1 # 3. Milestones for m_data in data.get("milestones", []): mapped_uid = user_id_map.get(m_data.get("user_id", 0)) mapped_pid = project_id_map.get(m_data.get("project_id", 0)) if mapped_uid is None or mapped_pid is None: continue ms = Milestone( user_id=mapped_uid, project_id=mapped_pid, title=m_data.get("title", ""), description=m_data.get("description"), status=m_data.get("status", "active"), order_index=m_data.get("order_index", 0), created_at=_dt(m_data.get("created_at")), updated_at=_dt(m_data.get("updated_at")), ) session.add(ms) await session.flush() milestone_id_map[m_data["id"]] = ms.id stats["milestones"] += 1 # 4a. Notes — first pass (no parent_id yet) notes_with_parents: list[tuple[int, int]] = [] # (new_note_id, old_parent_id) for n_data in data.get("notes", []): mapped_uid = user_id_map.get(n_data.get("user_id", 0)) if mapped_uid is None: continue note = Note( user_id=mapped_uid, title=n_data.get("title", ""), body=n_data.get("body", ""), tags=n_data.get("tags", []), parent_id=None, project_id=project_id_map.get(n_data["project_id"]) if n_data.get("project_id") else None, milestone_id=milestone_id_map.get(n_data["milestone_id"]) if n_data.get("milestone_id") else None, status=n_data.get("status"), priority=n_data.get("priority"), due_date=_d(n_data.get("due_date")), created_at=_dt(n_data.get("created_at")), updated_at=_dt(n_data.get("updated_at")), ) session.add(note) await session.flush() note_id_map[n_data["id"]] = note.id if n_data.get("parent_id"): notes_with_parents.append((note.id, n_data["parent_id"])) stats["notes"] += 1 # 4b. Patch parent_id for new_note_id, old_parent_id in notes_with_parents: new_parent_id = note_id_map.get(old_parent_id) if new_parent_id: note_row = await session.get(Note, new_note_id) if note_row: note_row.parent_id = new_parent_id # 5. TaskLogs for tl_data in data.get("task_logs", []): mapped_uid = user_id_map.get(tl_data.get("user_id", 0)) mapped_tid = note_id_map.get(tl_data.get("task_id", 0)) if mapped_uid is None or mapped_tid is None: continue tl = TaskLog( user_id=mapped_uid, task_id=mapped_tid, content=tl_data.get("content", ""), duration_minutes=tl_data.get("duration_minutes"), created_at=_dt(tl_data.get("created_at")), updated_at=_dt(tl_data.get("updated_at")), ) session.add(tl) stats["task_logs"] += 1 # 6. NoteDrafts for nd_data in data.get("note_drafts", []): mapped_uid = user_id_map.get(nd_data.get("user_id", 0)) mapped_nid = note_id_map.get(nd_data.get("note_id", 0)) if mapped_uid is None or mapped_nid is None: continue nd = NoteDraft( user_id=mapped_uid, note_id=mapped_nid, proposed_body=nd_data.get("proposed_body", ""), original_body=nd_data.get("original_body", ""), instruction=nd_data.get("instruction", ""), scope=nd_data.get("scope", "document"), created_at=_dt(nd_data.get("created_at")), updated_at=_dt(nd_data.get("updated_at")), ) session.add(nd) stats["note_drafts"] += 1 # 7. NoteVersions for nv_data in data.get("note_versions", []): mapped_uid = user_id_map.get(nv_data.get("user_id", 0)) mapped_nid = note_id_map.get(nv_data.get("note_id", 0)) if mapped_uid is None or mapped_nid is None: continue nv = NoteVersion( user_id=mapped_uid, note_id=mapped_nid, title=nv_data.get("title", ""), body=nv_data.get("body", ""), tags=nv_data.get("tags", []), pin_kind=nv_data.get("pin_kind"), pin_label=nv_data.get("pin_label"), created_at=_dt(nv_data.get("created_at")), ) session.add(nv) stats["note_versions"] += 1 # 8. Settings for s_data in data.get("settings", []): mapped_uid = user_id_map.get(s_data.get("user_id", 0)) if mapped_uid is None: continue session.add(Setting(user_id=mapped_uid, key=s_data["key"], value=s_data.get("value", ""))) stats["settings"] += 1 # 9. Rulebooks (v3) for rb_data in data.get("rulebooks", []): mapped_uid = user_id_map.get(rb_data.get("owner_user_id", 0)) if mapped_uid is None: continue rb = Rulebook( owner_user_id=mapped_uid, title=rb_data.get("title", ""), description=rb_data.get("description", ""), always_on=rb_data.get("always_on", False), created_at=_dt(rb_data.get("created_at")), updated_at=_dt(rb_data.get("updated_at")), ) session.add(rb) await session.flush() rulebook_id_map[rb_data["id"]] = rb.id stats["rulebooks"] += 1 # 10. Topics (v3) for t_data in data.get("rulebook_topics", []): mapped_rbid = rulebook_id_map.get(t_data.get("rulebook_id", 0)) if mapped_rbid is None: continue topic = RulebookTopic( rulebook_id=mapped_rbid, title=t_data.get("title", ""), description=t_data.get("description"), order_index=t_data.get("order_index", 0), created_at=_dt(t_data.get("created_at")), updated_at=_dt(t_data.get("updated_at")), ) session.add(topic) await session.flush() topic_id_map[t_data["id"]] = topic.id stats["rulebook_topics"] += 1 # 11. Rules (v3) — topic-rule (topic_id) XOR project-rule (project_id) for r_data in data.get("rules", []): mapped_topic = topic_id_map.get(r_data["topic_id"]) if r_data.get("topic_id") else None mapped_proj = project_id_map.get(r_data["project_id"]) if r_data.get("project_id") else None if mapped_topic is None and mapped_proj is None: continue # orphaned — its parent didn't restore rule = Rule( topic_id=mapped_topic, project_id=mapped_proj, title=r_data.get("title", ""), statement=r_data.get("statement", ""), why=r_data.get("why") or None, how_to_apply=r_data.get("how_to_apply") or None, order_index=r_data.get("order_index", 0), created_at=_dt(r_data.get("created_at")), updated_at=_dt(r_data.get("updated_at")), ) session.add(rule) await session.flush() rule_id_map[r_data["id"]] = rule.id stats["rules"] += 1 # 12. Rulebook subscriptions (v3 join table) for sub in data.get("rulebook_subscriptions", []): mapped_pid = project_id_map.get(sub.get("project_id", 0)) mapped_rbid = rulebook_id_map.get(sub.get("rulebook_id", 0)) if mapped_pid is None or mapped_rbid is None: continue await session.execute(project_rulebook_subscriptions.insert().values( project_id=mapped_pid, rulebook_id=mapped_rbid, )) stats["rulebook_subscriptions"] += 1 # 13. Rule suppressions (v3 join table) for sup in data.get("rule_suppressions", []): mapped_pid = project_id_map.get(sup.get("project_id", 0)) mapped_rid = rule_id_map.get(sup.get("rule_id", 0)) if mapped_pid is None or mapped_rid is None: continue await session.execute(project_rule_suppressions.insert().values( project_id=mapped_pid, rule_id=mapped_rid, )) stats["rule_suppressions"] += 1 # 14. Topic suppressions (v3 join table) for sup in data.get("topic_suppressions", []): mapped_pid = project_id_map.get(sup.get("project_id", 0)) mapped_tid = topic_id_map.get(sup.get("topic_id", 0)) if mapped_pid is None or mapped_tid is None: continue await session.execute(project_topic_suppressions.insert().values( project_id=mapped_pid, topic_id=mapped_tid, )) stats["topic_suppressions"] += 1 # --- v5 sections. Every one is data.get()-guarded, so a v2/v3/v4 # payload restores without them rather than failing on an absent key. # 15. Systems system_id_map: dict[int, int] = {} for sy_data in data.get("systems", []): mapped_uid = user_id_map.get(sy_data.get("user_id", 0)) mapped_pid = project_id_map.get(sy_data.get("project_id", 0)) if mapped_uid is None or mapped_pid is None: continue system = System( user_id=mapped_uid, project_id=mapped_pid, name=sy_data.get("name", ""), description=sy_data.get("description"), color=sy_data.get("color"), status=sy_data.get("status", "active"), order_index=sy_data.get("order_index", 0), ) session.add(system) await session.flush() system_id_map[sy_data["id"]] = system.id stats["systems"] += 1 # 16. Record↔system links for rs in data.get("record_systems", []): mapped_nid = note_id_map.get(rs.get("note_id", 0)) mapped_sid = system_id_map.get(rs.get("system_id", 0)) if mapped_nid is None or mapped_sid is None: continue session.add(RecordSystem(note_id=mapped_nid, system_id=mapped_sid)) stats["record_systems"] += 1 # 16b. Supersession claims. Guarded by `data.get` like every other # post-v2 section, so a v5 or older payload restores cleanly without it. # # Both ends must map. A claim is about a PAIR — half of one is not a # weaker claim, it is a dangling row pointing at whatever note happens # to hold that id next. for sup in data.get("note_supersessions", []): mapped_new = note_id_map.get(sup.get("superseder_id", 0)) mapped_old = note_id_map.get(sup.get("superseded_id", 0)) if mapped_new is None or mapped_old is None or mapped_new == mapped_old: continue session.add( NoteSupersession( superseder_id=mapped_new, superseded_id=mapped_old ) ) stats["note_supersessions"] += 1 # 17. Design systems. The export orders these parent-first, so a # parent's new id is always in the map by the time a child needs it — # no second pass, and a child whose parent is missing lands as a root # rather than failing the whole restore. design_system_id_map: dict[int, int] = {} for ds_data in data.get("design_systems", []): mapped_uid = user_id_map.get(ds_data.get("owner_user_id", 0)) if mapped_uid is None: continue design = DesignSystem( owner_user_id=mapped_uid, title=ds_data.get("title", ""), description=ds_data.get("description"), guidance=ds_data.get("guidance"), parent_id=design_system_id_map.get(ds_data.get("parent_id") or 0), ) session.add(design) await session.flush() design_system_id_map[ds_data["id"]] = design.id stats["design_systems"] += 1 # 18. Design tokens for t_data in data.get("design_tokens", []): mapped_dsid = design_system_id_map.get(t_data.get("design_system_id", 0)) if mapped_dsid is None: continue session.add(DesignToken( design_system_id=mapped_dsid, name=t_data.get("name", ""), value_by_mode=t_data.get("value_by_mode") or {}, group_name=t_data.get("group_name"), purpose=t_data.get("purpose"), rationale=t_data.get("rationale"), supersedes=t_data.get("supersedes") or [], order_index=t_data.get("order_index", 0), )) stats["design_tokens"] += 1 # 19. Usage events. Kept because pull-through is the evidence base for # whether recall works at all, and it is only ever accumulated — a # restore that dropped it would silently reset that measurement to zero # while everything still looked fine. for ev in data.get("note_usage_events", []): mapped_nid = note_id_map.get(ev.get("note_id", 0)) if mapped_nid is None: continue session.add(NoteUsageEvent( user_id=user_id_map.get(ev.get("user_id") or 0), note_id=mapped_nid, event=ev.get("event", ""), source=ev.get("source", ""), created_at=_dt(ev.get("created_at")), )) stats["note_usage_events"] += 1 # 20. Repo bindings — small, but losing them means every bound repo # quietly stops loading its project at session start. for rb_data in data.get("repo_bindings", []): mapped_uid = user_id_map.get(rb_data.get("user_id", 0)) mapped_pid = project_id_map.get(rb_data.get("project_id", 0)) if mapped_uid is None or mapped_pid is None: continue session.add(RepoBinding( user_id=mapped_uid, project_id=mapped_pid, repo_key=rb_data.get("repo_key", ""), )) stats["repo_bindings"] += 1 await session.commit() logger.info("Restored v2/v3 backup: %s", stats) return stats