CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 58s
CI & Build / Python tests (push) Successful in 1m38s
CI & Build / Build & push image (push) Successful in 27s
Step 2 of #4251. A System's `description` is a charter — several hundred words saying what belongs in that area and what does not — and it is the answer to "which part of this codebase does X live in". There was no semantic path to one: `list_systems` enumerates, and `search(system_id=…)` uses a System as a FILTER over notes. So a System could narrow a search and could never be the answer to one, and an agent asking where a record belonged had to read every charter or guess. ITS OWN SEARCH, not a `content_type` over notes, for the reason note 3163 gives about milestones: the row could be shared, the search cannot. A charter competing with the whole note corpus for one top-k is outranked by the records filed under it — the right answer crowded out by its own contents — and "where does this belong?" is a different question from "what prior art is there?", which a caller asking one should not have to read past answers to. So `system_embeddings` (0107) joins note_, rule_ and milestone_embeddings as the fourth sibling, with `system_document`, `upsert_system_embedding`, `semantic_search_systems`, a startup backfill and `search(content_type= "system")`. Scoped like milestones: with a project_id, that project's Systems if the caller can read the project (rule 78); without one, the caller's own. Archived Systems are excluded — an archived area is one the operator has said is no longer where things go, which is exactly the question being asked. `system_document` is the plainest of the four shapes on purpose. A charter is already written as the thing this search has to match, in the words someone asking would use — so there is no trigger to synthesise as `rule_document` must, and no second record to gather as `task_document` must. The stored charter IS the sharp document, the way a snippet's is. `color`, `status` and `order_index` stay out: presentation and bookkeeping, and a vector carrying them would be answering a question nobody asks of a charter. The search publishes `report["best_chunk"]` from the start rather than being retrofitted, which is what #4251 asked of any fourth search. It matters more here than anywhere: a charter runs long and a result shows its NAME, so a match on the paragraph that actually decides where a record belongs would otherwise be previewed by two words that cannot say. The id that comes back is the one `system_id`, `system_ids` and `list_system_records` already take, so the answer to "where does this belong?" is directly usable as "show me what is there" and as "file it here". `embed_system` sits beside `notes.embed_note` at the service for #2056's reason — every door gets it by construction. Not called on delete: that is a soft delete and the search joins through `System`, so the vectors are already unreachable, and leaving them means a restore is findable again immediately. `system_embeddings` is declared in backup's `_NOT_INCLUDED` as derived, beside its three siblings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
2058 lines
89 KiB
Python
2058 lines
89 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.rule_version import RuleVersion
|
|
from scribe.models.design_system import DesignSystem, DesignToken
|
|
from scribe.models.note_usage import NoteUsageEvent
|
|
from scribe.models.rule_usage import RuleUsageEvent
|
|
from scribe.models.retrieval_tuning import RetrievalTuningEvent
|
|
from scribe.models.canonical_system import CanonicalSystem
|
|
from scribe.models.rulebook import RuleRelation, rule_systems as rule_systems_t
|
|
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,
|
|
)
|
|
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.
|
|
# v11 (2026-08) added the note verification trio — notes.verify_with /
|
|
# expires_when / verified_at (milestone 317).
|
|
# v12 (2026-08) closed #3182: the nine Note columns that had been missing for
|
|
# years (note_type, task_kind, arose_from_id, data, description, the
|
|
# recurrence pair, started_at, completed_at), plus milestones.body — which IS
|
|
# the plan — and repo_bindings.ref. Until v12 a restore reported success and
|
|
# handed back a corpus with every snippet and process flattened into a plain
|
|
# note, every issue and spike into `work`, and every plan reduced to a title.
|
|
# _COLUMN_EXCLUSIONS and its guard landed with it, so the next such column
|
|
# fails the build instead.
|
|
# v13 (2026-08) added rule_versions — a rule's edit history (milestone 323).
|
|
# v14 (2026-09) added rule_usage_events — the rule twin of note_usage_events
|
|
# (milestone 333). Carrying it is the WHOLE REASON the table is separate: the
|
|
# note importer maps note_id through note_id_map, so a rule id parked there
|
|
# would restore attached to whatever note took that number.
|
|
# v15 (2026-09) dropped rulebook_subscriptions / rule_suppressions /
|
|
# topic_suppressions with their tables (milestone 414): a rule's scope is its
|
|
# home now. Older archives carrying those sections still restore — the keys are
|
|
# simply not read — as do the subscribe_rulebooks inception choices they hold.
|
|
# v16 (2026-09) added retrieval_tuning_events (milestone 416): the REASON each
|
|
# retrieval floor and budget is where it is. `settings` already carried the
|
|
# numbers, so leaving this behind would restore six moved dials with the
|
|
# argument for them silently dropped — and from this step on those dials are
|
|
# moved by the model, which is exactly the case where the operator needs the
|
|
# argument to review.
|
|
# v17 (2026-09) added retrieval_tuning_events.embedding_model / shape_version
|
|
# (milestone 416 step 6): a floor is a distance in ONE embedding model's
|
|
# geometry over documents cut one particular way, so the number alone cannot
|
|
# say whether it still measures anything. Both travel NULLABLE and unfilled —
|
|
# a row written before the stamp existed restores unstamped, because inventing
|
|
# the model it was measured under would turn "unknown" into a stated fact.
|
|
# Bump when the serialized schema changes.
|
|
BACKUP_VERSION = 17
|
|
|
|
# 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",
|
|
# 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",
|
|
# v9 (2026-08): the global area catalog (milestone 307). Global, not
|
|
# user-scoped, so it rides in EVERY export — including a single-user
|
|
# one, whose Systems would otherwise restore unmapped.
|
|
"canonical_systems",
|
|
# v10 (2026-08): a rule's area tag and its typed edges (milestone 307).
|
|
"rule_systems", "rule_relations",
|
|
# v13 (2026-08): a rule's edit history (milestone 323). note_versions has
|
|
# always travelled; its sibling has no excuse not to.
|
|
"rule_versions",
|
|
# v14 (2026-09): rule usage telemetry (milestone 333). Same argument
|
|
# note_usage_events makes for itself — pull-through is only ever
|
|
# accumulated, so a restore that dropped it would silently reset the
|
|
# measurement to zero while everything still looked fine.
|
|
"rule_usage_events",
|
|
# v16 (2026-09): why each retrieval floor and budget is where it is
|
|
# (milestone 416). The values live in `settings` and already travelled; the
|
|
# argument for them had nowhere to go. Now that the model moves these dials
|
|
# on the operator's behalf, a restore that kept the numbers and dropped the
|
|
# reasons would leave an install tuned by nobody it can name.
|
|
"retrieval_tuning_events",
|
|
]
|
|
|
|
# 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;
|
|
# the four *_embeddings tables are derived (regenerated at startup
|
|
# from the records themselves, which is also how a chunker bump is handled); 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", "rule_embeddings", "milestone_embeddings",
|
|
"system_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",
|
|
# Derived, like note_embeddings: the CSS consumer map (milestone 302) is
|
|
# rebuilt from the repo archive by every coverage sync, and carries no
|
|
# judgment — the first refresh after a restore recreates it exactly.
|
|
"code_shape_consumers",
|
|
]
|
|
|
|
|
|
# Columns a backed-up table deliberately does NOT export, per table. Paired
|
|
# with the column-coverage guard in tests/test_services_backup.py, this is
|
|
# _NOT_INCLUDED's shape one level down — and it exists because the table guard
|
|
# could not see the failure it was written to stop.
|
|
#
|
|
# #2293 was six whole tables missing. #3182 was NINE COLUMNS missing from a
|
|
# table that had been "covered" for years: note_type and task_kind, so every
|
|
# snippet and process restored as a plain note and every issue and spike as
|
|
# `work`; arose_from_id, so every provenance edge vanished; the recurrence
|
|
# pair, so recurring tasks stopped recurring; milestones.body, which IS the
|
|
# plan; repo_bindings.ref, the branch a ledger follows. Every one arrived the
|
|
# same way — a column added to the model and the migration, both of which fail
|
|
# loudly, and then never added to the serialiser, which fails silently.
|
|
#
|
|
# So: a new column on a backed-up table now fails the build unless it is either
|
|
# exported or named here with a reason. "I forgot" is no longer expressible.
|
|
_COLUMN_EXCLUSIONS: dict[str, set[str]] = {
|
|
# Trash is not exported at all, so neither is the batch id that groups a
|
|
# deletion for restore(). Uniform across every soft-deletable table.
|
|
"users": set(),
|
|
"projects": {
|
|
"deleted_at", "deleted_batch_id",
|
|
# Credentials-adjacent, same reasoning as api_keys and
|
|
# forge_connections: a restored project falls back to keyring-by-host
|
|
# resolution, which is the documented unpinned behaviour (#2778).
|
|
"forge_connection_id",
|
|
},
|
|
"milestones": {"deleted_at", "deleted_batch_id"},
|
|
"notes": {"deleted_at", "deleted_batch_id"},
|
|
"task_logs": set(),
|
|
"note_drafts": set(),
|
|
"note_versions": set(),
|
|
"settings": set(),
|
|
"rulebooks": {"deleted_at", "deleted_batch_id"},
|
|
"rulebook_topics": {"deleted_at", "deleted_batch_id"},
|
|
"rules": {"deleted_at", "deleted_batch_id"},
|
|
"systems": {
|
|
"deleted_at", "deleted_batch_id",
|
|
# Travels as `canonical_slug`: the catalog is global and its ids are
|
|
# per-install, so an id would restore pointing at whatever area
|
|
# happened to land on that number.
|
|
"canonical_id",
|
|
"created_at", "updated_at",
|
|
},
|
|
"canonical_systems": {
|
|
"deleted_at", "deleted_batch_id",
|
|
# Matched on SLUG at restore, so a target install that already seeded
|
|
# the standard vocabulary reuses its own rows rather than colliding.
|
|
"id", "created_at", "updated_at",
|
|
},
|
|
# Edge tables: the id is regenerated on insert, and the pair IS the row.
|
|
"record_systems": {"id", "created_at"},
|
|
"note_supersessions": {"id", "created_at"},
|
|
"rule_relations": {"id", "created_at"},
|
|
"note_usage_events": {"id"},
|
|
# Same as the note twin: the surrogate key is re-issued on insert.
|
|
"rule_usage_events": {"id"},
|
|
# Same again — and everything else travels, because each remaining column
|
|
# is part of the argument: what moved, from what, to what, by whom, why.
|
|
"retrieval_tuning_events": {"id"},
|
|
"design_systems": {"deleted_at", "deleted_batch_id", "created_at", "updated_at"},
|
|
"design_tokens": {"deleted_at", "deleted_batch_id", "created_at", "updated_at"},
|
|
"repo_bindings": {"id", "created_at", "updated_at"},
|
|
# Everything travels. A version row IS the audit trail, so a column left
|
|
# behind is a fact about a binding instruction that no longer exists
|
|
# anywhere.
|
|
"rule_versions": set(),
|
|
# Serialised via the model's own to_dict(), so a column reaches the backup
|
|
# the moment it reaches that method — and the guard still catches one that
|
|
# reaches neither.
|
|
"code_shapes": {
|
|
# The PROPOSER's standing suggestion for an unclassified row, not a
|
|
# judgment: "looks like an instance of #N", or "repeats with no canon".
|
|
# Every refresh recomputes it and a judgment clears it, so carrying it
|
|
# would restore stale machine guesses over a tree the proposer has not
|
|
# seen. Same reasoning as code_shape_consumers in _NOT_INCLUDED —
|
|
# derived data is regenerated, never restored.
|
|
"proposed_snippet_id", "proposal_basis", "proposal_score",
|
|
"proposal_group", "proposed_at", "proposed_sha",
|
|
},
|
|
"code_shape_events": set(),
|
|
"code_shape_uses": set(),
|
|
}
|
|
|
|
|
|
# The same accounting for the way BACK IN (#4197). `_COLUMN_EXCLUSIONS` above
|
|
# makes a dropped column unexpressible on the way out; this one does it for the
|
|
# import, where the same mistake is worse — an export gap leaves an obviously
|
|
# thin backup, an import gap means holding a complete, correct file and
|
|
# restoring an incomplete database from it, with a success message.
|
|
#
|
|
# A name here means the `_build_*` helper for that table deliberately does not
|
|
# pass it. THREE REASONS ARE LEGITIMATE and nothing else is:
|
|
#
|
|
# 1. The column is not in the backup at all, because `_COLUMN_EXCLUSIONS`
|
|
# above leaves it out. Nothing can restore what was never written.
|
|
# 2. The database issues the value: a surrogate `id`, which must be re-issued
|
|
# because the destination's ids are its own.
|
|
# 3. It is written LATER in the same restore, by something that is not a
|
|
# construction — the project's design-system pointer and inception record
|
|
# point at rows created after the project itself.
|
|
#
|
|
# "We forgot" is not on the list, which is the whole point. `code_shapes`
|
|
# carried three that had no reason at all — reason_code, recheck_at and
|
|
# diverges_from, exported and dropped — and this table is how they were found.
|
|
_IMPORT_COLUMN_EXCLUSIONS: dict[str, set[str]] = {
|
|
"users": {"id"},
|
|
"projects": {
|
|
"id",
|
|
# Not exported (see above), so not restorable.
|
|
"deleted_at", "deleted_batch_id", "forge_connection_id",
|
|
# Reason 3: both point at rows this restore creates AFTER the project,
|
|
# so they are patched in the final pass with their ids re-mapped.
|
|
"design_system_id", "inception",
|
|
},
|
|
"milestones": {"id", "deleted_at", "deleted_batch_id"},
|
|
"notes": {"id", "deleted_at", "deleted_batch_id"},
|
|
"task_logs": {"id"},
|
|
"note_drafts": {"id"},
|
|
"note_versions": {"id"},
|
|
# A composite primary key (user_id, key) — no surrogate to re-issue, and
|
|
# every column travels.
|
|
"settings": set(),
|
|
"rulebooks": {"id", "deleted_at", "deleted_batch_id"},
|
|
"rulebook_topics": {"id", "deleted_at", "deleted_batch_id"},
|
|
"rules": {"id", "deleted_at", "deleted_batch_id"},
|
|
"rule_versions": {"id"},
|
|
# `canonical_id` IS set, from the exported slug. The timestamps are not
|
|
# exported, so the row is re-stamped as of the restore.
|
|
"systems": {
|
|
"id", "deleted_at", "deleted_batch_id", "created_at", "updated_at",
|
|
},
|
|
"canonical_systems": {
|
|
"id", "deleted_at", "deleted_batch_id", "created_at", "updated_at",
|
|
},
|
|
"record_systems": {"id", "created_at"},
|
|
"note_supersessions": {"id", "created_at"},
|
|
"rule_relations": {"id", "created_at"},
|
|
"note_usage_events": {"id"},
|
|
"rule_usage_events": {"id"},
|
|
"retrieval_tuning_events": {"id"},
|
|
"design_systems": {
|
|
"id", "deleted_at", "deleted_batch_id", "created_at", "updated_at",
|
|
},
|
|
"design_tokens": {
|
|
"id", "deleted_at", "deleted_batch_id", "created_at", "updated_at",
|
|
},
|
|
"repo_bindings": {"id", "created_at", "updated_at"},
|
|
"code_shapes": {
|
|
"id",
|
|
# Not exported: the proposer's standing suggestion is derived, and the
|
|
# next refresh recomputes it against the restored snippet ids.
|
|
"proposed_snippet_id", "proposal_basis", "proposal_score",
|
|
"proposal_group", "proposed_at", "proposed_sha",
|
|
},
|
|
"code_shape_events": {"id"},
|
|
"code_shape_uses": {"id"},
|
|
}
|
|
|
|
|
|
def _dt(val: str | None) -> datetime:
|
|
return datetime.fromisoformat(val) if val else datetime.now(timezone.utc)
|
|
|
|
|
|
def _dt_or_none(val: str | None) -> datetime | None:
|
|
"""Like _dt, but keeps an absent timestamp absent.
|
|
|
|
_dt substitutes now() because created_at/updated_at must not be null.
|
|
For a nullable column that MEANS something by being empty, that default
|
|
is a lie: a rule nobody ever verified would restore looking verified at
|
|
the moment of the restore, and drop straight to the bottom of the sweep
|
|
it should have topped.
|
|
"""
|
|
return datetime.fromisoformat(val) if val else None
|
|
|
|
|
|
def _d(val: str | None) -> date | None:
|
|
return date.fromisoformat(val) if val else None
|
|
|
|
|
|
|
|
|
|
# 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 _canonical_system_rows(rows) -> list[dict]:
|
|
"""The global area catalog. Carried WITHOUT ids: a restore matches on slug,
|
|
so a target install that already seeded the standard vocabulary reuses its
|
|
own rows and only gains the entries an admin added here."""
|
|
return [
|
|
{
|
|
"name": r.name, "slug": r.slug, "description": r.description,
|
|
"order_index": r.order_index,
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
def _system_rows(rows, canonical_slugs: dict[int, str]) -> list[dict]:
|
|
"""A project's Systems. The canonical mapping travels as a SLUG, not an id
|
|
— the catalog is global and its ids are per-install, so an id would restore
|
|
pointing at whatever area happened to land on that number."""
|
|
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,
|
|
"canonical_slug": canonical_slugs.get(r.canonical_id or 0),
|
|
}
|
|
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, "project_id": r.project_id,
|
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
def _rule_usage_event_rows(rows) -> list[dict]:
|
|
return [
|
|
{
|
|
"user_id": r.user_id, "rule_id": r.rule_id, "event": r.event,
|
|
"source": r.source,
|
|
# The reason a rule was departed from (#4212). Exported because
|
|
# it is the only field on this table that cannot be recomputed:
|
|
# counts can be re-derived from a fresh install's own use, a
|
|
# stated reason cannot, and a `departed` row that comes back
|
|
# without one is indistinguishable from a rule that was missed.
|
|
"detail": r.detail,
|
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
def _retrieval_tuning_event_rows(rows) -> list[dict]:
|
|
"""The record of why a retrieval dial is where it is (#4102).
|
|
|
|
Not `to_dict()`: that method serves the MCP reader, which already knows
|
|
whose install it is asking about, so it omits `user_id`. A restore has to
|
|
remap it, and a row that arrived without it would have to be dropped or
|
|
guessed at.
|
|
"""
|
|
return [
|
|
{
|
|
"user_id": r.user_id, "surface": r.surface, "dial": r.dial,
|
|
"old_value": r.old_value, "new_value": r.new_value,
|
|
"actor": r.actor, "reason": r.reason,
|
|
# Carried, and NOT defaulted to the current model on the way out
|
|
# (#4104): a row that was unstamped when it was written is still
|
|
# unstamped after a round trip, and a backup that quietly filled
|
|
# the gap would turn "we don't know" into a stated fact.
|
|
"embedding_model": r.embedding_model,
|
|
"shape_version": r.shape_version,
|
|
"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 [
|
|
# `ref` is the branch this binding's ledger follows (#2873). Without
|
|
# it a restored binding silently falls back to the default branch, and
|
|
# the shape ledger starts accounting for a different tree (#3182).
|
|
{
|
|
"user_id": r.user_id, "project_id": r.project_id,
|
|
"repo_key": r.repo_key, "ref": r.ref,
|
|
}
|
|
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,
|
|
# THE PLAN. A milestone IS the plan (0066) and `body` is its
|
|
# design and intent; `description` is only the one-line summary.
|
|
# Dropping this restored every plan as a title with no reasoning
|
|
# behind it (#3182).
|
|
"body": m.body,
|
|
"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,
|
|
"description": n.description,
|
|
"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(),
|
|
# WHAT KIND OF RECORD THIS IS — both typing axes (#3182). Missing
|
|
# until now, which meant a restore reported success and handed back
|
|
# a corpus with every snippet and process flattened into a plain
|
|
# note and every issue and spike into `work`. Nothing recomputes
|
|
# these; the vocabulary is simply gone.
|
|
"note_type": n.note_type,
|
|
"task_kind": n.task_kind,
|
|
# Provenance — which record caused this one. Re-mapped in the
|
|
# second pass beside parent_id, never here: the value is an id in
|
|
# the SOURCE database.
|
|
"arose_from_id": n.arose_from_id,
|
|
# The queryable mirror. The only one of these that would self-heal
|
|
# (backfill_snippet_data rebuilds it from the body at startup), but
|
|
# a restore should not hand back a corpus that needs a restart to
|
|
# become searchable by location.
|
|
"data": n.data,
|
|
# Lifecycle: when the work actually started and finished, and the
|
|
# recurrence rule that makes a task come back. Without these a
|
|
# restored recurring task simply stops recurring.
|
|
"started_at": n.started_at.isoformat() if n.started_at else None,
|
|
"completed_at": n.completed_at.isoformat() if n.completed_at else None,
|
|
"recurrence_rule": n.recurrence_rule,
|
|
"recurrence_next_spawn_at": (
|
|
n.recurrence_next_spawn_at.isoformat()
|
|
if n.recurrence_next_spawn_at else None
|
|
),
|
|
# The verification trio (milestone 317, migration 0092). Operator
|
|
# judgment — "somebody checked this fact, and this is when" —
|
|
# which nothing can recompute.
|
|
"verify_with": n.verify_with,
|
|
"expires_when": n.expires_when,
|
|
"verified_at": n.verified_at.isoformat() if n.verified_at else None,
|
|
}
|
|
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 _rule_version_rows(rows) -> list[dict]:
|
|
"""A rule's edit history. Sibling of _note_version_rows, and it travels for
|
|
the same reason: a version is the only record of what a binding
|
|
instruction used to say, and nothing can recompute it."""
|
|
return [
|
|
{
|
|
"id": rv.id, "rule_id": rv.rule_id, "user_id": rv.user_id,
|
|
"title": rv.title, "statement": rv.statement, "why": rv.why,
|
|
"how_to_apply": rv.how_to_apply, "when_to_apply": rv.when_to_apply,
|
|
"kind": rv.kind, "verify_with": rv.verify_with,
|
|
"expires_when": rv.expires_when,
|
|
"created_at": rv.created_at.isoformat(),
|
|
}
|
|
for rv 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,
|
|
"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_system_rows(rows) -> list[dict]:
|
|
"""A rule's area tags, carried by canonical SLUG for the same reason the
|
|
Systems are: the catalog is global and its ids are per-install."""
|
|
return [{"rule_id": rule_id, "canonical_slug": slug} for rule_id, slug in rows]
|
|
|
|
|
|
def _rule_relation_rows(rows) -> list[dict]:
|
|
"""The typed edges between rules. Carried because they are a JUDGEMENT —
|
|
someone decided these two fail together, or that one supersedes the other,
|
|
and nothing in either rule's text records the decision. Lose them and a
|
|
split rule silently starts arriving half at a time again."""
|
|
return [
|
|
{
|
|
"from_rule_id": r.from_rule_id, "to_rule_id": r.to_rule_id,
|
|
"kind": r.kind, "note": r.note,
|
|
}
|
|
for r 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,
|
|
"when_to_apply": r.when_to_apply, "kind": r.kind,
|
|
"verify_with": r.verify_with, "expires_when": r.expires_when,
|
|
"verified_at": r.verified_at.isoformat() if r.verified_at else None,
|
|
"arose_from_id": r.arose_from_id,
|
|
"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()
|
|
rule_versions = (await session.execute(
|
|
select(RuleVersion).order_by(RuleVersion.rule_id, RuleVersion.id)
|
|
)).scalars().all()
|
|
settings = (await session.execute(select(Setting))).scalars().all()
|
|
systems = (await session.execute(select(System))).scalars().all()
|
|
canonical_systems = (await session.execute(
|
|
select(CanonicalSystem).where(CanonicalSystem.deleted_at.is_(None))
|
|
.order_by(CanonicalSystem.order_index)
|
|
)).scalars().all()
|
|
rule_system_rows = (await session.execute(
|
|
select(rule_systems_t.c.rule_id, CanonicalSystem.slug)
|
|
.join(CanonicalSystem, CanonicalSystem.id == rule_systems_t.c.canonical_id)
|
|
)).all()
|
|
rule_relations = (await session.execute(select(RuleRelation))).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()
|
|
rule_usage_events = (
|
|
await session.execute(select(RuleUsageEvent))
|
|
).scalars().all()
|
|
# Oldest first, so a restored history reads in the order the dials
|
|
# actually moved — the sequence IS the argument when a surface has been
|
|
# walked up and down.
|
|
retrieval_tuning_events = (await session.execute(
|
|
select(RetrievalTuningEvent).order_by(RetrievalTuningEvent.id)
|
|
)).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()
|
|
|
|
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),
|
|
"rule_versions": _rule_version_rows(rule_versions),
|
|
"settings": _setting_rows(settings),
|
|
"rulebooks": _rulebook_rows(rulebooks),
|
|
"rulebook_topics": _topic_rows(topics),
|
|
"rules": _rule_rows(rules),
|
|
"canonical_systems": _canonical_system_rows(canonical_systems),
|
|
"rule_systems": _rule_system_rows(rule_system_rows),
|
|
"rule_relations": _rule_relation_rows(rule_relations),
|
|
"systems": _system_rows(
|
|
systems, {c.id: c.slug for c in canonical_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),
|
|
"rule_usage_events": _rule_usage_event_rows(rule_usage_events),
|
|
"retrieval_tuning_events": _retrieval_tuning_event_rows(
|
|
retrieval_tuning_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()
|
|
# Global: taken whole even in a per-user export, because the Systems
|
|
# above reference it and a partial catalog restores partial mappings.
|
|
canonical_systems = (await session.execute(
|
|
select(CanonicalSystem).where(CanonicalSystem.deleted_at.is_(None))
|
|
.order_by(CanonicalSystem.order_index)
|
|
)).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 []
|
|
# Scoped to the rules this export already carries: an edge whose far
|
|
# end is absent would restore pointing at nothing.
|
|
_rule_ids = [r.id for r in rules]
|
|
rule_system_rows = (await session.execute(
|
|
select(rule_systems_t.c.rule_id, CanonicalSystem.slug)
|
|
.join(CanonicalSystem, CanonicalSystem.id == rule_systems_t.c.canonical_id)
|
|
.where(rule_systems_t.c.rule_id.in_(_rule_ids))
|
|
)).all() if _rule_ids else []
|
|
# Scoped through the RULE, not the version's user_id. That column is
|
|
# the ACTOR (milestone 323), so filtering on it would carry the
|
|
# versions this user wrote on someone ELSE's rule and drop the ones
|
|
# someone else wrote on theirs — the opposite of a per-user export.
|
|
rule_versions = (await session.execute(
|
|
select(RuleVersion).where(RuleVersion.rule_id.in_(_rule_ids))
|
|
.order_by(RuleVersion.rule_id, RuleVersion.id)
|
|
)).scalars().all() if _rule_ids else []
|
|
# Scoped through the RULE for the same reason the versions above are,
|
|
# and it is worth restating because the column that looks right is
|
|
# wrong: `user_id` here is whoever the arm fired FOR, not who owns the
|
|
# rule. Filtering on it would carry this user's surfacings of someone
|
|
# ELSE's rule and drop the ones fired for someone else on theirs.
|
|
rule_usage_events = (await session.execute(
|
|
select(RuleUsageEvent).where(RuleUsageEvent.rule_id.in_(_rule_ids))
|
|
)).scalars().all() if _rule_ids else []
|
|
# Scoped on user_id, and here that IS the right column — unlike the
|
|
# rule usage events directly above. These record changes to this user's
|
|
# OWN retrieval settings, which is what `user_id` means on this table;
|
|
# there is no second owner to route around.
|
|
retrieval_tuning_events = (await session.execute(
|
|
select(RetrievalTuningEvent)
|
|
.where(RetrievalTuningEvent.user_id == user_id)
|
|
.order_by(RetrievalTuningEvent.id)
|
|
)).scalars().all()
|
|
rule_relations = (await session.execute(
|
|
select(RuleRelation).where(
|
|
RuleRelation.from_rule_id.in_(_rule_ids),
|
|
RuleRelation.to_rule_id.in_(_rule_ids),
|
|
)
|
|
)).scalars().all() if _rule_ids else []
|
|
|
|
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),
|
|
"rule_versions": _rule_version_rows(rule_versions),
|
|
"settings": _setting_rows(settings),
|
|
"rulebooks": _rulebook_rows(rulebooks),
|
|
"rulebook_topics": _topic_rows(topics),
|
|
"rules": _rule_rows(rules),
|
|
"canonical_systems": _canonical_system_rows(canonical_systems),
|
|
"rule_systems": _rule_system_rows(rule_system_rows),
|
|
"rule_relations": _rule_relation_rows(rule_relations),
|
|
"systems": _system_rows(
|
|
systems, {c.id: c.slug for c in canonical_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),
|
|
"rule_usage_events": _rule_usage_event_rows(rule_usage_events),
|
|
"retrieval_tuning_events": _retrieval_tuning_event_rows(
|
|
retrieval_tuning_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),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Per-table builders — the import side's twin of the row helpers above (#4197)
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# The column guard (#3182) makes a dropped column unexpressible on the way OUT
|
|
# because export goes through per-table pure helpers: hand one a stand-in and
|
|
# read which KEYS came back. Nothing watched the way back IN, and that is the
|
|
# worse half — an export gap leaves an obviously thin backup, an import gap
|
|
# means holding a complete, correct file and restoring an incomplete database
|
|
# from it, with a success message.
|
|
#
|
|
# The guard was one-sided because the code was. `_restore_v2` was one long
|
|
# procedural function with the model construction inlined in each loop, so
|
|
# there was no per-table unit to hand a stand-in to. These builders are that
|
|
# unit. Each takes an exported row plus the id maps built so far and returns
|
|
# either the model instance to add or None for a row that cannot be restored.
|
|
#
|
|
# WHAT DELIBERATELY DID NOT MOVE. The loops, the flushes and the id-map
|
|
# bookkeeping stay in `_restore_v2`: that is the order-dependent part, where a
|
|
# mistake is a restore that half-works, and it gains nothing from being split.
|
|
# Only the construction moved, because the construction is the only thing the
|
|
# guard has to see.
|
|
#
|
|
# RETURNING None IS "SKIP", A None FIELD IS "DEGRADE", and the difference is
|
|
# per table by design. `code_shape_events` skips a row whose project will not
|
|
# map — a shape event without its project says nothing. `note_usage_events`
|
|
# degrades to None — the project is optional, null already means "not
|
|
# reported", and dropping the row would lose a real pull and deflate the
|
|
# pull-through the table exists to report. Both are right for their table, so
|
|
# each builder states its own policy rather than a rule being imposed on all.
|
|
|
|
|
|
class _Maps:
|
|
"""The source-id to new-id maps a restore accumulates as it goes.
|
|
|
|
One object rather than nine locals so a builder's signature says what it
|
|
depends on, and so the guard can hand every builder the same fully
|
|
populated stand-in without knowing which maps that table happens to read.
|
|
"""
|
|
|
|
__slots__ = (
|
|
"users", "projects", "milestones", "notes", "rulebooks", "topics",
|
|
"rules", "systems", "design_systems", "shapes", "canonical_by_slug",
|
|
)
|
|
|
|
def __init__(self) -> None:
|
|
self.users: dict[int, int] = {}
|
|
self.projects: dict[int, int] = {}
|
|
self.milestones: dict[int, int] = {}
|
|
self.notes: dict[int, int] = {}
|
|
self.rulebooks: dict[int, int] = {}
|
|
self.topics: dict[int, int] = {}
|
|
self.rules: dict[int, int] = {}
|
|
self.systems: dict[int, int] = {}
|
|
self.design_systems: dict[int, int] = {}
|
|
self.shapes: dict[int, int] = {}
|
|
self.canonical_by_slug: dict[str, int] = {}
|
|
|
|
|
|
def _build_user(row: dict, maps: _Maps) -> User:
|
|
"""Users are the root of every map, so nothing can fail to resolve."""
|
|
return User(
|
|
username=row["username"],
|
|
email=row.get("email"),
|
|
password_hash=row.get("password_hash"),
|
|
oauth_sub=row.get("oauth_sub"),
|
|
role=row.get("role", "user"),
|
|
session_version=row.get("session_version", 1),
|
|
created_at=_dt(row.get("created_at")),
|
|
)
|
|
|
|
|
|
def _build_project(row: dict, maps: _Maps) -> Project | None:
|
|
uid = maps.users.get(row.get("user_id", 0))
|
|
if uid is None:
|
|
return None
|
|
return Project(
|
|
user_id=uid,
|
|
title=row.get("title", ""),
|
|
description=row.get("description", ""),
|
|
goal=row.get("goal", ""),
|
|
status=row.get("status", "active"),
|
|
color=row.get("color"),
|
|
created_at=_dt(row.get("created_at")),
|
|
updated_at=_dt(row.get("updated_at")),
|
|
)
|
|
|
|
|
|
def _build_milestone(row: dict, maps: _Maps) -> Milestone | None:
|
|
uid = maps.users.get(row.get("user_id", 0))
|
|
pid = maps.projects.get(row.get("project_id", 0))
|
|
if uid is None or pid is None:
|
|
return None
|
|
return Milestone(
|
|
user_id=uid,
|
|
project_id=pid,
|
|
title=row.get("title", ""),
|
|
description=row.get("description"),
|
|
body=row.get("body"),
|
|
status=row.get("status", "active"),
|
|
order_index=row.get("order_index", 0),
|
|
created_at=_dt(row.get("created_at")),
|
|
updated_at=_dt(row.get("updated_at")),
|
|
)
|
|
|
|
|
|
def _build_note(row: dict, maps: _Maps) -> Note | None:
|
|
"""`parent_id` and `arose_from_id` land NULL and are patched afterwards.
|
|
|
|
Both are ids in the SOURCE database (#3182), and the target note may not
|
|
have been created yet when this row is built.
|
|
"""
|
|
uid = maps.users.get(row.get("user_id", 0))
|
|
if uid is None:
|
|
return None
|
|
return Note(
|
|
user_id=uid,
|
|
title=row.get("title", ""),
|
|
body=row.get("body", ""),
|
|
tags=row.get("tags", []),
|
|
parent_id=None,
|
|
arose_from_id=None,
|
|
description=row.get("description"),
|
|
note_type=row.get("note_type") or "note",
|
|
task_kind=row.get("task_kind") or "work",
|
|
data=row.get("data"),
|
|
started_at=_dt_or_none(row.get("started_at")),
|
|
completed_at=_dt_or_none(row.get("completed_at")),
|
|
recurrence_rule=row.get("recurrence_rule"),
|
|
recurrence_next_spawn_at=_dt_or_none(row.get("recurrence_next_spawn_at")),
|
|
project_id=maps.projects.get(row["project_id"]) if row.get("project_id") else None,
|
|
milestone_id=maps.milestones.get(row["milestone_id"]) if row.get("milestone_id") else None,
|
|
status=row.get("status"),
|
|
priority=row.get("priority"),
|
|
due_date=_d(row.get("due_date")),
|
|
created_at=_dt(row.get("created_at")),
|
|
updated_at=_dt(row.get("updated_at")),
|
|
verify_with=row.get("verify_with"),
|
|
expires_when=row.get("expires_when"),
|
|
# _dt_or_none — see the note on the other restore path.
|
|
verified_at=_dt_or_none(row.get("verified_at")),
|
|
)
|
|
|
|
|
|
def _build_task_log(row: dict, maps: _Maps) -> TaskLog | None:
|
|
uid = maps.users.get(row.get("user_id", 0))
|
|
tid = maps.notes.get(row.get("task_id", 0))
|
|
if uid is None or tid is None:
|
|
return None
|
|
return TaskLog(
|
|
user_id=uid,
|
|
task_id=tid,
|
|
content=row.get("content", ""),
|
|
duration_minutes=row.get("duration_minutes"),
|
|
created_at=_dt(row.get("created_at")),
|
|
updated_at=_dt(row.get("updated_at")),
|
|
)
|
|
|
|
|
|
def _build_note_draft(row: dict, maps: _Maps) -> NoteDraft | None:
|
|
uid = maps.users.get(row.get("user_id", 0))
|
|
nid = maps.notes.get(row.get("note_id", 0))
|
|
if uid is None or nid is None:
|
|
return None
|
|
return NoteDraft(
|
|
user_id=uid,
|
|
note_id=nid,
|
|
proposed_body=row.get("proposed_body", ""),
|
|
original_body=row.get("original_body", ""),
|
|
instruction=row.get("instruction", ""),
|
|
scope=row.get("scope", "document"),
|
|
created_at=_dt(row.get("created_at")),
|
|
updated_at=_dt(row.get("updated_at")),
|
|
)
|
|
|
|
|
|
def _build_note_version(row: dict, maps: _Maps) -> NoteVersion | None:
|
|
uid = maps.users.get(row.get("user_id", 0))
|
|
nid = maps.notes.get(row.get("note_id", 0))
|
|
if uid is None or nid is None:
|
|
return None
|
|
return NoteVersion(
|
|
user_id=uid,
|
|
note_id=nid,
|
|
title=row.get("title", ""),
|
|
body=row.get("body", ""),
|
|
tags=row.get("tags", []),
|
|
pin_kind=row.get("pin_kind"),
|
|
pin_label=row.get("pin_label"),
|
|
created_at=_dt(row.get("created_at")),
|
|
)
|
|
|
|
|
|
def _build_setting(row: dict, maps: _Maps) -> Setting | None:
|
|
uid = maps.users.get(row.get("user_id", 0))
|
|
if uid is None:
|
|
return None
|
|
return Setting(user_id=uid, key=row["key"], value=row.get("value", ""))
|
|
|
|
|
|
def _build_retrieval_tuning_event(row: dict, maps: _Maps) -> RetrievalTuningEvent | None:
|
|
"""No id remapping beyond the user: `surface` is a registry NAME, not a
|
|
foreign key, which is what lets this history survive a restore into an
|
|
install whose row ids all differ."""
|
|
uid = maps.users.get(row.get("user_id") or 0)
|
|
if uid is None:
|
|
return None
|
|
return RetrievalTuningEvent(
|
|
user_id=uid,
|
|
surface=row.get("surface", ""),
|
|
dial=row.get("dial", ""),
|
|
old_value=row.get("old_value"),
|
|
new_value=row.get("new_value"),
|
|
actor=row.get("actor") or "model",
|
|
reason=row.get("reason", ""),
|
|
# .get with no default, deliberately (v17): an archive written before
|
|
# the stamp existed has no key here, and None is the right answer for
|
|
# it — the same "unstamped" a pre-#4104 row carries in place.
|
|
embedding_model=row.get("embedding_model"),
|
|
shape_version=row.get("shape_version"),
|
|
created_at=_dt(row.get("created_at")),
|
|
)
|
|
|
|
|
|
def _build_rulebook(row: dict, maps: _Maps) -> Rulebook | None:
|
|
uid = maps.users.get(row.get("owner_user_id", 0))
|
|
if uid is None:
|
|
return None
|
|
return Rulebook(
|
|
owner_user_id=uid,
|
|
title=row.get("title", ""),
|
|
description=row.get("description", ""),
|
|
created_at=_dt(row.get("created_at")),
|
|
updated_at=_dt(row.get("updated_at")),
|
|
)
|
|
|
|
|
|
def _build_topic(row: dict, maps: _Maps) -> RulebookTopic | None:
|
|
rbid = maps.rulebooks.get(row.get("rulebook_id", 0))
|
|
if rbid is None:
|
|
return None
|
|
return RulebookTopic(
|
|
rulebook_id=rbid,
|
|
title=row.get("title", ""),
|
|
description=row.get("description"),
|
|
order_index=row.get("order_index", 0),
|
|
created_at=_dt(row.get("created_at")),
|
|
updated_at=_dt(row.get("updated_at")),
|
|
)
|
|
|
|
|
|
def _build_rule(row: dict, maps: _Maps) -> Rule | None:
|
|
"""A topic-rule (topic_id) XOR a project-rule (project_id). Neither
|
|
mapping surviving means the rule is orphaned and is not restored."""
|
|
topic = maps.topics.get(row["topic_id"]) if row.get("topic_id") else None
|
|
proj = maps.projects.get(row["project_id"]) if row.get("project_id") else None
|
|
if topic is None and proj is None:
|
|
return None
|
|
return Rule(
|
|
topic_id=topic,
|
|
project_id=proj,
|
|
title=row.get("title", ""),
|
|
statement=row.get("statement", ""),
|
|
why=row.get("why") or None,
|
|
how_to_apply=row.get("how_to_apply") or None,
|
|
when_to_apply=row.get("when_to_apply") or None,
|
|
# A file written before milestone 394 carries `tier` and `always_on`;
|
|
# neither is read. Dropping a field the schema no longer has is the
|
|
# tolerant direction — an archive records what WAS, and refusing it
|
|
# because it remembers a deleted column would make every pre-394
|
|
# backup unrestorable.
|
|
#
|
|
# Same shape, same reason: a file written before 0098 has no kind, and
|
|
# every rule in it was a rule. Defaulting the other way would restore
|
|
# an old backup with things that had always bound quietly no longer
|
|
# binding.
|
|
kind=row.get("kind") or "rule",
|
|
verify_with=row.get("verify_with") or None,
|
|
expires_when=row.get("expires_when") or None,
|
|
# Restored as-is, NOT reset to null. `verified_at` records when someone
|
|
# last ran the check; a restore does not make that untrue, and clearing
|
|
# it would put every constraint at the top of the sweep with nothing
|
|
# having actually changed.
|
|
verified_at=_dt_or_none(row.get("verified_at")),
|
|
# Remapped through the note map like every other note edge. Exported
|
|
# since 0088 but dropped on the way back in until milestone 312 — a
|
|
# restore silently lost every rule's provenance link. SET NULL
|
|
# semantics apply here too: a source note that didn't restore leaves
|
|
# the rule intact.
|
|
arose_from_id=maps.notes.get(row.get("arose_from_id") or 0),
|
|
order_index=row.get("order_index", 0),
|
|
created_at=_dt(row.get("created_at")),
|
|
updated_at=_dt(row.get("updated_at")),
|
|
)
|
|
|
|
|
|
def _build_canonical_system(row: dict, maps: _Maps) -> CanonicalSystem | None:
|
|
"""Matched on SLUG, not id. This install already has the standard
|
|
vocabulary from its migrations, so the common case creates nothing and the
|
|
restore simply learns which local id each slug is; only an entry an admin
|
|
added on the source instance is built here."""
|
|
slug = row.get("slug") or ""
|
|
if not slug or slug in maps.canonical_by_slug:
|
|
return None
|
|
return CanonicalSystem(
|
|
name=row.get("name", ""),
|
|
slug=slug,
|
|
description=row.get("description"),
|
|
order_index=row.get("order_index", 0),
|
|
)
|
|
|
|
|
|
def _build_rule_relation(row: dict, maps: _Maps) -> RuleRelation | None:
|
|
"""Both ends must map, and a self-edge is not a relation."""
|
|
src = maps.rules.get(row.get("from_rule_id", 0))
|
|
dst = maps.rules.get(row.get("to_rule_id", 0))
|
|
if src is None or dst is None or src == dst:
|
|
return None
|
|
return RuleRelation(
|
|
from_rule_id=src,
|
|
to_rule_id=dst,
|
|
kind=row.get("kind", "co_surfaces"),
|
|
note=row.get("note") or None,
|
|
)
|
|
|
|
|
|
def _build_rule_version(row: dict, maps: _Maps) -> RuleVersion | None:
|
|
rid = maps.rules.get(row.get("rule_id", 0))
|
|
if rid is None:
|
|
return None
|
|
return RuleVersion(
|
|
rule_id=rid,
|
|
# Unlike NoteVersion, an unmappable user does NOT drop the row.
|
|
# user_id is the ACTOR and is nullable by design: the column is SET
|
|
# NULL precisely so history outlives the account that wrote it.
|
|
# Skipping here would delete the record the FK preserves.
|
|
user_id=maps.users.get(row.get("user_id") or 0),
|
|
title=row.get("title", ""),
|
|
statement=row.get("statement", ""),
|
|
why=row.get("why"),
|
|
how_to_apply=row.get("how_to_apply"),
|
|
when_to_apply=row.get("when_to_apply"),
|
|
# NOT defaulted, unlike the rule itself. A version records what was;
|
|
# absent means nobody wrote it down, and inventing "rule" here would
|
|
# put an artifact where a measurement belongs.
|
|
kind=row.get("kind"),
|
|
verify_with=row.get("verify_with"),
|
|
expires_when=row.get("expires_when"),
|
|
created_at=_dt(row.get("created_at")),
|
|
)
|
|
|
|
|
|
def _build_system(row: dict, maps: _Maps) -> System | None:
|
|
uid = maps.users.get(row.get("user_id", 0))
|
|
pid = maps.projects.get(row.get("project_id", 0))
|
|
if uid is None or pid is None:
|
|
return None
|
|
return System(
|
|
user_id=uid,
|
|
project_id=pid,
|
|
name=row.get("name", ""),
|
|
description=row.get("description"),
|
|
color=row.get("color"),
|
|
status=row.get("status", "active"),
|
|
order_index=row.get("order_index", 0),
|
|
# An unknown slug restores UNMAPPED rather than failing: the System and
|
|
# its records are the payload, the mapping is an aid.
|
|
canonical_id=maps.canonical_by_slug.get(row.get("canonical_slug") or ""),
|
|
)
|
|
|
|
|
|
def _build_record_system(row: dict, maps: _Maps) -> RecordSystem | None:
|
|
nid = maps.notes.get(row.get("note_id", 0))
|
|
sid = maps.systems.get(row.get("system_id", 0))
|
|
if nid is None or sid is None:
|
|
return None
|
|
return RecordSystem(note_id=nid, system_id=sid)
|
|
|
|
|
|
def _build_note_supersession(row: dict, maps: _Maps) -> NoteSupersession | None:
|
|
"""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."""
|
|
new_id = maps.notes.get(row.get("superseder_id", 0))
|
|
old_id = maps.notes.get(row.get("superseded_id", 0))
|
|
if new_id is None or old_id is None or new_id == old_id:
|
|
return None
|
|
return NoteSupersession(superseder_id=new_id, superseded_id=old_id)
|
|
|
|
|
|
def _build_design_system(row: dict, maps: _Maps) -> DesignSystem | None:
|
|
"""The export orders these parent-first, so a parent's new id is always in
|
|
the map by the time a child needs it — and a child whose parent is missing
|
|
lands as a root rather than failing the whole restore."""
|
|
uid = maps.users.get(row.get("owner_user_id", 0))
|
|
if uid is None:
|
|
return None
|
|
return DesignSystem(
|
|
owner_user_id=uid,
|
|
title=row.get("title", ""),
|
|
description=row.get("description"),
|
|
guidance=row.get("guidance"),
|
|
parent_id=maps.design_systems.get(row.get("parent_id") or 0),
|
|
)
|
|
|
|
|
|
def _build_design_token(row: dict, maps: _Maps) -> DesignToken | None:
|
|
dsid = maps.design_systems.get(row.get("design_system_id", 0))
|
|
if dsid is None:
|
|
return None
|
|
return DesignToken(
|
|
design_system_id=dsid,
|
|
name=row.get("name", ""),
|
|
value_by_mode=row.get("value_by_mode") or {},
|
|
group_name=row.get("group_name"),
|
|
purpose=row.get("purpose"),
|
|
rationale=row.get("rationale"),
|
|
supersedes=row.get("supersedes") or [],
|
|
order_index=row.get("order_index", 0),
|
|
)
|
|
|
|
|
|
def _build_usage_event(row: dict, maps: _Maps) -> NoteUsageEvent | None:
|
|
"""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."""
|
|
nid = maps.notes.get(row.get("note_id", 0))
|
|
if nid is None:
|
|
return None
|
|
return NoteUsageEvent(
|
|
user_id=maps.users.get(row.get("user_id") or 0),
|
|
note_id=nid,
|
|
event=row.get("event", ""),
|
|
source=row.get("source", ""),
|
|
# DEGRADES to None rather than dropping the row: unlike a shape event,
|
|
# a usage event without a project is still a real pull, and discarding
|
|
# it would deflate the pull-through this table exists to report. Null
|
|
# already means "not reported".
|
|
project_id=(maps.projects.get(row["project_id"])
|
|
if row.get("project_id") else None),
|
|
created_at=_dt(row.get("created_at")),
|
|
)
|
|
|
|
|
|
def _build_rule_usage_event(row: dict, maps: _Maps) -> RuleUsageEvent | None:
|
|
"""Resolved through the RULE map, not the note map. A rule id run through
|
|
the note map would either drop (best case) or land on whatever note took
|
|
that number, producing telemetry that is wrong rather than missing and
|
|
that nothing downstream could detect (milestone 333)."""
|
|
rid = maps.rules.get(row.get("rule_id", 0))
|
|
if rid is None:
|
|
return None
|
|
return RuleUsageEvent(
|
|
user_id=maps.users.get(row.get("user_id") or 0),
|
|
rule_id=rid,
|
|
event=row.get("event", ""),
|
|
source=row.get("source", ""),
|
|
# `.get(...) or None` rather than a bare default: an archive written
|
|
# before 0106 has no key at all, and one written after may carry "" for
|
|
# a non-departure row. Both mean "no reason", and both must land as
|
|
# NULL so the readout does not have to tell an empty string from an
|
|
# absent one.
|
|
detail=(row.get("detail") or None),
|
|
created_at=_dt(row.get("created_at")),
|
|
)
|
|
|
|
|
|
def _build_repo_binding(row: dict, maps: _Maps) -> RepoBinding | None:
|
|
"""Small, but losing these means every bound repo quietly stops loading
|
|
its project at session start."""
|
|
uid = maps.users.get(row.get("user_id", 0))
|
|
pid = maps.projects.get(row.get("project_id", 0))
|
|
if uid is None or pid is None:
|
|
return None
|
|
return RepoBinding(
|
|
user_id=uid,
|
|
project_id=pid,
|
|
repo_key=row.get("repo_key", ""),
|
|
ref=row.get("ref"),
|
|
)
|
|
|
|
|
|
def _build_code_shape(row: dict, maps: _Maps) -> CodeShape | None:
|
|
"""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."""
|
|
pid = maps.projects.get(row.get("project_id", 0))
|
|
if pid is None:
|
|
return None
|
|
status = row.get("status", "unclassified")
|
|
snippet_id = maps.notes.get(row.get("snippet_id") or 0)
|
|
classified_by = row.get("classified_by")
|
|
classified_at = row.get("classified_at")
|
|
if status in ("canonical", "instance", "variant") and snippet_id is None:
|
|
status = "unclassified"
|
|
classified_by = None
|
|
classified_at = None
|
|
return CodeShape(
|
|
project_id=pid,
|
|
repo_key=row.get("repo_key", ""),
|
|
path=row.get("path", ""),
|
|
symbol=row.get("symbol", ""),
|
|
kind=row.get("kind", "sym"),
|
|
status=status,
|
|
snippet_id=snippet_id,
|
|
reason=row.get("reason"),
|
|
# Exported since #2874 and dropped on the way back in until #4197 —
|
|
# every judged row restored with its verdict and without the code for
|
|
# WHY, which is the column the accounting reads to tell a scoped-css
|
|
# exemption from a convention-plumbing one. Kept on a downgrade for
|
|
# the same reason `reason` is: the free text and its code are one
|
|
# statement, and the existing downgrade clears the judgment's author
|
|
# and date, not its argument.
|
|
reason_code=row.get("reason_code"),
|
|
classified_by=classified_by,
|
|
classified_at=_dt(classified_at) if classified_at else None,
|
|
first_seen_commit=row.get("first_seen_commit", ""),
|
|
last_seen_commit=row.get("last_seen_commit", ""),
|
|
vanished_at=_dt(row["vanished_at"]) if row.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=row.get("signature", ""),
|
|
body_sha=row.get("body_sha", ""),
|
|
classified_sha=row.get("classified_sha", ""),
|
|
# Both exported and both dropped until #4197. `recheck_at` is the
|
|
# standing "this judgment asks to be confirmed again" flag, so losing
|
|
# it restores a tree that looks settled and is not.
|
|
recheck_at=_dt(row["recheck_at"]) if row.get("recheck_at") else None,
|
|
# RE-MAPPED, not carried: it is a FK to notes.id like `snippet_id`,
|
|
# and a raw source id would point at whatever snippet took that
|
|
# number in the destination — wrong rather than missing, which is the
|
|
# milestone 333 trap one table over.
|
|
diverges_from=maps.notes.get(row.get("diverges_from") or 0),
|
|
created_at=_dt(row.get("created_at")),
|
|
updated_at=_dt(row.get("updated_at")),
|
|
)
|
|
|
|
|
|
def _build_code_shape_event(row: dict, maps: _Maps) -> CodeShapeEvent | None:
|
|
"""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.
|
|
|
|
SKIPS a row whose project will not map, unlike its usage-event sibling: a
|
|
shape event without its project says nothing."""
|
|
shape_id = maps.shapes.get(row.get("shape_id") or 0)
|
|
pid = maps.projects.get(row.get("project_id", 0))
|
|
if shape_id is None or pid is None:
|
|
return None
|
|
old_sid = row.get("snippet_id")
|
|
return CodeShapeEvent(
|
|
shape_id=shape_id,
|
|
project_id=pid,
|
|
path=row.get("path", ""),
|
|
symbol=row.get("symbol", ""),
|
|
kind=row.get("kind", "sym"),
|
|
event=row.get("event", "classified"),
|
|
status=row.get("status"),
|
|
snippet_id=maps.notes.get(old_sid, old_sid) if old_sid else None,
|
|
classified_by=row.get("classified_by"),
|
|
reason=row.get("reason"),
|
|
commit=row.get("commit", ""),
|
|
at=_dt(row.get("at")),
|
|
)
|
|
|
|
|
|
def _build_code_shape_use(row: dict, maps: _Maps) -> CodeShapeUse | None:
|
|
"""Consumption edges (#2870) ride their shape AND their snippet — both
|
|
ends must have survived, or the edge is no longer a fact."""
|
|
shape_id = maps.shapes.get(row.get("shape_id") or 0)
|
|
snippet_id = maps.notes.get(row.get("snippet_id") or 0)
|
|
if shape_id is None or snippet_id is None:
|
|
return None
|
|
return CodeShapeUse(
|
|
shape_id=shape_id,
|
|
snippet_id=snippet_id,
|
|
basis=row.get("basis", "import"),
|
|
evidence=row.get("evidence"),
|
|
created_at=_dt(row.get("created_at")),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
arose_from_id=None, # patched below, same reason
|
|
description=n_data.get("description"),
|
|
note_type=n_data.get("note_type") or "note",
|
|
task_kind=n_data.get("task_kind") or "work",
|
|
data=n_data.get("data"),
|
|
started_at=_dt_or_none(n_data.get("started_at")),
|
|
completed_at=_dt_or_none(n_data.get("completed_at")),
|
|
recurrence_rule=n_data.get("recurrence_rule"),
|
|
recurrence_next_spawn_at=_dt_or_none(
|
|
n_data.get("recurrence_next_spawn_at")
|
|
),
|
|
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")),
|
|
verify_with=n_data.get("verify_with"),
|
|
expires_when=n_data.get("expires_when"),
|
|
# _dt_or_none, NOT _dt: an absent stamp must stay absent. _dt
|
|
# substitutes now(), which would restore every never-checked
|
|
# note as checked at the moment of the restore — inverting the
|
|
# one signal the sweep reads.
|
|
verified_at=_dt_or_none(n_data.get("verified_at")),
|
|
)
|
|
session.add(note)
|
|
await session.flush()
|
|
if old_id is not None:
|
|
note_id_map[old_id] = note.id
|
|
stats["notes"] += 1
|
|
|
|
# Patch the two note->note edges now that every note has a new id.
|
|
# Both are ids in the SOURCE database, so writing either straight into
|
|
# the constructor would point at whatever record happens to hold that
|
|
# number here — a restore that succeeds and silently re-parents (#3182).
|
|
# An edge whose target did not survive the import is left NULL rather
|
|
# than guessed at.
|
|
for n_data in data.get("notes", []):
|
|
old_id = n_data.get("id")
|
|
if not old_id or old_id not in note_id_map:
|
|
continue
|
|
note_row = await session.get(Note, note_id_map[old_id])
|
|
if note_row is None:
|
|
continue
|
|
old_parent = n_data.get("parent_id")
|
|
if old_parent and old_parent in note_id_map:
|
|
note_row.parent_id = note_id_map[old_parent]
|
|
old_origin = n_data.get("arose_from_id")
|
|
if old_origin and old_origin in note_id_map:
|
|
note_row.arose_from_id = note_id_map[old_origin]
|
|
|
|
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.
|
|
|
|
Every model is constructed by a `_build_*` helper above, never inline.
|
|
That is what gives the import side a per-table unit the column guard can
|
|
hand a stand-in to (#4197); what stays here is the ordering, the flushes
|
|
and the id-map bookkeeping, which is the part a split would only make
|
|
harder to read.
|
|
"""
|
|
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,
|
|
"systems": 0, "record_systems": 0, "design_systems": 0,
|
|
"design_tokens": 0, "note_usage_events": 0, "rule_usage_events": 0,
|
|
"repo_bindings": 0,
|
|
"note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0,
|
|
"code_shape_uses": 0, "canonical_systems": 0,
|
|
"rule_systems": 0, "rule_relations": 0, "rule_versions": 0,
|
|
"retrieval_tuning_events": 0,
|
|
}
|
|
|
|
async with async_session() as session:
|
|
maps = _Maps()
|
|
|
|
# 1. Users
|
|
for u_data in data.get("users", []):
|
|
user = _build_user(u_data, maps)
|
|
session.add(user)
|
|
await session.flush()
|
|
maps.users[u_data["id"]] = user.id
|
|
stats["users"] += 1
|
|
|
|
# 2. Projects
|
|
for p_data in data.get("projects", []):
|
|
proj = _build_project(p_data, maps)
|
|
if proj is None:
|
|
continue
|
|
session.add(proj)
|
|
await session.flush()
|
|
maps.projects[p_data["id"]] = proj.id
|
|
stats["projects"] += 1
|
|
|
|
# 3. Milestones
|
|
for m_data in data.get("milestones", []):
|
|
ms = _build_milestone(m_data, maps)
|
|
if ms is None:
|
|
continue
|
|
session.add(ms)
|
|
await session.flush()
|
|
maps.milestones[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)
|
|
notes_with_origins: list[tuple[int, int]] = [] # (new_note_id, old_arose_from_id)
|
|
for n_data in data.get("notes", []):
|
|
note = _build_note(n_data, maps)
|
|
if note is None:
|
|
continue
|
|
session.add(note)
|
|
await session.flush()
|
|
maps.notes[n_data["id"]] = note.id
|
|
if n_data.get("parent_id"):
|
|
notes_with_parents.append((note.id, n_data["parent_id"]))
|
|
if n_data.get("arose_from_id"):
|
|
notes_with_origins.append((note.id, n_data["arose_from_id"]))
|
|
stats["notes"] += 1
|
|
|
|
# 4b. Patch the note->note edges. Deferred for the same reason
|
|
# parent_id always has been: these are ids in the SOURCE database
|
|
# (#3182). An edge whose target did not survive stays NULL.
|
|
for new_note_id, old_parent_id in notes_with_parents:
|
|
new_parent_id = maps.notes.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
|
|
for new_note_id, old_origin_id in notes_with_origins:
|
|
new_origin_id = maps.notes.get(old_origin_id)
|
|
if new_origin_id:
|
|
note_row = await session.get(Note, new_note_id)
|
|
if note_row:
|
|
note_row.arose_from_id = new_origin_id
|
|
|
|
# 5. TaskLogs
|
|
for tl_data in data.get("task_logs", []):
|
|
tl = _build_task_log(tl_data, maps)
|
|
if tl is None:
|
|
continue
|
|
session.add(tl)
|
|
stats["task_logs"] += 1
|
|
|
|
# 6. NoteDrafts
|
|
for nd_data in data.get("note_drafts", []):
|
|
nd = _build_note_draft(nd_data, maps)
|
|
if nd is None:
|
|
continue
|
|
session.add(nd)
|
|
stats["note_drafts"] += 1
|
|
|
|
# 7. NoteVersions
|
|
for nv_data in data.get("note_versions", []):
|
|
nv = _build_note_version(nv_data, maps)
|
|
if nv is None:
|
|
continue
|
|
session.add(nv)
|
|
stats["note_versions"] += 1
|
|
|
|
# 8. Settings
|
|
for s_data in data.get("settings", []):
|
|
setting = _build_setting(s_data, maps)
|
|
if setting is None:
|
|
continue
|
|
session.add(setting)
|
|
stats["settings"] += 1
|
|
|
|
# 8b. Retrieval tuning history (v16) — restored beside the settings it
|
|
# explains, and for the same reason: the numbers above are the state,
|
|
# these rows are the argument for it. From milestone 416 those dials
|
|
# move on the operator's behalf, so an install restored with the values
|
|
# and without the reasons is one tuned by nobody it can name.
|
|
for t_data in data.get("retrieval_tuning_events", []):
|
|
event = _build_retrieval_tuning_event(t_data, maps)
|
|
if event is None:
|
|
continue
|
|
session.add(event)
|
|
stats["retrieval_tuning_events"] += 1
|
|
|
|
# 9. Rulebooks (v3)
|
|
for rb_data in data.get("rulebooks", []):
|
|
rb = _build_rulebook(rb_data, maps)
|
|
if rb is None:
|
|
continue
|
|
session.add(rb)
|
|
await session.flush()
|
|
maps.rulebooks[rb_data["id"]] = rb.id
|
|
stats["rulebooks"] += 1
|
|
|
|
# 10. Topics (v3)
|
|
for t_data in data.get("rulebook_topics", []):
|
|
topic = _build_topic(t_data, maps)
|
|
if topic is None:
|
|
continue
|
|
session.add(topic)
|
|
await session.flush()
|
|
maps.topics[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", []):
|
|
rule = _build_rule(r_data, maps)
|
|
if rule is None:
|
|
continue # orphaned — its parent didn't restore
|
|
session.add(rule)
|
|
await session.flush()
|
|
maps.rules[r_data["id"]] = rule.id
|
|
stats["rules"] += 1
|
|
|
|
# 12-14. Rulebook subscriptions, rule and topic suppressions (v3-v14)
|
|
# `rulebook_subscriptions`, `rule_suppressions` and `topic_suppressions`
|
|
# are READ BY NOBODY since milestone 414 dropped their tables. An
|
|
# archive carrying them still imports, for the reason 14b gives.
|
|
|
|
# 14b. Always-on rulebook exclusions (v10, milestone 297)
|
|
# `rulebook_exclusions` was a v10 section and is READ BY NOBODY since
|
|
# milestone 394 removed the table. An archive carrying it still
|
|
# imports — the key is simply not looked at — because refusing a
|
|
# backup for remembering something we deleted would make every v10-v13
|
|
# archive unrestorable.
|
|
|
|
# --- v5 sections. Every one is data.get()-guarded, so a v2/v3/v4
|
|
# payload restores without them rather than failing on an absent key.
|
|
|
|
# 14c. The global area catalog, matched on SLUG. Runs BEFORE systems,
|
|
# which resolve their mapping through this map. The map is seeded from
|
|
# what this install already has, so the common case creates nothing.
|
|
existing_canonical = (await session.execute(
|
|
select(CanonicalSystem).where(CanonicalSystem.deleted_at.is_(None))
|
|
)).scalars().all()
|
|
for entry in existing_canonical:
|
|
maps.canonical_by_slug[entry.slug] = entry.id
|
|
for cs_data in data.get("canonical_systems", []):
|
|
entry = _build_canonical_system(cs_data, maps)
|
|
if entry is None:
|
|
continue
|
|
session.add(entry)
|
|
await session.flush()
|
|
maps.canonical_by_slug[entry.slug] = entry.id
|
|
stats["canonical_systems"] += 1
|
|
|
|
# 14d. A rule's area tags and its typed edges. Runs HERE, not beside the
|
|
# rules in section 11, because it needs both maps: the rule ids from
|
|
# there and the canonical slugs from 14c just above.
|
|
#
|
|
# This one is a raw insert on a join table with no model class, so it
|
|
# has no builder and no column guard — both ends ARE the row.
|
|
for rs in data.get("rule_systems", []):
|
|
mapped_rule = maps.rules.get(rs.get("rule_id", 0))
|
|
canonical_id = maps.canonical_by_slug.get(rs.get("canonical_slug") or "")
|
|
if mapped_rule is None or canonical_id is None:
|
|
continue
|
|
await session.execute(rule_systems_t.insert().values(
|
|
rule_id=mapped_rule, canonical_id=canonical_id,
|
|
))
|
|
stats["rule_systems"] += 1
|
|
|
|
for rr in data.get("rule_relations", []):
|
|
relation = _build_rule_relation(rr, maps)
|
|
if relation is None:
|
|
continue
|
|
session.add(relation)
|
|
stats["rule_relations"] += 1
|
|
|
|
# A rule's edit history (milestone 323). Must come after the rules
|
|
# themselves — the rule map is only populated above — and both ids are
|
|
# ids in the SOURCE database, which is #3182's arose_from_id trap.
|
|
for rv in data.get("rule_versions", []):
|
|
version = _build_rule_version(rv, maps)
|
|
if version is None:
|
|
continue
|
|
session.add(version)
|
|
stats["rule_versions"] += 1
|
|
|
|
# 15. Systems
|
|
for sy_data in data.get("systems", []):
|
|
system = _build_system(sy_data, maps)
|
|
if system is None:
|
|
continue
|
|
session.add(system)
|
|
await session.flush()
|
|
maps.systems[sy_data["id"]] = system.id
|
|
stats["systems"] += 1
|
|
|
|
# 16. Record<->system links
|
|
for rs in data.get("record_systems", []):
|
|
link = _build_record_system(rs, maps)
|
|
if link is None:
|
|
continue
|
|
session.add(link)
|
|
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.
|
|
for sup in data.get("note_supersessions", []):
|
|
claim = _build_note_supersession(sup, maps)
|
|
if claim is None:
|
|
continue
|
|
session.add(claim)
|
|
stats["note_supersessions"] += 1
|
|
|
|
# 17. Design systems, parent-first (the export orders them that way).
|
|
for ds_data in data.get("design_systems", []):
|
|
design = _build_design_system(ds_data, maps)
|
|
if design is None:
|
|
continue
|
|
session.add(design)
|
|
await session.flush()
|
|
maps.design_systems[ds_data["id"]] = design.id
|
|
stats["design_systems"] += 1
|
|
|
|
# 18. Design tokens
|
|
for t_data in data.get("design_tokens", []):
|
|
token = _build_design_token(t_data, maps)
|
|
if token is None:
|
|
continue
|
|
session.add(token)
|
|
stats["design_tokens"] += 1
|
|
|
|
# 19. Usage events
|
|
for ev in data.get("note_usage_events", []):
|
|
event = _build_usage_event(ev, maps)
|
|
if event is None:
|
|
continue
|
|
session.add(event)
|
|
stats["note_usage_events"] += 1
|
|
|
|
# The rule twin. Must come after the rules themselves; the rule map is
|
|
# populated there.
|
|
for ev in data.get("rule_usage_events", []):
|
|
event = _build_rule_usage_event(ev, maps)
|
|
if event is None:
|
|
continue
|
|
session.add(event)
|
|
stats["rule_usage_events"] += 1
|
|
|
|
# 20. Repo bindings
|
|
for rb_data in data.get("repo_bindings", []):
|
|
binding = _build_repo_binding(rb_data, maps)
|
|
if binding is None:
|
|
continue
|
|
session.add(binding)
|
|
stats["repo_bindings"] += 1
|
|
|
|
# 21. Code shapes (v7, #2787)
|
|
for cs_data in data.get("code_shapes", []):
|
|
shape = _build_code_shape(cs_data, maps)
|
|
if shape is None:
|
|
continue
|
|
session.add(shape)
|
|
await session.flush()
|
|
if cs_data.get("id"):
|
|
maps.shapes[int(cs_data["id"])] = shape.id
|
|
stats["code_shapes"] += 1
|
|
|
|
# v8: the ledger's history rides its shapes.
|
|
for ev in data.get("code_shape_events", []):
|
|
event = _build_code_shape_event(ev, maps)
|
|
if event is None:
|
|
continue
|
|
session.add(event)
|
|
stats["code_shape_events"] += 1
|
|
|
|
# v9: consumption edges (#2870).
|
|
for use in data.get("code_shape_uses", []):
|
|
edge = _build_code_shape_use(use, maps)
|
|
if edge is None:
|
|
continue
|
|
session.add(edge)
|
|
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.
|
|
#
|
|
# An UPDATE on a row this restore already wrote, not a construction,
|
|
# which is why it has no builder: there is no set of kwargs for a
|
|
# column guard to read.
|
|
for p_data in data.get("projects", []):
|
|
new_pid = maps.projects.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 = maps.design_systems.get(old_ds)
|
|
inception = p_data.get("inception")
|
|
if isinstance(inception, dict):
|
|
choices = dict(inception.get("choices") or {})
|
|
# DROPPED, not remapped (milestone 394). A pre-394 archive
|
|
# carries the retired exclusion choice; restoring it would put
|
|
# a key back that `validate_inception` now rejects as unknown,
|
|
# so the next edit to that project would fail on data this
|
|
# importer wrote.
|
|
choices.pop("exclude_always_on_rulebooks", None)
|
|
# Same for subscribe_rulebooks since milestone 414.
|
|
choices.pop("subscribe_rulebooks", None)
|
|
ds = choices.get("design_system_id")
|
|
choices["design_system_id"] = maps.design_systems.get(ds) if ds else None
|
|
proj.inception = {**inception, "choices": choices}
|
|
|
|
await session.commit()
|
|
|
|
logger.info("Restored v2/v3 backup: %s", stats)
|
|
return stats
|