CI & Build / Python lint (push) Successful in 6s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 42s
CI & Build / integration (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m36s
CI & Build / Build & push image (push) Successful in 36s
A project's inheritance becomes a decision, not a default (milestone 297).
- projects.inception (JSONB, NULL = undecided): {decided_at, decided_by, via
mcp|ui|legacy, choices {exclude_always_on_rulebooks, subscribe_rulebooks,
design_system_id, seed_systems}}; on to_dict.
- project_rulebook_exclusions: a project's opt-out of a whole always-on
rulebook — the sibling of the rule/topic suppressions, CASCADE both ways.
- services/inception.py (first cut): the vocabulary, validate_inception
(pure, all-or-nothing), normalize_choices, is_decided. Effects come in
step 3.
- Migration 0085 backfills every existing project via="legacy" with its
current standing (no exclusions, its subscriptions, its design_system_id,
no seed) so the ask fires only for projects created after this ships.
- Backup v10: rulebook_exclusions section; project rows carry inception and
design_system_id, restored in a post-pass once rulebooks/design systems are
mapped (design_system_id was not restored before — fixed in passing).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1203 lines
52 KiB
Python
1203 lines
52 KiB
Python
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.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse
|
|
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_exclusions,
|
|
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.
|
|
# v7 (2026-08) added code_shapes — the shape ledger (#2787). Classifications
|
|
# are judgment data worth carrying; a restore keeps a judgment only when its
|
|
# snippet target survives the id re-mapping, else the row rejoins the todo.
|
|
# v8 (2026-08) added code_shape_events — the ledger's history (#2793): what
|
|
# was used where, when, and why is not recomputable, so it travels.
|
|
# v9 (2026-08) added code_shape_uses — the ledger's consumption edges (#2870):
|
|
# judgment-grade edges (agent/audit/import) are operator records; mechanical
|
|
# ones (reference/hook) travel too, cheaply, and the next refresh refreshes them.
|
|
# v10 (2026-08) added projects.inception + project_rulebook_exclusions
|
|
# (milestone 297): the WHY a project inherits what it does, and its opt-outs.
|
|
# Bump when the serialized schema changes.
|
|
BACKUP_VERSION = 10
|
|
|
|
# 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", "project_rulebook_exclusions",
|
|
# 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",
|
|
# v7 (2026-08): the shape ledger (#2787); v8: its history (#2793).
|
|
"code_shapes", "code_shape_events", "code_shape_uses",
|
|
]
|
|
|
|
# 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",
|
|
# Sensitive credentials, same reasoning as api_keys: a backup that carries
|
|
# forge tokens is a token-exfiltration file. Users re-add connections
|
|
# after a restore; the per-project pin (projects.forge_connection_id) is
|
|
# deliberately not exported either, so restored projects fall back to
|
|
# keyring-by-host resolution — the documented unpinned behavior (#2778).
|
|
"forge_connections",
|
|
]
|
|
|
|
|
|
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]
|
|
|
|
|
|
def _rulebook_exclusion_rows(rows) -> list[dict]:
|
|
return [{"project_id": r.project_id, "rulebook_id": r.rulebook_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 _code_shape_rows(rows) -> list[dict]:
|
|
return [r.to_dict() for r in rows]
|
|
|
|
|
|
def _code_shape_event_rows(rows) -> list[dict]:
|
|
return [r.to_dict() for r in rows]
|
|
|
|
|
|
def _code_shape_use_rows(rows) -> list[dict]:
|
|
return [r.to_dict() 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
|
|
]
|
|
|
|
|
|
# Row builders for the sections both exporters carry. Pure, like the join-table
|
|
# helpers above; the full and per-user exports used to restate every one of
|
|
# these comprehensions side by side, and a column added to one and not the
|
|
# other is a backup that silently drops it (#2293's shape, one layer down).
|
|
|
|
def _user_rows(rows) -> list[dict]:
|
|
return [
|
|
{
|
|
"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 rows
|
|
]
|
|
|
|
|
|
def _project_rows(rows) -> list[dict]:
|
|
return [
|
|
{
|
|
"id": p.id, "user_id": p.user_id, "title": p.title,
|
|
"description": p.description, "goal": p.goal, "status": p.status,
|
|
"color": p.color,
|
|
"design_system_id": p.design_system_id,
|
|
"inception": p.inception,
|
|
"created_at": p.created_at.isoformat(),
|
|
"updated_at": p.updated_at.isoformat(),
|
|
}
|
|
for p in rows
|
|
]
|
|
|
|
|
|
def _milestone_rows(rows) -> list[dict]:
|
|
return [
|
|
{
|
|
"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 rows
|
|
]
|
|
|
|
|
|
def _note_rows(rows) -> list[dict]:
|
|
return [
|
|
{
|
|
"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 rows
|
|
]
|
|
|
|
|
|
def _task_log_rows(rows) -> list[dict]:
|
|
return [
|
|
{
|
|
"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 rows
|
|
]
|
|
|
|
|
|
def _note_draft_rows(rows) -> list[dict]:
|
|
return [
|
|
{
|
|
"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 rows
|
|
]
|
|
|
|
|
|
def _note_version_rows(rows) -> list[dict]:
|
|
return [
|
|
{
|
|
"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 rows
|
|
]
|
|
|
|
|
|
def _setting_rows(rows) -> list[dict]:
|
|
return [{"user_id": s.user_id, "key": s.key, "value": s.value} for s in rows]
|
|
|
|
|
|
def _rulebook_rows(rows) -> list[dict]:
|
|
return [
|
|
{
|
|
"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 rows
|
|
]
|
|
|
|
|
|
def _topic_rows(rows) -> list[dict]:
|
|
return [
|
|
{
|
|
"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 rows
|
|
]
|
|
|
|
|
|
def _rule_rows(rows) -> list[dict]:
|
|
return [
|
|
{
|
|
"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 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()
|
|
code_shapes = (await session.execute(select(CodeShape))).scalars().all()
|
|
code_shape_events = (await session.execute(
|
|
select(CodeShapeEvent).order_by(CodeShapeEvent.at, CodeShapeEvent.id)
|
|
)).scalars().all()
|
|
code_shape_uses = (await session.execute(
|
|
select(CodeShapeUse).order_by(CodeShapeUse.shape_id, CodeShapeUse.snippet_id)
|
|
)).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()
|
|
rulebook_exclusions = (await session.execute(
|
|
select(project_rulebook_exclusions)
|
|
)).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": _user_rows(users),
|
|
"projects": _project_rows(projects),
|
|
"milestones": _milestone_rows(milestones),
|
|
"notes": _note_rows(notes),
|
|
"task_logs": _task_log_rows(task_logs),
|
|
"note_drafts": _note_draft_rows(note_drafts),
|
|
"note_versions": _note_version_rows(note_versions),
|
|
"settings": _setting_rows(settings),
|
|
"rulebooks": _rulebook_rows(rulebooks),
|
|
"rulebook_topics": _topic_rows(topics),
|
|
"rules": _rule_rows(rules),
|
|
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
|
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
|
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
|
"rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions),
|
|
"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),
|
|
"code_shapes": _code_shape_rows(code_shapes),
|
|
"code_shape_events": _code_shape_event_rows(code_shape_events),
|
|
"code_shape_uses": _code_shape_use_rows(code_shape_uses),
|
|
}
|
|
|
|
|
|
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()
|
|
# The ledger has no user_id of its own — rows belong to the project
|
|
# they account for, so a user's export carries their projects' rows.
|
|
code_shapes = (await session.execute(
|
|
select(CodeShape).where(CodeShape.project_id.in_(project_ids))
|
|
)).scalars().all() if project_ids else []
|
|
code_shape_events = (await session.execute(
|
|
select(CodeShapeEvent).where(CodeShapeEvent.project_id.in_(project_ids))
|
|
.order_by(CodeShapeEvent.at, CodeShapeEvent.id)
|
|
)).scalars().all() if project_ids else []
|
|
code_shape_uses = (await session.execute(
|
|
select(CodeShapeUse).join(CodeShape, CodeShape.id == CodeShapeUse.shape_id)
|
|
.where(CodeShape.project_id.in_(project_ids))
|
|
.order_by(CodeShapeUse.shape_id, CodeShapeUse.snippet_id)
|
|
)).scalars().all() if project_ids else []
|
|
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()
|
|
rulebook_exclusions = (await session.execute(
|
|
select(project_rulebook_exclusions).where(
|
|
project_rulebook_exclusions.c.project_id.in_(project_ids)
|
|
)
|
|
)).all()
|
|
else:
|
|
subscriptions = rule_suppressions = topic_suppressions = rulebook_exclusions = []
|
|
|
|
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": _project_rows(projects),
|
|
"milestones": _milestone_rows(milestones),
|
|
"notes": _note_rows(notes),
|
|
"task_logs": _task_log_rows(task_logs),
|
|
"note_drafts": _note_draft_rows(note_drafts),
|
|
"note_versions": _note_version_rows(note_versions),
|
|
"settings": _setting_rows(settings),
|
|
"rulebooks": _rulebook_rows(rulebooks),
|
|
"rulebook_topics": _topic_rows(topics),
|
|
"rules": _rule_rows(rules),
|
|
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
|
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
|
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
|
"rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions),
|
|
"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),
|
|
"code_shapes": _code_shape_rows(code_shapes),
|
|
"code_shape_events": _code_shape_event_rows(code_shape_events),
|
|
"code_shape_uses": _code_shape_use_rows(code_shape_uses),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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, "rulebook_exclusions": 0,
|
|
"systems": 0, "record_systems": 0, "design_systems": 0,
|
|
"design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0,
|
|
"note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0,
|
|
"code_shape_uses": 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
|
|
|
|
# 14b. Always-on rulebook exclusions (v10, milestone 297)
|
|
for exc in data.get("rulebook_exclusions", []):
|
|
mapped_pid = project_id_map.get(exc.get("project_id", 0))
|
|
mapped_rbid = rulebook_id_map.get(exc.get("rulebook_id", 0))
|
|
if mapped_pid is None or mapped_rbid is None:
|
|
continue
|
|
await session.execute(project_rulebook_exclusions.insert().values(
|
|
project_id=mapped_pid, rulebook_id=mapped_rbid,
|
|
))
|
|
stats["rulebook_exclusions"] += 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
|
|
|
|
# 21. Code shapes (v7, #2787) — the ledger's classifications are
|
|
# judgments worth carrying. A judgment whose snippet target didn't
|
|
# survive the re-mapping (canonical/instance/variant with a gone
|
|
# snippet) is downgraded to unclassified so it rejoins the todo
|
|
# honestly instead of dangling; exempt needs no target and keeps.
|
|
shape_id_map: dict[int, int] = {}
|
|
for cs_data in data.get("code_shapes", []):
|
|
mapped_pid = project_id_map.get(cs_data.get("project_id", 0))
|
|
if mapped_pid is None:
|
|
continue
|
|
status = cs_data.get("status", "unclassified")
|
|
mapped_sid = note_id_map.get(cs_data.get("snippet_id") or 0)
|
|
classified_by = cs_data.get("classified_by")
|
|
classified_at = cs_data.get("classified_at")
|
|
if status in ("canonical", "instance", "variant") and mapped_sid is None:
|
|
status = "unclassified"
|
|
classified_by = None
|
|
classified_at = None
|
|
shape = CodeShape(
|
|
project_id=mapped_pid,
|
|
repo_key=cs_data.get("repo_key", ""),
|
|
path=cs_data.get("path", ""),
|
|
symbol=cs_data.get("symbol", ""),
|
|
kind=cs_data.get("kind", "sym"),
|
|
status=status,
|
|
snippet_id=mapped_sid,
|
|
reason=cs_data.get("reason"),
|
|
classified_by=classified_by,
|
|
classified_at=_dt(classified_at) if classified_at else None,
|
|
first_seen_commit=cs_data.get("first_seen_commit", ""),
|
|
last_seen_commit=cs_data.get("last_seen_commit", ""),
|
|
vanished_at=_dt(cs_data["vanished_at"]) if cs_data.get("vanished_at") else None,
|
|
# Fingerprints restore; proposals (#2792) deliberately do not —
|
|
# they are mechanical, and the next refresh recomputes them
|
|
# against the restored snippet ids.
|
|
signature=cs_data.get("signature", ""),
|
|
body_sha=cs_data.get("body_sha", ""),
|
|
classified_sha=cs_data.get("classified_sha", ""),
|
|
created_at=_dt(cs_data.get("created_at")),
|
|
updated_at=_dt(cs_data.get("updated_at")),
|
|
)
|
|
session.add(shape)
|
|
await session.flush()
|
|
if cs_data.get("id"):
|
|
shape_id_map[int(cs_data["id"])] = shape.id
|
|
stats["code_shapes"] += 1
|
|
|
|
# v8: the ledger's history rides its shapes. snippet_id is kept as
|
|
# the history's own claim (FK-free by design) but re-mapped when the
|
|
# snippet survived, so a restored timeline points at restored records.
|
|
for ev in data.get("code_shape_events", []):
|
|
new_shape_id = shape_id_map.get(ev.get("shape_id") or 0)
|
|
mapped_pid = project_id_map.get(ev.get("project_id", 0))
|
|
if new_shape_id is None or mapped_pid is None:
|
|
continue
|
|
old_sid = ev.get("snippet_id")
|
|
session.add(CodeShapeEvent(
|
|
shape_id=new_shape_id,
|
|
project_id=mapped_pid,
|
|
path=ev.get("path", ""),
|
|
symbol=ev.get("symbol", ""),
|
|
kind=ev.get("kind", "sym"),
|
|
event=ev.get("event", "classified"),
|
|
status=ev.get("status"),
|
|
snippet_id=note_id_map.get(old_sid, old_sid) if old_sid else None,
|
|
classified_by=ev.get("classified_by"),
|
|
reason=ev.get("reason"),
|
|
commit=ev.get("commit", ""),
|
|
at=_dt(ev.get("at")),
|
|
))
|
|
stats["code_shape_events"] += 1
|
|
|
|
# v9: consumption edges (#2870) ride their shape AND their snippet —
|
|
# both ends must have survived, or the edge is no longer a fact.
|
|
for use in data.get("code_shape_uses", []):
|
|
new_shape_id = shape_id_map.get(use.get("shape_id") or 0)
|
|
new_sid = note_id_map.get(use.get("snippet_id") or 0)
|
|
if new_shape_id is None or new_sid is None:
|
|
continue
|
|
session.add(CodeShapeUse(
|
|
shape_id=new_shape_id, snippet_id=new_sid,
|
|
basis=use.get("basis", "import"), evidence=use.get("evidence"),
|
|
created_at=_dt(use.get("created_at")),
|
|
))
|
|
stats["code_shape_uses"] += 1
|
|
|
|
# v10: a project's design-system pointer and its inception record ride
|
|
# the project but point at design systems and rulebooks restored AFTER
|
|
# it — so they are written last, with ids re-mapped. An id that did
|
|
# not survive drops out of the record rather than dangling.
|
|
for p_data in data.get("projects", []):
|
|
new_pid = project_id_map.get(p_data.get("id") or 0)
|
|
if new_pid is None:
|
|
continue
|
|
proj = await session.get(Project, new_pid)
|
|
if proj is None:
|
|
continue
|
|
old_ds = p_data.get("design_system_id")
|
|
if old_ds:
|
|
proj.design_system_id = design_system_id_map.get(old_ds)
|
|
inception = p_data.get("inception")
|
|
if isinstance(inception, dict):
|
|
choices = dict(inception.get("choices") or {})
|
|
choices["exclude_always_on_rulebooks"] = [
|
|
rulebook_id_map[i] for i in choices.get("exclude_always_on_rulebooks") or []
|
|
if i in rulebook_id_map
|
|
]
|
|
choices["subscribe_rulebooks"] = [
|
|
rulebook_id_map[i] for i in choices.get("subscribe_rulebooks") or []
|
|
if i in rulebook_id_map
|
|
]
|
|
ds = choices.get("design_system_id")
|
|
choices["design_system_id"] = design_system_id_map.get(ds) if ds else None
|
|
proj.inception = {**inception, "choices": choices}
|
|
|
|
await session.commit()
|
|
|
|
logger.info("Restored v2/v3 backup: %s", stats)
|
|
return stats
|