cctally 1.27.0 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -202,9 +202,38 @@ class DowngradeDetected(Exception):
202
202
  self.db_label = db_label
203
203
  self.db_version = db_version
204
204
  self.max_known = max_known
205
+ db_key = "cache" if db_label.startswith("cache") else "stats"
205
206
  super().__init__(
206
207
  f"{db_label} is at version {db_version} but this cctally "
207
- f"only knows up to {max_known}."
208
+ f"only knows up to {max_known}. A newer/unreleased cctally likely "
209
+ f"touched this data dir. Run `cctally db recover --db {db_key}` to "
210
+ f"revert it to the known schema head (cache.db is re-derivable and "
211
+ f"recovers without --yes; stats.db needs --yes and may require a "
212
+ f"re-record afterward)."
213
+ )
214
+
215
+
216
+ class ProdMigrationRefused(Exception):
217
+ """Raised by the dispatcher when a git-checkout binary would forward-migrate
218
+ the REAL prod data dir (~/.local/share/cctally), which would brick the
219
+ installed release with DowngradeDetected (issue #142).
220
+
221
+ Escape hatch: set CCTALLY_ALLOW_PROD_MIGRATION=1. The guard is
222
+ connection-scoped + password-DB-resolved (see _would_block_prod_migration
223
+ + _cctally_core._real_prod_data_dir) so it never fires on :memory:/temp/
224
+ fake-HOME test connections. Spec:
225
+ docs/superpowers/specs/2026-06-05-prod-migration-guard-design.md."""
226
+
227
+ def __init__(self, db_label: str, next_migration: str):
228
+ self.db_label = db_label
229
+ self.next_migration = next_migration
230
+ super().__init__(
231
+ f"cctally: refusing to apply migration '{next_migration}' "
232
+ f"({db_label}) to the prod data dir (~/.local/share/cctally) from "
233
+ f"a dev checkout — a checkout may carry migrations your installed "
234
+ f"cctally can't read, which would brick it (DowngradeDetected). "
235
+ f"Point CCTALLY_DATA_DIR at a scratch/dev dir, or run the installed "
236
+ f"binary. Override with CCTALLY_ALLOW_PROD_MIGRATION=1."
208
237
  )
209
238
 
210
239
 
@@ -430,11 +459,149 @@ def _bootstrap_rename_legacy_markers(conn: sqlite3.Connection, db_label: str) ->
430
459
  _clear_migration_error_log_entries(old)
431
460
 
432
461
 
462
+ def _conn_db_dir(conn: sqlite3.Connection) -> "pathlib.Path | None":
463
+ """Resolved directory of the connection's `main` database file, or None for
464
+ an in-memory / no-file connection (PRAGMA database_list returns '' there).
465
+ Tuple-indexed so it works on the cache.db connection (no row_factory)."""
466
+ for row in conn.execute("PRAGMA database_list").fetchall():
467
+ if row[1] == "main":
468
+ db_file = row[2]
469
+ if not db_file:
470
+ return None
471
+ return pathlib.Path(db_file).resolve().parent
472
+ return None
473
+
474
+
475
+ def _would_block_prod_migration(conn: sqlite3.Connection) -> bool:
476
+ """True iff a git-checkout binary is about to migrate a DB that physically
477
+ lives in the REAL prod data dir (issue #142).
478
+
479
+ Connection-scoped (NOT global APP_DIR) so :memory:/temp/scratch connections
480
+ never trip it; HOME-faking-immune via _real_prod_data_dir (password DB, not
481
+ $HOME); suppressor-INDEPENDENT raw .git check so it still fires under the
482
+ test-suite's CCTALLY_DISABLE_DEV_AUTODETECT. Escape: CCTALLY_ALLOW_PROD_MIGRATION."""
483
+ if os.environ.get("CCTALLY_ALLOW_PROD_MIGRATION"):
484
+ return False
485
+ if not (_cctally_core._repo_root() / ".git").exists():
486
+ return False
487
+ db_dir = _conn_db_dir(conn)
488
+ if db_dir is None:
489
+ return False
490
+ try:
491
+ return db_dir == _cctally_core._real_prod_data_dir().resolve()
492
+ except OSError:
493
+ return False
494
+
495
+
496
+ def _first_pending_migration_name(
497
+ conn: sqlite3.Connection, registry: "list[Migration]", cur_version: int
498
+ ) -> str:
499
+ """Best-effort name of the first not-yet-applied migration, for the refusal
500
+ message. Marker-aware (handles skip-gaps + db-unskip's user_version=0) with
501
+ a raw-index fallback. Legacy unprefixed markers are an accepted imperfection
502
+ — the name is a human hint, not load-bearing."""
503
+ try:
504
+ applied = {r[0] for r in conn.execute(
505
+ "SELECT name FROM schema_migrations").fetchall()}
506
+ except sqlite3.OperationalError:
507
+ applied = set()
508
+ try:
509
+ skipped = {r[0] for r in conn.execute(
510
+ "SELECT name FROM schema_migrations_skipped").fetchall()}
511
+ except sqlite3.OperationalError:
512
+ skipped = set()
513
+ for m in registry:
514
+ if m.name not in applied and m.name not in skipped:
515
+ return m.name
516
+ return registry[cur_version].name
517
+
518
+
519
+ def _recover_version_ahead(
520
+ conn: sqlite3.Connection,
521
+ registry: list[Migration],
522
+ db_label: str,
523
+ ) -> dict:
524
+ """Reconcile a version-ahead DB down to this binary's known head (issue #145).
525
+
526
+ A DB whose ``PRAGMA user_version`` exceeds ``len(registry)`` was last
527
+ touched by a newer/unreleased cctally. cache.db is fully re-derivable, so
528
+ we heal in place instead of bricking: trim the unknown (ahead) markers from
529
+ BOTH ledger tables, then reconcile ``user_version``.
530
+
531
+ We DELIBERATELY do not blind-set ``user_version = len(registry)``: the
532
+ dispatcher treats ``schema_migrations_skipped`` as authoritative and only
533
+ advances ``user_version`` when every known migration is applied-or-skipped.
534
+ So we trim unknown rows from both tables (Codex review P1 #1), then set
535
+ ``user_version = len(registry)`` only if every known migration is
536
+ applied-or-skipped; otherwise ``0`` so the dispatcher's normal walk re-runs
537
+ the still-pending known migrations idempotently (Codex review P1 #2) — never
538
+ cementing a fast-path past a genuinely-missing known migration.
539
+
540
+ Extra tables/columns the unknown migration created are left inert (SQLite
541
+ tolerates them; cache is re-derivable). Idempotent: no-op when not ahead.
542
+ Tolerates absent ledger tables (Codex review P2).
543
+
544
+ Returns ``{"reverted_from", "reverted_to", "trimmed"}`` for the caller's
545
+ breadcrumb / ``db recover`` report.
546
+ """
547
+ cur_version = conn.execute("PRAGMA user_version").fetchone()[0]
548
+ if cur_version <= len(registry):
549
+ return {"reverted_from": cur_version, "reverted_to": cur_version, "trimmed": 0}
550
+
551
+ aliases = _LEGACY_MARKER_ALIASES_BY_DB.get(db_label, {})
552
+ known = {m.name for m in registry} | set(aliases.keys()) | set(aliases.values())
553
+ placeholders = ",".join("?" for _ in known) if known else "''"
554
+ params = tuple(known)
555
+
556
+ trimmed = 0
557
+ for table in ("schema_migrations", "schema_migrations_skipped"):
558
+ try:
559
+ cur = conn.execute(
560
+ f"DELETE FROM {table} WHERE name NOT IN ({placeholders})", params
561
+ )
562
+ trimmed += max(cur.rowcount, 0) # DELETE rowcount is always >= 0
563
+ except sqlite3.OperationalError:
564
+ pass # table absent → nothing to trim there
565
+
566
+ applied: set[str] = set()
567
+ skipped: set[str] = set()
568
+ for table, dest in (("schema_migrations", applied),
569
+ ("schema_migrations_skipped", skipped)):
570
+ try:
571
+ for row in conn.execute(f"SELECT name FROM {table}").fetchall():
572
+ dest.add(row[0])
573
+ except sqlite3.OperationalError:
574
+ pass
575
+
576
+ all_known_done = all((m.name in applied or m.name in skipped) for m in registry)
577
+ new_version = len(registry) if all_known_done else 0
578
+ conn.execute(f"PRAGMA user_version = {new_version}")
579
+ conn.commit()
580
+ return {"reverted_from": cur_version, "reverted_to": new_version, "trimmed": trimmed}
581
+
582
+
583
+ def _stamp_applied(conn, name, applied_at_utc=None):
584
+ """Persist the schema_migrations marker for ``name``, then commit.
585
+
586
+ Central stamp owned by the dispatcher (issue #140). Handlers no longer
587
+ self-stamp — EXCEPT cache 001, whose stamp must stay atomic with its
588
+ destructive wipe; for that one this call is an idempotent no-op.
589
+ ``INSERT OR IGNORE`` so a pre-existing row (cache 001, or a concurrent
590
+ winner) never raises.
591
+ """
592
+ conn.execute(
593
+ "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) VALUES (?, ?)",
594
+ (name, applied_at_utc or now_utc_iso()),
595
+ )
596
+ conn.commit()
597
+
598
+
433
599
  def _run_pending_migrations(
434
600
  conn: sqlite3.Connection,
435
601
  *,
436
602
  registry: list[Migration],
437
603
  db_label: str,
604
+ recover_version_ahead: bool = False,
438
605
  ) -> None:
439
606
  """Apply pending migrations from ``registry`` against ``conn``.
440
607
 
@@ -475,9 +642,31 @@ def _run_pending_migrations(
475
642
  """
476
643
  cur_version = conn.execute("PRAGMA user_version").fetchone()[0]
477
644
  if cur_version > len(registry):
478
- raise DowngradeDetected(
479
- db_label, db_version=cur_version, max_known=len(registry),
480
- )
645
+ if recover_version_ahead:
646
+ # cache.db is re-derivable — heal in place instead of bricking (#145).
647
+ info = _recover_version_ahead(conn, registry, db_label)
648
+ eprint(
649
+ f"cctally: {db_label} was ahead (v{info['reverted_from']} > "
650
+ f"known v{len(registry)}); trimmed unknown migration state and "
651
+ f"reconciled to the known head (cache is re-derivable). Run "
652
+ f"'cctally cache-sync --rebuild' for a full rebuild."
653
+ )
654
+ cur_version = conn.execute("PRAGMA user_version").fetchone()[0]
655
+ # common case: cur_version == len(registry) → fast-path below.
656
+ # adversarial (a known marker was missing): cur_version == 0 →
657
+ # falls through to the normal pending-loop, which reconciles.
658
+ # NOTE: on that adversarial fall-through against a prod cache.db,
659
+ # _recover_version_ahead has ALREADY committed user_version=0, so
660
+ # the prod-migration guard below ("user_version provably unchanged")
661
+ # is reached with user_version already lowered. That is acceptable
662
+ # ONLY because heal opts in for cache.db, which is re-derivable — a
663
+ # reset-to-0 then ProdMigrationRefused just makes the next legit
664
+ # open re-walk. The guard's "unchanged" invariant holds for stats.db
665
+ # (never heals) and for the non-heal path.
666
+ else:
667
+ raise DowngradeDetected(
668
+ db_label, db_version=cur_version, max_known=len(registry),
669
+ )
481
670
  if cur_version == len(registry):
482
671
  # When the registry is currently empty (today's cache.db case),
483
672
  # still leave the schema_migrations table behind so a later
@@ -504,6 +693,19 @@ def _run_pending_migrations(
504
693
  )
505
694
  return # fast path
506
695
 
696
+ # Prod-migration guard (issue #142): a git-checkout binary must not
697
+ # forward-migrate the real prod data dir — that bumps user_version past
698
+ # what the installed release knows and bricks it with DowngradeDetected.
699
+ # We are past the two early returns, so cur_version < len(registry): there
700
+ # ARE pending migrations that would advance user_version. Refuse BEFORE
701
+ # bootstrap-rename / fresh-install detection / any marker write, so
702
+ # user_version is provably unchanged. Connection-scoped so it only fires
703
+ # on the real prod DB files, never on :memory:/temp/scratch test conns.
704
+ if _would_block_prod_migration(conn):
705
+ raise ProdMigrationRefused(
706
+ db_label, _first_pending_migration_name(conn, registry, cur_version)
707
+ )
708
+
507
709
  # Track whether schema_migrations existed before this open so we can
508
710
  # detect the fresh-install path. After bootstrap, even a "first time
509
711
  # opened with framework code" DB might have rows from the legacy
@@ -606,6 +808,11 @@ def _run_pending_migrations(
606
808
  "weekly_cost_snapshots",
607
809
  "five_hour_blocks",
608
810
  "percent_milestones",
811
+ # budget milestone tables tracked by 011 (#137); empty on fresh
812
+ # installs, so this only guards a hand-dropped schema_migrations DB.
813
+ "budget_milestones",
814
+ "projected_milestones",
815
+ "codex_budget_milestones",
609
816
  ),
610
817
  "cache.db": ("session_entries",),
611
818
  }.get(db_label, ())
@@ -636,6 +843,7 @@ def _run_pending_migrations(
636
843
  qualified_name = f"{db_label}:{m.name}"
637
844
  try:
638
845
  m.handler(conn)
846
+ _stamp_applied(conn, m.name, now_iso) # central stamp (#140)
639
847
  _clear_migration_error_log_entries(qualified_name)
640
848
  applied.add(m.name)
641
849
  except MigrationGateNotMet as gate_exc:
@@ -713,25 +921,18 @@ def _backfill_five_hour_block_models(conn: sqlite3.Connection) -> None:
713
921
  `DELETE FROM five_hour_blocks` followed by re-backfill doesn't
714
922
  leave duplicates.
715
923
 
716
- Always inserts the schema_migrations marker at the end (inside the
717
- same transaction) so the gate closes regardless of how many child
718
- rows were written empty `session_entries` for a block (real
719
- users with API/web-only blocks) yields zero child rows but MUST
720
- still close the gate (regression scenario Q2).
924
+ The gate closes regardless of how many child rows were written —
925
+ empty `session_entries` for a block (real users with API/web-only
926
+ blocks) yields zero child rows but MUST still be marked applied
927
+ (regression scenario Q2). The dispatcher central-stamps the
928
+ schema_migrations marker on this handler's clean return (#140).
721
929
  """
722
930
  # Empty-table fast path: with no parent five_hour_blocks rows, this
723
- # backfill has nothing to do. We still must close the gate so the
724
- # dispatcher sees us as applied. INSERT OR IGNORE the marker and
725
- # return (replaces the prior `has_blocks` outer gate from the
726
- # pre-framework era).
931
+ # backfill has nothing to do. Return cleanly so the dispatcher
932
+ # central-stamps us as applied (#140) replaces the prior
933
+ # `has_blocks` outer gate from the pre-framework era.
727
934
  if not conn.execute("SELECT 1 FROM five_hour_blocks LIMIT 1").fetchone():
728
- conn.execute(
729
- "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) VALUES (?, ?)",
730
- ("001_five_hour_block_models_backfill_v1", now_utc_iso()),
731
- )
732
- conn.commit()
733
935
  return
734
- now_iso = now_utc_iso()
735
936
  conn.execute("BEGIN")
736
937
  try:
737
938
  # Defensive: clean up any orphans from a prior parent rebuild.
@@ -792,15 +993,6 @@ def _backfill_five_hour_block_models(conn: sqlite3.Connection) -> None:
792
993
  ],
793
994
  )
794
995
 
795
- # Mark migration done — closes the gate even when zero rows
796
- # were written (empty session_entries / API-only blocks).
797
- conn.execute(
798
- """
799
- INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc)
800
- VALUES (?, ?)
801
- """,
802
- ("001_five_hour_block_models_backfill_v1", now_iso),
803
- )
804
996
  conn.commit()
805
997
  except Exception:
806
998
  conn.rollback()
@@ -814,24 +1006,17 @@ def _backfill_five_hour_block_projects(conn: sqlite3.Connection) -> None:
814
1006
  """Upgrade-user backfill of five_hour_block_projects.
815
1007
 
816
1008
  Mirror of _backfill_five_hour_block_models but writes by_project
817
- buckets and inserts the projects-side schema_migrations marker.
818
- Cleans up orphan child rows defensively before the main loop.
819
- Marker insert fires regardless of child-row count so the gate
820
- closes for empty-row backfills too.
1009
+ buckets. Cleans up orphan child rows defensively before the main
1010
+ loop. The dispatcher central-stamps the projects-side
1011
+ schema_migrations marker on clean return (#140), so the gate closes
1012
+ for empty-row backfills too.
821
1013
  """
822
1014
  # Empty-table fast path: with no parent five_hour_blocks rows, this
823
- # backfill has nothing to do. We still must close the gate so the
824
- # dispatcher sees us as applied. INSERT OR IGNORE the marker and
825
- # return (replaces the prior `has_blocks` outer gate from the
826
- # pre-framework era).
1015
+ # backfill has nothing to do. Return cleanly so the dispatcher
1016
+ # central-stamps us as applied (#140) replaces the prior
1017
+ # `has_blocks` outer gate from the pre-framework era.
827
1018
  if not conn.execute("SELECT 1 FROM five_hour_blocks LIMIT 1").fetchone():
828
- conn.execute(
829
- "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) VALUES (?, ?)",
830
- ("002_five_hour_block_projects_backfill_v1", now_utc_iso()),
831
- )
832
- conn.commit()
833
1019
  return
834
- now_iso = now_utc_iso()
835
1020
  conn.execute("BEGIN")
836
1021
  try:
837
1022
  conn.execute(
@@ -888,13 +1073,6 @@ def _backfill_five_hour_block_projects(conn: sqlite3.Connection) -> None:
888
1073
  ],
889
1074
  )
890
1075
 
891
- conn.execute(
892
- """
893
- INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc)
894
- VALUES (?, ?)
895
- """,
896
- ("002_five_hour_block_projects_backfill_v1", now_iso),
897
- )
898
1076
  conn.commit()
899
1077
  except Exception:
900
1078
  conn.rollback()
@@ -1428,13 +1606,6 @@ def _migration_merge_5h_block_duplicates_v1(conn: sqlite3.Connection) -> None:
1428
1606
  dropped_ids,
1429
1607
  )
1430
1608
 
1431
- conn.execute(
1432
- """
1433
- INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc)
1434
- VALUES (?, ?)
1435
- """,
1436
- ("003_merge_5h_block_duplicates_v1", now_utc_iso()),
1437
- )
1438
1609
  conn.commit()
1439
1610
  except Exception:
1440
1611
  conn.rollback()
@@ -1483,7 +1654,8 @@ def _migration_heal_forked_week_start_date_buckets(conn: sqlite3.Connection) ->
1483
1654
  external state (no ``cache.db`` open, no JSONL walk).
1484
1655
 
1485
1656
  Empty-table fast path: when none of the three tables has a forked
1486
- row, INSERT the marker and return without opening a transaction.
1657
+ row, return without opening a transaction (the dispatcher
1658
+ central-stamps the marker on clean return, #140).
1487
1659
 
1488
1660
  Spec hook: paired regression test in
1489
1661
  ``tests/test_heal_forked_week_start_date_buckets.py``.
@@ -1491,7 +1663,7 @@ def _migration_heal_forked_week_start_date_buckets(conn: sqlite3.Connection) ->
1491
1663
  # Empty-fork fast path. UNION ALL across the three tables; one
1492
1664
  # SELECT 1 / LIMIT 1 short-circuits on the first violator. When
1493
1665
  # zero rows are forked, skip the BEGIN/UPDATE block entirely and
1494
- # just stamp the marker.
1666
+ # return (the dispatcher central-stamps the marker, #140).
1495
1667
  has_fork_row = conn.execute(
1496
1668
  """
1497
1669
  SELECT 1 FROM (
@@ -1510,12 +1682,6 @@ def _migration_heal_forked_week_start_date_buckets(conn: sqlite3.Connection) ->
1510
1682
  """
1511
1683
  ).fetchone()
1512
1684
  if not has_fork_row:
1513
- conn.execute(
1514
- "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
1515
- "VALUES (?, ?)",
1516
- ("004_heal_forked_week_start_date_buckets", now_utc_iso()),
1517
- )
1518
- conn.commit()
1519
1685
  return
1520
1686
 
1521
1687
  conn.execute("BEGIN")
@@ -1571,13 +1737,6 @@ def _migration_heal_forked_week_start_date_buckets(conn: sqlite3.Connection) ->
1571
1737
  """
1572
1738
  )
1573
1739
 
1574
- conn.execute(
1575
- """
1576
- INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc)
1577
- VALUES (?, ?)
1578
- """,
1579
- ("004_heal_forked_week_start_date_buckets", now_utc_iso()),
1580
- )
1581
1740
  conn.commit()
1582
1741
  except Exception:
1583
1742
  conn.rollback()
@@ -1608,23 +1767,18 @@ def _migration_percent_milestones_reset_event_id(conn: sqlite3.Connection) -> No
1608
1767
 
1609
1768
  Idempotent: a second invocation finds the column already present
1610
1769
  and returns. Empty-table fast path: when the column is already
1611
- present the marker still gets stamped — no schema edit needed.
1770
+ present this handler is a no-op — no schema edit needed (the
1771
+ dispatcher central-stamps the marker on clean return, #140).
1612
1772
  """
1613
1773
  # Fast-path probe: column already present means a prior run of this
1614
1774
  # migration (or a fresh-install fast-stamp from the dispatcher that
1615
1775
  # already picked up the new live-schema CREATE TABLE) has done the
1616
- # work. Just stamp the marker and return.
1776
+ # work. Return; the dispatcher central-stamps the marker (#140).
1617
1777
  cols = {
1618
1778
  str(r[1])
1619
1779
  for r in conn.execute("PRAGMA table_info(percent_milestones)").fetchall()
1620
1780
  }
1621
1781
  if "reset_event_id" in cols:
1622
- conn.execute(
1623
- "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
1624
- "VALUES (?, ?)",
1625
- ("005_percent_milestones_reset_event_id", now_utc_iso()),
1626
- )
1627
- conn.commit()
1628
1782
  return
1629
1783
 
1630
1784
  conn.execute("BEGIN")
@@ -1681,11 +1835,6 @@ def _migration_percent_milestones_reset_event_id(conn: sqlite3.Connection) -> No
1681
1835
  """
1682
1836
  )
1683
1837
  conn.execute("DROP TABLE percent_milestones_old_005")
1684
- conn.execute(
1685
- "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
1686
- "VALUES (?, ?)",
1687
- ("005_percent_milestones_reset_event_id", now_utc_iso()),
1688
- )
1689
1838
  conn.commit()
1690
1839
  except Exception:
1691
1840
  conn.rollback()
@@ -1722,26 +1871,18 @@ def _migration_five_hour_milestones_reset_event_id(conn: sqlite3.Connection) ->
1722
1871
  (fresh-install fast-stamp from the dispatcher because the live
1723
1872
  ``CREATE TABLE IF NOT EXISTS five_hour_milestones`` already carries
1724
1873
  the new shape — REQUIRED for fresh-install correctness per spec §3.2),
1725
- the marker still gets stamped — no schema edit needed.
1874
+ this handler is a no-op — no schema edit needed (the dispatcher
1875
+ central-stamps the marker on clean return, #140).
1726
1876
  """
1727
1877
  # Fast-path probe: column already present means a prior run of this
1728
1878
  # migration (or a fresh-install fast-stamp from the dispatcher that
1729
1879
  # already picked up the new live-schema CREATE TABLE) has done the
1730
- # work. Just stamp the marker and return. The marker INSERT runs in
1731
- # SQLite's implicit transaction (auto-opened by the write, closed by
1732
- # ``commit()`` — same shape as migration 005's fast path); no explicit
1733
- # ``BEGIN`` is needed for a single-statement DML.
1880
+ # work. Return; the dispatcher central-stamps the marker (#140).
1734
1881
  cols = {
1735
1882
  str(r[1])
1736
1883
  for r in conn.execute("PRAGMA table_info(five_hour_milestones)").fetchall()
1737
1884
  }
1738
1885
  if "reset_event_id" in cols:
1739
- conn.execute(
1740
- "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
1741
- "VALUES (?, ?)",
1742
- ("006_five_hour_milestones_reset_event_id", now_utc_iso()),
1743
- )
1744
- conn.commit()
1745
1886
  return
1746
1887
 
1747
1888
  conn.execute("BEGIN")
@@ -1813,11 +1954,6 @@ def _migration_five_hour_milestones_reset_event_id(conn: sqlite3.Connection) ->
1813
1954
  """
1814
1955
  )
1815
1956
  conn.execute("DROP TABLE five_hour_milestones_old_006")
1816
- conn.execute(
1817
- "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
1818
- "VALUES (?, ?)",
1819
- ("006_five_hour_milestones_reset_event_id", now_utc_iso()),
1820
- )
1821
1957
  conn.commit()
1822
1958
  except Exception:
1823
1959
  conn.rollback()
@@ -1861,22 +1997,16 @@ def _migration_observed_pre_credit_pct(conn: sqlite3.Connection) -> None:
1861
1997
 
1862
1998
  Idempotent: a second invocation finds the column already present
1863
1999
  and returns. Empty-column fast path: when the live CREATE TABLE
1864
- already carries the column (fresh install), stamp the marker and
1865
- return without an ALTER. Simple ADD COLUMN no UNIQUE constraint
1866
- change, so no rename-recreate-copy needed (contrast migrations
1867
- 005 / 006).
2000
+ already carries the column (fresh install), return without an ALTER
2001
+ (the dispatcher central-stamps the marker on clean return, #140).
2002
+ Simple ADD COLUMN — no UNIQUE constraint change, so no
2003
+ rename-recreate-copy needed (contrast migrations 005 / 006).
1868
2004
  """
1869
2005
  cols = {
1870
2006
  str(r[1])
1871
2007
  for r in conn.execute("PRAGMA table_info(week_reset_events)").fetchall()
1872
2008
  }
1873
2009
  if "observed_pre_credit_pct" in cols:
1874
- conn.execute(
1875
- "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
1876
- "VALUES (?, ?)",
1877
- ("007_observed_pre_credit_pct", now_utc_iso()),
1878
- )
1879
- conn.commit()
1880
2010
  return
1881
2011
 
1882
2012
  conn.execute("BEGIN")
@@ -1885,11 +2015,6 @@ def _migration_observed_pre_credit_pct(conn: sqlite3.Connection) -> None:
1885
2015
  "ALTER TABLE week_reset_events "
1886
2016
  "ADD COLUMN observed_pre_credit_pct REAL"
1887
2017
  )
1888
- conn.execute(
1889
- "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
1890
- "VALUES (?, ?)",
1891
- ("007_observed_pre_credit_pct", now_utc_iso()),
1892
- )
1893
2018
  conn.commit()
1894
2019
  except Exception:
1895
2020
  conn.rollback()
@@ -2182,6 +2307,34 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2182
2307
  ON session_entries(msg_id, req_id)
2183
2308
  WHERE msg_id IS NOT NULL AND req_id IS NOT NULL;
2184
2309
 
2310
+ CREATE TABLE IF NOT EXISTS conversation_messages (
2311
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2312
+ session_id TEXT,
2313
+ uuid TEXT,
2314
+ parent_uuid TEXT,
2315
+ source_path TEXT NOT NULL,
2316
+ byte_offset INTEGER NOT NULL,
2317
+ timestamp_utc TEXT,
2318
+ entry_type TEXT NOT NULL,
2319
+ text TEXT NOT NULL DEFAULT '',
2320
+ blocks_json TEXT NOT NULL DEFAULT '[]',
2321
+ model TEXT,
2322
+ msg_id TEXT,
2323
+ req_id TEXT,
2324
+ cwd TEXT,
2325
+ git_branch TEXT,
2326
+ is_sidechain INTEGER NOT NULL DEFAULT 0,
2327
+ UNIQUE(source_path, byte_offset)
2328
+ );
2329
+ CREATE INDEX IF NOT EXISTS idx_conv_session_ts
2330
+ ON conversation_messages(session_id, timestamp_utc, id);
2331
+ CREATE INDEX IF NOT EXISTS idx_conv_session_uuid
2332
+ ON conversation_messages(session_id, uuid);
2333
+ CREATE INDEX IF NOT EXISTS idx_conv_source
2334
+ ON conversation_messages(source_path);
2335
+ CREATE INDEX IF NOT EXISTS idx_conv_turnkey
2336
+ ON conversation_messages(msg_id, req_id);
2337
+
2185
2338
  CREATE TABLE IF NOT EXISTS codex_session_files (
2186
2339
  path TEXT PRIMARY KEY,
2187
2340
  size_bytes INTEGER NOT NULL,
@@ -2227,6 +2380,99 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2227
2380
  "CREATE INDEX IF NOT EXISTS idx_session_files_session_id "
2228
2381
  "ON session_files(session_id)"
2229
2382
  )
2383
+ # FTS5 is optional in the sqlite build. Create the external-content index +
2384
+ # sync triggers as separate executes wrapped in one try; on failure create
2385
+ # NEITHER the table NOR the triggers (a trigger referencing a missing table
2386
+ # would itself error), set a persisted flag, and let search fall back to
2387
+ # LIKE. Spec §1. Idempotent (IF NOT EXISTS).
2388
+ if _fts5_available(conn):
2389
+ try:
2390
+ # Recovery (spec §1/P2): if a PRIOR run marked FTS unavailable,
2391
+ # conversation_messages rows were ingested (by sync_cache / the
2392
+ # backfill) WITHOUT the AFTER INSERT trigger ever indexing them —
2393
+ # or a prior downgrade dropped the index while leaving the base
2394
+ # rows. Detect that BEFORE clearing the flag so we can rebuild the
2395
+ # external-content index from conversation_messages below. A fresh
2396
+ # install never sets the flag, so this stays False and no rebuild
2397
+ # runs (the triggers index rows incrementally as they arrive).
2398
+ recovering = conn.execute(
2399
+ "SELECT 1 FROM cache_meta WHERE key='fts5_unavailable'"
2400
+ ).fetchone() is not None
2401
+ conn.execute(
2402
+ "CREATE VIRTUAL TABLE IF NOT EXISTS conversation_fts "
2403
+ "USING fts5(text, content='conversation_messages', content_rowid='id')")
2404
+ conn.execute(
2405
+ "CREATE TRIGGER IF NOT EXISTS conv_fts_ai AFTER INSERT ON conversation_messages "
2406
+ "BEGIN INSERT INTO conversation_fts(rowid, text) VALUES (new.id, new.text); END")
2407
+ conn.execute(
2408
+ "CREATE TRIGGER IF NOT EXISTS conv_fts_ad AFTER DELETE ON conversation_messages "
2409
+ "BEGIN INSERT INTO conversation_fts(conversation_fts, rowid, text) "
2410
+ "VALUES('delete', old.id, old.text); END")
2411
+ conn.execute(
2412
+ "CREATE TRIGGER IF NOT EXISTS conv_fts_au AFTER UPDATE OF text ON conversation_messages "
2413
+ "BEGIN INSERT INTO conversation_fts(conversation_fts, rowid, text) "
2414
+ "VALUES('delete', old.id, old.text); "
2415
+ "INSERT INTO conversation_fts(rowid, text) VALUES (new.id, new.text); END")
2416
+ if recovering:
2417
+ # Repopulate the freshly-(re)created index from the base table
2418
+ # so pre-recovery history is searchable. Cheap no-op when
2419
+ # conversation_messages is empty.
2420
+ conn.execute(
2421
+ "INSERT INTO conversation_fts(conversation_fts) VALUES('rebuild')")
2422
+ conn.execute("DELETE FROM cache_meta WHERE key='fts5_unavailable'")
2423
+ except sqlite3.OperationalError:
2424
+ # partial create cleanup, then mark unavailable
2425
+ for stmt in ("DROP TRIGGER IF EXISTS conv_fts_au",
2426
+ "DROP TRIGGER IF EXISTS conv_fts_ad",
2427
+ "DROP TRIGGER IF EXISTS conv_fts_ai",
2428
+ "DROP TABLE IF EXISTS conversation_fts"):
2429
+ try:
2430
+ conn.execute(stmt)
2431
+ except sqlite3.OperationalError:
2432
+ pass
2433
+ _set_cache_meta(conn, "fts5_unavailable", "1")
2434
+ else:
2435
+ # FTS5 is unavailable on THIS sqlite build. If a prior (FTS-capable)
2436
+ # run created the sync triggers, they now reference an unusable
2437
+ # conversation_fts and EVERY INSERT into conversation_messages would
2438
+ # raise "no such module: fts5". Because the conversation INSERT shares
2439
+ # sync_cache's per-file write transaction with session_entries, that
2440
+ # rollback would discard COST ingest too. Drop the orphan triggers so
2441
+ # writes succeed under the LIKE fallback. (The conversation_fts vtable
2442
+ # itself can't be DROPped without the fts5 module, but with no triggers
2443
+ # nothing writes to it.)
2444
+ for stmt in ("DROP TRIGGER IF EXISTS conv_fts_au",
2445
+ "DROP TRIGGER IF EXISTS conv_fts_ad",
2446
+ "DROP TRIGGER IF EXISTS conv_fts_ai"):
2447
+ try:
2448
+ conn.execute(stmt)
2449
+ except sqlite3.OperationalError:
2450
+ pass
2451
+ _set_cache_meta(conn, "fts5_unavailable", "1")
2452
+ # The FTS branch above issues DML (DELETE/INSERT on cache_meta) which opens
2453
+ # an implicit transaction under sqlite3's legacy autocommit mode. Close it
2454
+ # so the migration dispatcher's subsequent ``conn.execute("BEGIN")`` starts
2455
+ # cleanly (mirrors the bootstrap-rename commit envelope rationale).
2456
+ conn.commit()
2457
+
2458
+
2459
+ def _fts5_available(conn: sqlite3.Connection) -> bool:
2460
+ """True if this sqlite build can create an FTS5 table. Cheap probe on a
2461
+ temp table that is created then dropped. Hidden test seam: tests monkeypatch
2462
+ this to False to exercise the LIKE fallback."""
2463
+ try:
2464
+ conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS _fts5_probe USING fts5(x)")
2465
+ conn.execute("DROP TABLE IF EXISTS _fts5_probe")
2466
+ return True
2467
+ except sqlite3.OperationalError:
2468
+ return False
2469
+
2470
+
2471
+ def _set_cache_meta(conn: sqlite3.Connection, key: str, value: str) -> None:
2472
+ conn.execute(
2473
+ "CREATE TABLE IF NOT EXISTS cache_meta (key TEXT PRIMARY KEY, value TEXT)")
2474
+ conn.execute("INSERT INTO cache_meta(key, value) VALUES(?, ?) "
2475
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, value))
2230
2476
 
2231
2477
 
2232
2478
  def _eagerly_apply_cache_migrations() -> None:
@@ -2338,6 +2584,7 @@ def _eagerly_apply_cache_migrations() -> None:
2338
2584
  # if 001 has already applied, this is a fast-path return.
2339
2585
  _run_pending_migrations(
2340
2586
  conn, registry=_CACHE_MIGRATIONS, db_label="cache.db",
2587
+ recover_version_ahead=True,
2341
2588
  )
2342
2589
  finally:
2343
2590
  # Close immediately so the WAL writer lock (if any) is
@@ -2627,6 +2874,45 @@ def _001_dedup_highest_wins_locked(conn: sqlite3.Connection) -> None:
2627
2874
  raise
2628
2875
 
2629
2876
 
2877
+ # === Region 7c: Cache migration 002_conversation_messages_backfill ===
2878
+
2879
+ @cache_migration("002_conversation_messages_backfill")
2880
+ def _002_conversation_messages_backfill(conn: sqlite3.Connection) -> None:
2881
+ """Mark the ``conversation_messages`` backfill pending (Plan 1 Task 5; the
2882
+ deferral is issue #139).
2883
+
2884
+ The table + indexes + FTS already live in ``_apply_cache_schema`` (so fresh
2885
+ installs have them and the dispatcher stamps THIS migration without running
2886
+ it — there is no history to populate). This handler runs only on an
2887
+ EXISTING install (``session_entries`` non-empty), which needs the message
2888
+ index populated from the full JSONL history.
2889
+
2890
+ Rather than walk that history INLINE — which blocked the triggering command
2891
+ until the whole (potentially ~1M-line) backfill completed, including a
2892
+ stats-only ``cctally report`` that fires the cache dispatcher via
2893
+ ``_eagerly_apply_cache_migrations`` but never opens cache.db for reads
2894
+ (issue #139) — this handler just sets the ``conversation_backfill_pending``
2895
+ cache_meta flag and returns in microseconds. The actual offset-0 backfill
2896
+ runs on the next ``sync_cache``, which already holds the ``cache.db.lock``
2897
+ flock and owns the walker (see ``_cctally_cache.sync_cache``); a
2898
+ cache-consuming command — or, most often, the background ``hook-tick`` —
2899
+ absorbs the one-time walk where the latency is expected/invisible. Because
2900
+ the handler no longer touches JSONL it needs no flock and cannot contend
2901
+ with a concurrent sync, so the old non-blocking-flock +
2902
+ ``MigrationGateNotMet`` defer dance is gone.
2903
+
2904
+ Does NOT self-stamp its ``schema_migrations`` marker: the dispatcher owns
2905
+ the central stamp on the existing-install success path (issue #140), calling
2906
+ ``_stamp_applied(conn, m.name)`` right after this handler returns cleanly —
2907
+ so the migration persists and is never re-walked (re-setting the flag) on a
2908
+ subsequent ``open_cache_db()``. This handler only commits the cache_meta
2909
+ flag. The flag itself is consumed + cleared by the first ``sync_cache`` that
2910
+ sees it (idempotent + crash-resumable there); a ``cache-sync --rebuild``
2911
+ clears it directly since its normal offset-0 walk repopulates the index."""
2912
+ _set_cache_meta(conn, "conversation_backfill_pending", "1")
2913
+ conn.commit()
2914
+
2915
+
2630
2916
  # === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
2631
2917
 
2632
2918
  @stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
@@ -2789,17 +3075,6 @@ def _008_recompute_weekly_cost_snapshots_dedup_fix(
2789
3075
  "SET cost_usd = ? WHERE id = ?",
2790
3076
  (total, snap_id),
2791
3077
  )
2792
- # D3 — INSERT OR IGNORE for race safety. Mirrors the
2793
- # convention applied to every other production migration
2794
- # and the matching change to cache migration 001.
2795
- conn.execute(
2796
- "INSERT OR IGNORE INTO schema_migrations "
2797
- "(name, applied_at_utc) VALUES (?, ?)",
2798
- (
2799
- "008_recompute_weekly_cost_snapshots_dedup_fix",
2800
- now_utc_iso(),
2801
- ),
2802
- )
2803
3078
  conn.execute("COMMIT")
2804
3079
  except Exception:
2805
3080
  conn.execute("ROLLBACK")
@@ -3217,14 +3492,6 @@ def _009_recompute_five_hour_blocks_dedup_fix(
3217
3492
  ],
3218
3493
  )
3219
3494
 
3220
- conn.execute(
3221
- "INSERT OR IGNORE INTO schema_migrations "
3222
- "(name, applied_at_utc) VALUES (?, ?)",
3223
- (
3224
- "009_recompute_five_hour_blocks_dedup_fix",
3225
- now_utc_iso(),
3226
- ),
3227
- )
3228
3495
  conn.execute("COMMIT")
3229
3496
  except Exception:
3230
3497
  conn.execute("ROLLBACK")
@@ -3407,14 +3674,6 @@ def _010_recompute_percent_milestones_dedup_fix(
3407
3674
  (cumulative, marginal, mid),
3408
3675
  )
3409
3676
 
3410
- conn.execute(
3411
- "INSERT OR IGNORE INTO schema_migrations "
3412
- "(name, applied_at_utc) VALUES (?, ?)",
3413
- (
3414
- "010_recompute_percent_milestones_dedup_fix",
3415
- now_utc_iso(),
3416
- ),
3417
- )
3418
3677
  conn.execute("COMMIT")
3419
3678
  except Exception:
3420
3679
  conn.execute("ROLLBACK")
@@ -3423,6 +3682,124 @@ def _010_recompute_percent_milestones_dedup_fix(
3423
3682
  cache_ro.close()
3424
3683
 
3425
3684
 
3685
+ @stats_migration("011_budget_milestone_period_keys")
3686
+ def _migration_budget_milestone_period_keys(conn: sqlite3.Connection) -> None:
3687
+ """Add a write-once ``period`` column to the three budget milestone tables
3688
+ and include it in each UNIQUE key (issue #137).
3689
+
3690
+ ``budget_milestones`` -> UNIQUE(week_start_at, period, threshold)
3691
+ ``codex_budget_milestones`` -> UNIQUE(period_start_at, period, threshold)
3692
+ ``projected_milestones`` -> UNIQUE(week_start_at, period, metric, threshold)
3693
+
3694
+ Fixes (1) stale dashboard period labels and (2) the calendar-week /
3695
+ calendar-month dedup collision when the 1st of the month lands on the
3696
+ configured week-start weekday.
3697
+
3698
+ Historical rows are backfilled to ``period = NULL`` (the "pre-011 unknown
3699
+ period" sentinel) rather than a fabricated value, honoring write-once
3700
+ milestones. The firing pre-probe matches ``period = ? OR period IS NULL``
3701
+ so unknown-period rows never re-fire (no spurious upgrade alert), and the
3702
+ dashboard COALESCEs NULL to the vendor-default noun.
3703
+
3704
+ SQLite cannot ALTER an inline UNIQUE in place -> rename-recreate-copy idiom
3705
+ (same as migration 005). Idempotent: a table that already has ``period``
3706
+ (fresh install where the live CREATE made the new shape, or a prior run) is
3707
+ skipped; when all three are present the handler returns and the dispatcher
3708
+ central-stamps the marker (#140).
3709
+ """
3710
+ specs = [
3711
+ (
3712
+ "budget_milestones",
3713
+ """
3714
+ CREATE TABLE budget_milestones (
3715
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3716
+ week_start_at TEXT NOT NULL,
3717
+ period TEXT,
3718
+ threshold INTEGER NOT NULL,
3719
+ budget_usd REAL NOT NULL,
3720
+ spent_usd REAL NOT NULL,
3721
+ consumption_pct REAL NOT NULL,
3722
+ crossed_at_utc TEXT NOT NULL,
3723
+ alerted_at TEXT,
3724
+ UNIQUE(week_start_at, period, threshold)
3725
+ )
3726
+ """,
3727
+ # (cols copied target<-source) — period omitted from source => NULL
3728
+ "id, week_start_at, threshold, budget_usd, spent_usd, "
3729
+ "consumption_pct, crossed_at_utc, alerted_at",
3730
+ ),
3731
+ (
3732
+ "codex_budget_milestones",
3733
+ """
3734
+ CREATE TABLE codex_budget_milestones (
3735
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3736
+ period_start_at TEXT NOT NULL,
3737
+ period TEXT,
3738
+ threshold INTEGER NOT NULL,
3739
+ budget_usd REAL NOT NULL,
3740
+ spent_usd REAL NOT NULL,
3741
+ consumption_pct REAL NOT NULL,
3742
+ crossed_at_utc TEXT NOT NULL,
3743
+ alerted_at TEXT,
3744
+ UNIQUE(period_start_at, period, threshold)
3745
+ )
3746
+ """,
3747
+ "id, period_start_at, threshold, budget_usd, spent_usd, "
3748
+ "consumption_pct, crossed_at_utc, alerted_at",
3749
+ ),
3750
+ (
3751
+ "projected_milestones",
3752
+ """
3753
+ CREATE TABLE projected_milestones (
3754
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3755
+ week_start_at TEXT NOT NULL,
3756
+ period TEXT,
3757
+ metric TEXT NOT NULL,
3758
+ threshold INTEGER NOT NULL,
3759
+ projected_value REAL NOT NULL,
3760
+ denominator REAL NOT NULL,
3761
+ crossed_at_utc TEXT NOT NULL,
3762
+ alerted_at TEXT,
3763
+ UNIQUE(week_start_at, period, metric, threshold)
3764
+ )
3765
+ """,
3766
+ "id, week_start_at, metric, threshold, projected_value, "
3767
+ "denominator, crossed_at_utc, alerted_at",
3768
+ ),
3769
+ ]
3770
+
3771
+ def _has_period(table: str) -> bool:
3772
+ cols = {
3773
+ str(r[1])
3774
+ for r in conn.execute(f"PRAGMA table_info({table})").fetchall()
3775
+ }
3776
+ return "period" in cols
3777
+
3778
+ # Compute needs-rebuild BEFORE any transaction (no deferred-BEGIN-then-read
3779
+ # on stats.db — SQLITE_BUSY_SNAPSHOT, migrations-gotchas.md).
3780
+ pending = [s for s in specs if not _has_period(s[0])]
3781
+
3782
+ if not pending:
3783
+ # Fresh install (live CREATE already made the new shape) or prior run.
3784
+ return
3785
+
3786
+ conn.execute("BEGIN IMMEDIATE") # write-lock up front; DDL is first DML
3787
+ try:
3788
+ for table, create_sql, cols in pending:
3789
+ old = f"{table}_old_011"
3790
+ conn.execute(f"ALTER TABLE {table} RENAME TO {old}")
3791
+ conn.execute(create_sql)
3792
+ # period omitted from the SELECT => NULL for every historical row
3793
+ conn.execute(
3794
+ f"INSERT INTO {table} ({cols}) SELECT {cols} FROM {old}"
3795
+ )
3796
+ conn.execute(f"DROP TABLE {old}")
3797
+ conn.commit()
3798
+ except Exception:
3799
+ conn.rollback()
3800
+ raise
3801
+
3802
+
3426
3803
  # === Region 8: Test-only migration registration (was bin/cctally:12086-12140) ===
3427
3804
 
3428
3805
  # ──────────────────────────────────────────────────────────────────────
@@ -3447,39 +3824,22 @@ if os.environ.get("CCTALLY_MIGRATION_TEST_MODE") == "1":
3447
3824
  @stats_migration(_stats_test_name)
3448
3825
  def _test_migration_failure_injection(conn):
3449
3826
  """Test-only migration: raises RuntimeError when test_failure_trigger
3450
- table is non-empty; otherwise inserts the marker and succeeds."""
3827
+ table is non-empty; otherwise it is a no-op (the dispatcher stamps)."""
3451
3828
  if conn.execute(
3452
3829
  "SELECT 1 FROM sqlite_master WHERE type='table' AND name='test_failure_trigger'"
3453
3830
  ).fetchone() and conn.execute(
3454
3831
  "SELECT 1 FROM test_failure_trigger LIMIT 1"
3455
3832
  ).fetchone():
3456
3833
  raise RuntimeError("test failure injected")
3457
- conn.execute("BEGIN")
3458
- try:
3459
- conn.execute(
3460
- "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) VALUES (?, ?)",
3461
- (_stats_test_name, now_utc_iso()),
3462
- )
3463
- conn.commit()
3464
- except Exception:
3465
- conn.rollback()
3466
- raise
3834
+ return
3467
3835
 
3468
3836
  _cache_test_seq = len(_CACHE_MIGRATIONS) + 1
3469
3837
  _cache_test_name = f"{_cache_test_seq:03d}_test_cache_migration"
3470
3838
 
3471
3839
  @cache_migration(_cache_test_name)
3472
3840
  def _test_cache_migration(conn):
3473
- conn.execute("BEGIN")
3474
- try:
3475
- conn.execute(
3476
- "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) VALUES (?, ?)",
3477
- (_cache_test_name, now_utc_iso()),
3478
- )
3479
- conn.commit()
3480
- except Exception:
3481
- conn.rollback()
3482
- raise
3841
+ """Test-only cache migration: no-op body; the dispatcher stamps."""
3842
+ return
3483
3843
 
3484
3844
 
3485
3845
  # === Region 9: db CLI subcommands (was bin/cctally:19707-20043) ===
@@ -3821,3 +4181,74 @@ def cmd_db_unskip(args: argparse.Namespace) -> int:
3821
4181
  conn.close()
3822
4182
  print(f"Unskipped: {name} (will run on next open).")
3823
4183
  return 0
4184
+
4185
+
4186
+ def cmd_db_recover(args: argparse.Namespace) -> int:
4187
+ """Revert a version-ahead DB to this binary's known schema head (#145).
4188
+
4189
+ cache.db is fully re-derivable, so `--db cache` heals without --yes.
4190
+ stats.db holds non-re-derivable snapshots/milestones, so `--db stats`
4191
+ requires explicit --yes and may need a re-record afterward, AND honors the
4192
+ #146 prod guard (a dev/worktree binary refuses to trim+revert the real prod
4193
+ stats.db unless CCTALLY_ALLOW_PROD_MIGRATION=1). Bypasses
4194
+ open_db()/open_cache_db() (raw connect) so it never re-triggers the
4195
+ dispatcher. Idempotent: a no-op when the DB is not ahead.
4196
+ """
4197
+ which = args.db # "cache" | "stats"
4198
+ if which == "cache":
4199
+ path, registry, label = _cctally_core.CACHE_DB_PATH, _CACHE_MIGRATIONS, "cache.db"
4200
+ else:
4201
+ path, registry, label = _cctally_core.DB_PATH, _STATS_MIGRATIONS, "stats.db"
4202
+
4203
+ # Absent file → nothing to recover; do NOT connect (sqlite3.connect would
4204
+ # create an empty DB file — mirrors cmd_db_unskip).
4205
+ if not path.exists():
4206
+ print(f"cctally: {label} not present; nothing to recover.")
4207
+ return 0
4208
+
4209
+ conn = sqlite3.connect(path)
4210
+ try:
4211
+ cur_version = conn.execute("PRAGMA user_version").fetchone()[0]
4212
+ head = len(registry)
4213
+ if cur_version <= head:
4214
+ print(
4215
+ f"cctally: {label} is at version {cur_version} "
4216
+ f"(≤ known {head}); nothing to recover."
4217
+ )
4218
+ return 0
4219
+ # Prod guard (issue #146): a dev/worktree binary must not trim the unknown
4220
+ # migration markers + revert user_version on the installed release's
4221
+ # NON-re-derivable prod stats.db — the destructive cousin of the #142
4222
+ # forward-migration guard (trimmed markers can't be re-derived). Reuses
4223
+ # the same connection-scoped predicate (git checkout AND the DB physically
4224
+ # in the real prod dir, password-DB-resolved, honoring
4225
+ # CCTALLY_ALLOW_PROD_MIGRATION). cache.db is re-derivable and intentionally
4226
+ # exempt — it mirrors the dispatcher's opt-in auto-heal.
4227
+ if which == "stats" and _would_block_prod_migration(conn):
4228
+ eprint(
4229
+ "cctally: refusing to recover stats.db in the prod data dir "
4230
+ "(~/.local/share/cctally) from a dev checkout — trimming the "
4231
+ "unknown migration markers and reverting user_version on the "
4232
+ "installed release's non-re-derivable stats.db could corrupt it. "
4233
+ "Run the installed binary, or override with "
4234
+ "CCTALLY_ALLOW_PROD_MIGRATION=1."
4235
+ )
4236
+ return 2
4237
+ if which == "stats" and not getattr(args, "yes", False):
4238
+ eprint(
4239
+ f"cctally: {label} is at version {cur_version} but this cctally "
4240
+ f"only knows up to {head}. Recovering stats.db trims the unknown "
4241
+ f"migration markers and reverts user_version, but any schema the "
4242
+ f"unknown migration created is left in place and a re-record/"
4243
+ f"re-sync may be needed. Re-run with --yes to proceed, or restore "
4244
+ f"{label} from a backup."
4245
+ )
4246
+ return 2
4247
+ info = _recover_version_ahead(conn, registry, label)
4248
+ print(
4249
+ f"cctally: reverted {label} v{info['reverted_from']} → "
4250
+ f"v{info['reverted_to']}, dropped {info['trimmed']} unknown marker(s)."
4251
+ )
4252
+ return 0
4253
+ finally:
4254
+ conn.close()