velxio/backend/scripts/backfill_boards_2026_05.py

525 lines
18 KiB
Python
Raw Normal View History

feat: persist multi-board projects + add auto-save The project save/load pipeline only persisted a single `board_type`, so multi-board workspaces silently lost every board except the active one on save, and wires referencing the dropped boards' IDs orphaned to the canvas corner on reload. An audit of the production backup found 74/306 projects (24%) with at least one orphaned wire and 174/301 non-trivial projects whose code was still the default Blink template — strong signal that users save once and never re-save. Backend - Add `boards_json` column on `projects` with idempotent ALTER TABLE in the lifespan migration list. - New `FileGroup` schema + `file_groups` array on ProjectCreate/Update/Response. Legacy `files`/`code` kept for back-compat. - `project_files.py` now uses `{pid}/{groupId}/{filename}` subdirs via `read_groups`/`write_groups`. Legacy flat layouts are auto-promoted on read; legacy single-list `files` only updates the active group, leaving other boards' files intact. - `_persist_files_from_body` honors file_groups → files → code priority. Frontend - `useSimulatorStore.addBoard` accepts an optional `explicitId` so saved board IDs can be restored verbatim (wires reference IDs literally). - New `loadProjectState({boards, fileGroups, components, wires, activeBoardId})` action: tears down current boards, recreates from the payload, restores file groups atomically, recalculates wire positions on the next frame, and refreshes the Interconnect. - `useEditorStore.replaceFileGroups` for atomic multi-group restore. - `SaveProjectModal` and `ProjectByIdPage`/`ProjectPage` now go through `buildSavePayload` / `buildLoadPayload` (handles pre-backfill projects by synthesising a default board from `board_type`). Auto-save (#useAutoSaveProject hook) - 2.5s debounced silent PUT triggered ONLY when an authenticated user has a `currentProject` with a UUID. State hash detects real changes vs. UI-only churn; baseline is reset on project load so the just-loaded state isn't immediately re-saved. - `beforeunload` flush via `fetch keepalive: true` (supports PUT + credentials, survives unload). - Compact status indicator in `AppHeader` (idle/dirty/saving/saved/error). Backfill script (one-off, idempotent) - `backend/scripts/backfill_boards_2026_05.py` populates `boards_json` for legacy projects. Heuristic per project, based on which board IDs the wires reference: Case A — wires only ref 'arduino-uno' but board_type ≠ uno: rename id→board_type and rewrite wire endpoints. Case B — single-board normal: keep verbatim. Case C — multi-board: recreate one board per distinct ref, infer kind by stripping trailing -N suffix. Also moves any flat files into the active board's group subdir. Stdlib-only, runs from host or `docker exec`. Docker - `Dockerfile.standalone` now copies `backend/scripts/` into the image so the backfill is callable via `docker exec velxio-app python /app/scripts/backfill_boards_2026_05.py --apply`. Verified locally on the restored production backup (363 projects): 33 Case A, 316 Case B, 14 Case C, 135 wire endpoints renamed, 0 orphans. Re-running the script after apply skips all 363 (idempotent). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:43:33 +07:00
"""
One-time backfill: populate ``projects.boards_json`` and reorganise on-disk
files into per-board subdirs for projects created before the multi-board
persistence change.
Pre-existing schema only stored ``board_type`` (a single string) plus flat
``components_json`` / ``wires_json`` columns. The frontend, however, has been
multi-board-capable for a while wire endpoints reference board IDs literally
(e.g. ``componentId='esp32-cam'``). On reload, ``setBoardType`` mutated the
sole default board's ``boardKind`` but kept its ``id`` fixed at
``'arduino-uno'`` (the legacy ``INITIAL_BOARD_ID``), so any wire that pointed
to a non-Uno board id was orphaned and visually anchored to (0, 0).
Heuristic per project (idempotent projects whose ``boards_json`` already
holds a non-empty array are skipped):
Case A single-board legacy
Wires reference only ``'arduino-uno'`` AND ``board_type != 'arduino-uno'``.
The user really had ONE board of kind ``board_type``; the id is residual
from the ``INITIAL_BOARD_ID`` quirk. Rename the wire endpoints
``'arduino-uno'`` -> ``board_type`` and create one board with
``id == board_type``.
Case B single-board normal
Wires reference at most one board id, and that id matches ``board_type``
(or there are no wires). Create one board verbatim.
Case C multi-board
Wires reference more than one distinct board id. Create one BoardInstance
per distinct id, in order of first appearance, laid out left-to-right at
(50 + i*350, 50). The active board is the one matching ``board_type`` if
present, otherwise the first.
Refs whose kind cannot be inferred (e.g. typos like ``'esp'``) are left alone:
the wires stay orphaned and are reported in the summary for manual review.
Usage::
# Dry-run (default) — prints the plan, touches nothing
python -m backend.scripts.backfill_boards_2026_05
# Apply changes to DB and disk
python -m backend.scripts.backfill_boards_2026_05 --apply
# Run for a single project
python -m backend.scripts.backfill_boards_2026_05 --apply --project-id <uuid>
# Custom DB / data path (defaults to backend/.env's values)
python -m backend.scripts.backfill_boards_2026_05 --db data/velxio.db --data-dir data
"""
from __future__ import annotations
import argparse
import dataclasses
import json
import os
import re
import sqlite3
import sys
from collections import Counter
from pathlib import Path
# ── Canonical board kinds (mirrors frontend/src/types/board.ts) ────────────────
BOARD_KINDS: set[str] = {
"arduino-uno",
"arduino-nano",
"arduino-mega",
"attiny85",
"raspberry-pi-pico",
"pi-pico-w",
"raspberry-pi-3",
"esp32",
"esp32-devkit-c-v4",
"esp32-cam",
"wemos-lolin32-lite",
"esp32-s3",
"xiao-esp32-s3",
"arduino-nano-esp32",
"esp32-c3",
"xiao-esp32-c3",
"aitewinrobot-esp32c3-supermini",
}
# Suffix added by addBoard for additional instances of the same kind
INSTANCE_SUFFIX_RE = re.compile(r"^(?P<kind>.+?)-(?P<n>\d+)$")
DEFAULT_X = 50
DEFAULT_Y = 50
BOARD_X_STEP = 350
def infer_kind(ref: str) -> str | None:
"""Map a wire endpoint componentId to a BoardKind, or None if unknown.
'esp32' -> 'esp32', 'esp32-cam' -> 'esp32-cam', 'esp32-2' -> 'esp32',
'arduino-uno-3' -> 'arduino-uno', 'esp' -> None.
"""
if ref in BOARD_KINDS:
return ref
m = INSTANCE_SUFFIX_RE.match(ref)
if m and m.group("kind") in BOARD_KINDS:
return m.group("kind")
return None
def make_board(board_id: str, board_kind: str, x: int, y: int) -> dict:
"""A serialisable BoardInstance matching frontend/src/types/board.ts."""
return {
"id": board_id,
"boardKind": board_kind,
"x": x,
"y": y,
"running": False,
"compiledProgram": None,
"serialOutput": "",
"serialBaudRate": 0,
"serialMonitorOpen": False,
"activeFileGroupId": f"group-{board_id}",
"languageMode": "arduino",
}
@dataclasses.dataclass
class PlanItem:
project_id: str
name: str
case: str # 'A' | 'B' | 'C' | 'skip-already-migrated' | 'skip-empty'
board_type: str
new_boards: list[dict]
wires_renamed: int
new_wires_json: str | None
new_disk_layout: dict | None # {"target_group": str, "files": list[dict]} or None
orphan_refs: list[str]
def plan_for_project(
project_id: str,
name: str,
board_type: str,
components_json: str,
wires_json: str,
boards_json: str,
project_files_dir: Path,
) -> PlanItem:
# Idempotent skip: already migrated.
try:
existing = json.loads(boards_json or "[]")
except (ValueError, TypeError):
existing = []
if isinstance(existing, list) and existing:
return PlanItem(
project_id=project_id,
name=name,
case="skip-already-migrated",
board_type=board_type,
new_boards=[],
wires_renamed=0,
new_wires_json=None,
new_disk_layout=None,
orphan_refs=[],
)
try:
components = json.loads(components_json or "[]")
except (ValueError, TypeError):
components = []
try:
wires = json.loads(wires_json or "[]")
except (ValueError, TypeError):
wires = []
comp_ids: set[str] = set()
for c in components:
if isinstance(c, dict):
cid = c.get("id")
if isinstance(cid, str) and cid:
comp_ids.add(cid)
# Collect unique wire-endpoint refs that are NOT components — these are
# board ids. Preserve order of first appearance.
seen: list[str] = []
seen_set: set[str] = set()
for w in wires:
if not isinstance(w, dict):
continue
for ep_key in ("start", "end"):
ep = w.get(ep_key)
if not isinstance(ep, dict):
continue
cid = ep.get("componentId")
if isinstance(cid, str) and cid and cid not in comp_ids and cid not in seen_set:
seen.append(cid)
seen_set.add(cid)
bt = board_type or "arduino-uno"
# ── Case selection ────────────────────────────────────────────────────────
new_boards: list[dict] = []
new_wires_json: str | None = None
wires_renamed = 0
orphans: list[str] = []
case: str
if not seen:
# No wires, or wires only reference components. Single board from board_type.
case = "B"
new_boards = [make_board(bt, bt, DEFAULT_X, DEFAULT_Y)]
elif seen == ["arduino-uno"] and bt != "arduino-uno":
# Case A: legacy id residue. Rename to board_type.
case = "A"
new_boards = [make_board(bt, bt, DEFAULT_X, DEFAULT_Y)]
# Rewrite wires
new_wires = []
for w in wires:
if not isinstance(w, dict):
new_wires.append(w)
continue
new_w = dict(w)
for ep_key in ("start", "end"):
ep = new_w.get(ep_key)
if isinstance(ep, dict) and ep.get("componentId") == "arduino-uno":
new_w[ep_key] = {**ep, "componentId": bt}
wires_renamed += 1
new_wires.append(new_w)
new_wires_json = json.dumps(new_wires)
elif len(seen) == 1:
# Case B: single-board, wire ids match (or board_type is arduino-uno
# and wires use 'arduino-uno', which is consistent).
case = "B"
only = seen[0]
kind = infer_kind(only)
if kind is None:
# Truly unknown ref — fall back to board_type, log orphan
orphans.append(only)
new_boards = [make_board(bt, bt, DEFAULT_X, DEFAULT_Y)]
else:
# If the single ref's kind matches board_type, board_id == ref.
# Otherwise the ref is some other id (rare, but possible) — keep
# the ref as the board id since wires reference it literally.
new_boards = [make_board(only, kind, DEFAULT_X, DEFAULT_Y)]
else:
# Case C: multi-board.
case = "C"
for i, ref in enumerate(seen):
kind = infer_kind(ref)
if kind is None:
orphans.append(ref)
continue
new_boards.append(
make_board(ref, kind, DEFAULT_X + i * BOARD_X_STEP, DEFAULT_Y)
)
# Active board sorting: ensure the board matching board_type sits first
# (the frontend treats boards[0] as the default visible board).
if new_boards:
for i, b in enumerate(new_boards):
if b["id"] == bt or b["boardKind"] == bt:
if i > 0:
new_boards.insert(0, new_boards.pop(i))
break
# ── Disk layout: promote flat files into a group dir ──────────────────────
new_disk_layout: dict | None = None
if new_boards and project_files_dir.exists():
# Collect any flat files at the project root (skip subdirs).
flat_files = []
for entry in project_files_dir.iterdir():
if entry.is_file():
try:
flat_files.append(
{
"name": entry.name,
"content": entry.read_text(encoding="utf-8"),
}
)
except (OSError, UnicodeDecodeError):
# Binary or unreadable file — skip but keep on disk
pass
if flat_files:
target_group = new_boards[0]["activeFileGroupId"]
new_disk_layout = {"target_group": target_group, "files": flat_files}
return PlanItem(
project_id=project_id,
name=name,
case=case,
board_type=bt,
new_boards=new_boards,
wires_renamed=wires_renamed,
new_wires_json=new_wires_json,
new_disk_layout=new_disk_layout,
orphan_refs=orphans,
)
def apply_plan(plan: PlanItem, conn: sqlite3.Connection, data_dir: Path) -> None:
"""Persist plan to DB and disk. Idempotent per row."""
if plan.case in ("skip-already-migrated", "skip-empty"):
return
boards_json = json.dumps(plan.new_boards)
if plan.new_wires_json is not None:
conn.execute(
"UPDATE projects SET boards_json = ?, wires_json = ?, "
"updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(boards_json, plan.new_wires_json, plan.project_id),
)
else:
conn.execute(
"UPDATE projects SET boards_json = ?, "
"updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(boards_json, plan.project_id),
)
# Disk layout: move flat files into the active group's subdir.
if plan.new_disk_layout:
proj_dir = data_dir / "projects" / plan.project_id
target_dir = proj_dir / plan.new_disk_layout["target_group"]
target_dir.mkdir(parents=True, exist_ok=True)
for f in plan.new_disk_layout["files"]:
(target_dir / f["name"]).write_text(f["content"], encoding="utf-8")
# Remove the flat copy
flat = proj_dir / f["name"]
if flat.is_file():
try:
flat.unlink()
except OSError:
pass
def parse_args() -> argparse.Namespace:
ap = argparse.ArgumentParser(
description=(__doc__ or "").split("\n\n", 1)[0]
)
ap.add_argument(
"--apply",
action="store_true",
help="Persist changes (default is dry-run).",
)
ap.add_argument(
"--db",
default=None,
help="Path to velxio.db (default: derived from DATABASE_URL or "
"data/velxio.db).",
)
ap.add_argument(
"--data-dir",
default=None,
help="Path to data directory containing 'projects/' (default: "
"DATA_DIR env var or 'data').",
)
ap.add_argument(
"--project-id",
default=None,
help="Run for a single project id (UUID).",
)
ap.add_argument(
"--limit",
type=int,
default=None,
help="Stop after N projects (for spot-checking).",
)
return ap.parse_args()
def resolve_paths(args: argparse.Namespace) -> tuple[Path, Path]:
db_path = args.db
if not db_path:
# Try DATABASE_URL of the form sqlite+aiosqlite:///<path>
url = os.environ.get("DATABASE_URL", "")
if url.startswith("sqlite"):
after = url.split(":///", 1)[-1]
if after:
db_path = after
if not db_path:
# Fall back to the canonical local restored path
for candidate in ("data/velxio.db", "../data/velxio.db", "velxio.db"):
if Path(candidate).exists():
db_path = candidate
break
if not db_path:
raise SystemExit("Could not locate velxio.db. Pass --db <path>.")
data_dir = args.data_dir or os.environ.get("DATA_DIR")
if not data_dir:
# Same parent as the DB, sibling to the file
data_dir = str(Path(db_path).parent)
return Path(db_path), Path(data_dir)
def main() -> int:
args = parse_args()
db_path, data_dir = resolve_paths(args)
if not db_path.exists():
print(f"DB not found: {db_path}", file=sys.stderr)
return 1
print(f"DB: {db_path}")
print(f"DATA_DIR: {data_dir}")
print(f"Mode: {'APPLY' if args.apply else 'DRY-RUN'}")
print()
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
# Schema check: boards_json must exist (added by lifespan auto-migration).
cols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()}
if "boards_json" not in cols:
print(
"FATAL: projects.boards_json column does not exist. Start the "
"backend at least once so the lifespan auto-migration runs, or "
"execute manually:\n"
" ALTER TABLE projects ADD COLUMN boards_json TEXT NOT NULL "
"DEFAULT '[]';",
file=sys.stderr,
)
return 2
if args.project_id:
rows = conn.execute(
"SELECT id, name, board_type, components_json, wires_json, boards_json "
"FROM projects WHERE id = ?",
(args.project_id,),
).fetchall()
else:
rows = conn.execute(
"SELECT id, name, board_type, components_json, wires_json, boards_json "
"FROM projects ORDER BY created_at"
).fetchall()
if args.limit:
rows = rows[: args.limit]
case_counts: Counter[str] = Counter()
plans: list[PlanItem] = []
total_wires_renamed = 0
all_orphans: list[tuple[str, str, str]] = [] # (project_id, name, ref)
for r in rows:
proj_dir = data_dir / "projects" / r["id"]
plan = plan_for_project(
project_id=r["id"],
name=r["name"] or "",
board_type=r["board_type"] or "arduino-uno",
components_json=r["components_json"] or "[]",
wires_json=r["wires_json"] or "[]",
boards_json=r["boards_json"] or "[]",
project_files_dir=proj_dir,
)
plans.append(plan)
case_counts[plan.case] += 1
total_wires_renamed += plan.wires_renamed
for ref in plan.orphan_refs:
all_orphans.append((plan.project_id, plan.name, ref))
# ── Print per-project plan ────────────────────────────────────────────────
for p in plans:
if p.case == "skip-already-migrated":
continue
ids = [b["id"] for b in p.new_boards]
extras = []
if p.wires_renamed:
extras.append(f"renamed {p.wires_renamed} wire endpoints")
if p.new_disk_layout:
extras.append(
f"move {len(p.new_disk_layout['files'])} flat file(s) -> "
f"{p.new_disk_layout['target_group']}/"
)
if p.orphan_refs:
extras.append(f"orphan refs={p.orphan_refs}")
suffix = (" | " + "; ".join(extras)) if extras else ""
name_repr = (p.name[:35] + "...") if len(p.name) > 38 else p.name
try:
print(
f"[{p.case}] {p.project_id[:8]} {name_repr!r:40s} "
f"board_type={p.board_type!r:25s} -> boards={ids}{suffix}"
)
except UnicodeEncodeError:
# Windows cp1252 console can't render some emojis/glyphs
safe = name_repr.encode("ascii", "replace").decode("ascii")
print(
f"[{p.case}] {p.project_id[:8]} {safe!r:40s} "
f"board_type={p.board_type!r:25s} -> boards={ids}{suffix}"
)
# ── Summary ───────────────────────────────────────────────────────────────
print()
print("=" * 70)
print(f"Total projects considered: {len(plans)}")
for case in ("A", "B", "C", "skip-already-migrated"):
n = case_counts.get(case, 0)
if n:
label = {
"A": "Case A — legacy id rename (single-board)",
"B": "Case B — single-board normal (no wire rename)",
"C": "Case C — multi-board recovered",
"skip-already-migrated": "Skipped (boards_json already populated)",
}[case]
print(f" {label}: {n}")
print(f"Wire endpoints renamed: {total_wires_renamed}")
if all_orphans:
print()
print(f"WARNING: {len(all_orphans)} orphan board refs (kind unknown):")
for pid, name, ref in all_orphans[:30]:
print(f" {pid[:8]} {name!r:30s} ref={ref!r}")
if len(all_orphans) > 30:
print(f" ... and {len(all_orphans) - 30} more")
print()
if args.apply:
for p in plans:
apply_plan(p, conn, data_dir)
conn.commit()
print("Changes committed.")
else:
print("Dry-run only. Re-run with --apply to commit.")
conn.close()
return 0
if __name__ == "__main__":
sys.exit(main())