cctally 1.28.0 → 1.29.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.
@@ -569,7 +569,14 @@ def _recover_version_ahead(
569
569
  ("schema_migrations_skipped", skipped)):
570
570
  try:
571
571
  for row in conn.execute(f"SELECT name FROM {table}").fetchall():
572
- dest.add(row[0])
572
+ # Normalize legacy unprefixed markers to their canonical NNN_
573
+ # name (issue #148). The alias union above keeps such a row from
574
+ # being trimmed; without this normalization the membership test
575
+ # below compares canonical m.name against the legacy alias and
576
+ # falsely concludes the migration is missing, resetting
577
+ # user_version to 0 and forcing a needless full re-walk. Mirrors
578
+ # the alias-aware read in cmd_db_status.
579
+ dest.add(aliases.get(row[0], row[0]))
573
580
  except sqlite3.OperationalError:
574
581
  pass
575
582
 
@@ -2401,18 +2408,11 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2401
2408
  conn.execute(
2402
2409
  "CREATE VIRTUAL TABLE IF NOT EXISTS conversation_fts "
2403
2410
  "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")
2411
+ # Trigger DDL lives in ONE place (_CONV_FTS_TRIGGER_DDL) so this
2412
+ # initial create and the #138 storm-free full-clear
2413
+ # (clear_conversation_messages, which drops + recreates the
2414
+ # triggers) can never drift.
2415
+ _create_conversation_fts_triggers(conn)
2416
2416
  if recovering:
2417
2417
  # Repopulate the freshly-(re)created index from the base table
2418
2418
  # so pre-recovery history is searchable. Cheap no-op when
@@ -2422,14 +2422,11 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2422
2422
  conn.execute("DELETE FROM cache_meta WHERE key='fts5_unavailable'")
2423
2423
  except sqlite3.OperationalError:
2424
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
2425
+ _drop_conversation_fts_triggers(conn)
2426
+ try:
2427
+ conn.execute("DROP TABLE IF EXISTS conversation_fts")
2428
+ except sqlite3.OperationalError:
2429
+ pass
2433
2430
  _set_cache_meta(conn, "fts5_unavailable", "1")
2434
2431
  else:
2435
2432
  # FTS5 is unavailable on THIS sqlite build. If a prior (FTS-capable)
@@ -2441,13 +2438,7 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2441
2438
  # writes succeed under the LIKE fallback. (The conversation_fts vtable
2442
2439
  # itself can't be DROPped without the fts5 module, but with no triggers
2443
2440
  # 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
2441
+ _drop_conversation_fts_triggers(conn)
2451
2442
  _set_cache_meta(conn, "fts5_unavailable", "1")
2452
2443
  # The FTS branch above issues DML (DELETE/INSERT on cache_meta) which opens
2453
2444
  # an implicit transaction under sqlite3's legacy autocommit mode. Close it
@@ -2475,6 +2466,93 @@ def _set_cache_meta(conn: sqlite3.Connection, key: str, value: str) -> None:
2475
2466
  "ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, value))
2476
2467
 
2477
2468
 
2469
+ # Conversation FTS sync triggers (external-content FTS5). Defined ONCE here so
2470
+ # the initial create in _apply_cache_schema and the #138 storm-free full-clear
2471
+ # in clear_conversation_messages (which drops + recreates them) can never drift.
2472
+ # conv_fts_ad / conv_fts_au use the external-content `'delete'` idiom.
2473
+ _CONV_FTS_TRIGGER_DDL = (
2474
+ "CREATE TRIGGER IF NOT EXISTS conv_fts_ai AFTER INSERT ON conversation_messages "
2475
+ "BEGIN INSERT INTO conversation_fts(rowid, text) VALUES (new.id, new.text); END",
2476
+ "CREATE TRIGGER IF NOT EXISTS conv_fts_ad AFTER DELETE ON conversation_messages "
2477
+ "BEGIN INSERT INTO conversation_fts(conversation_fts, rowid, text) "
2478
+ "VALUES('delete', old.id, old.text); END",
2479
+ "CREATE TRIGGER IF NOT EXISTS conv_fts_au AFTER UPDATE OF text ON conversation_messages "
2480
+ "BEGIN INSERT INTO conversation_fts(conversation_fts, rowid, text) "
2481
+ "VALUES('delete', old.id, old.text); "
2482
+ "INSERT INTO conversation_fts(rowid, text) VALUES (new.id, new.text); END",
2483
+ )
2484
+ # Drop by name (the body is irrelevant to DROP TRIGGER); reverse order is
2485
+ # cosmetic — order doesn't matter for independent triggers.
2486
+ _CONV_FTS_TRIGGER_NAMES = ("conv_fts_au", "conv_fts_ad", "conv_fts_ai")
2487
+
2488
+
2489
+ def _create_conversation_fts_triggers(conn: sqlite3.Connection) -> None:
2490
+ """Create the three external-content FTS5 sync triggers (idempotent —
2491
+ each is ``IF NOT EXISTS``). Single source of truth for the trigger DDL,
2492
+ shared by ``_apply_cache_schema`` and ``clear_conversation_messages``
2493
+ (#138). The caller must have already created ``conversation_fts``."""
2494
+ for stmt in _CONV_FTS_TRIGGER_DDL:
2495
+ conn.execute(stmt)
2496
+
2497
+
2498
+ def _drop_conversation_fts_triggers(conn: sqlite3.Connection) -> None:
2499
+ """Drop the three FTS5 sync triggers (idempotent — ``IF EXISTS``). Swallows
2500
+ ``OperationalError`` per statement so a partial/absent trigger set (e.g. an
2501
+ FTS-unavailable build) is tolerated."""
2502
+ for name in _CONV_FTS_TRIGGER_NAMES:
2503
+ try:
2504
+ conn.execute(f"DROP TRIGGER IF EXISTS {name}")
2505
+ except sqlite3.OperationalError:
2506
+ pass
2507
+
2508
+
2509
+ def clear_conversation_messages(conn: sqlite3.Connection) -> None:
2510
+ """Full-clear ``conversation_messages`` + its FTS index WITHOUT firing the
2511
+ per-row delete trigger O(rows) (#138).
2512
+
2513
+ A bulk ``DELETE FROM conversation_messages`` fires ``conv_fts_ad`` once per
2514
+ row — each an FTS5 ``'delete'`` shadow-write — AND forfeits SQLite's
2515
+ no-trigger truncate fast-path, stalling the held ``cache.db.lock`` far
2516
+ longer than the ``session_entries`` clear alone. We suppress the triggers:
2517
+
2518
+ drop all 3 conv_fts triggers
2519
+ → DELETE FROM conversation_messages (true truncate fast-path now)
2520
+ → INSERT INTO conversation_fts(conversation_fts) VALUES('delete-all')
2521
+ (resets the external-content index)
2522
+ → recreate all 3 triggers
2523
+
2524
+ Ordering is load-bearing: clearing the FTS index while the per-row delete
2525
+ trigger is still live makes the base ``DELETE`` write ``'delete'`` postings
2526
+ against already-gone rows and CORRUPTS the index (``database disk image is
2527
+ malformed``; verified on SQLite 3.53.1). Dropping the triggers first makes
2528
+ the base ``DELETE`` not touch the index at all; the explicit ``'delete-all'``
2529
+ then resets it cleanly and ``integrity-check`` still passes.
2530
+
2531
+ Runs inside the caller's open transaction (the held ``cache.db.lock``); the
2532
+ caller owns the commit. When FTS5 is unavailable
2533
+ (``cache_meta.fts5_unavailable`` set → no triggers, no usable vtable),
2534
+ falls back to a plain base ``DELETE`` — there are no triggers to storm and a
2535
+ ``'delete-all'`` would error on the absent vtable."""
2536
+ try:
2537
+ fts_unavailable = conn.execute(
2538
+ "SELECT 1 FROM cache_meta WHERE key='fts5_unavailable'"
2539
+ ).fetchone() is not None
2540
+ except sqlite3.OperationalError:
2541
+ # No cache_meta yet — only possible before the schema is applied, in
2542
+ # which case there is no FTS vtable/triggers either. Bias to the plain
2543
+ # DELETE: it can't storm what doesn't exist and won't touch a vtable.
2544
+ fts_unavailable = True
2545
+
2546
+ if fts_unavailable:
2547
+ conn.execute("DELETE FROM conversation_messages")
2548
+ return
2549
+
2550
+ _drop_conversation_fts_triggers(conn)
2551
+ conn.execute("DELETE FROM conversation_messages")
2552
+ conn.execute("INSERT INTO conversation_fts(conversation_fts) VALUES('delete-all')")
2553
+ _create_conversation_fts_triggers(conn)
2554
+
2555
+
2478
2556
  def _eagerly_apply_cache_migrations() -> None:
2479
2557
  """Open cache.db so its pending migrations (notably
2480
2558
  ``001_dedup_highest_wins``) apply BEFORE stats migration 008's gate
@@ -3775,9 +3853,16 @@ def _migration_budget_milestone_period_keys(conn: sqlite3.Connection) -> None:
3775
3853
  }
3776
3854
  return "period" in cols
3777
3855
 
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])]
3856
+ def _table_exists(table: str) -> bool:
3857
+ return conn.execute(
3858
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)
3859
+ ).fetchone() is not None
3860
+
3861
+ # Compute needs-rebuild BEFORE any transaction (no deferred-BEGIN-then-read on
3862
+ # stats.db — SQLITE_BUSY_SNAPSHOT, migrations-gotchas.md). A spec table that
3863
+ # does not exist (e.g. codex_budget_milestones on a DB predating that feature,
3864
+ # now that v012 no longer live-creates it — #143) needs no period column.
3865
+ pending = [s for s in specs if _table_exists(s[0]) and not _has_period(s[0])]
3781
3866
 
3782
3867
  if not pending:
3783
3868
  # Fresh install (live CREATE already made the new shape) or prior run.
@@ -3800,6 +3885,83 @@ def _migration_budget_milestone_period_keys(conn: sqlite3.Connection) -> None:
3800
3885
  raise
3801
3886
 
3802
3887
 
3888
+ @stats_migration("012_unify_budget_milestones_vendor")
3889
+ def _migration_unify_budget_milestones_vendor(conn: sqlite3.Connection) -> None:
3890
+ """Merge ``codex_budget_milestones`` into a vendor-tagged ``budget_milestones``
3891
+ (issue #143).
3892
+
3893
+ ``budget_milestones`` (Claude, keyed ``week_start_at``) and
3894
+ ``codex_budget_milestones`` (Codex, keyed ``period_start_at``) are
3895
+ structurally identical modulo vendor + key-column name. This migration
3896
+ rebuilds ``budget_milestones`` with a ``vendor`` column and the renamed
3897
+ ``period_start_at`` key, copies Claude rows (``week_start_at``->``period_start_at``,
3898
+ ``vendor='claude'``) and Codex rows (``vendor='codex'``), and drops the Codex
3899
+ table. History + ``alerted_at`` + ``period`` are preserved verbatim; the
3900
+ write-once ``period`` NULL sentinel is carried as-is. ``id`` is NOT copied
3901
+ (AUTOINCREMENT reassigns — the envelope/dispatch ids are composite strings,
3902
+ never the row PK).
3903
+
3904
+ State machine (idempotent / partial-state safe): the Claude rebuild and the
3905
+ Codex absorb are independently guarded, so a retry after a crash-before-stamp
3906
+ (table already unified, Codex maybe gone) is a clean no-op or a Codex-only
3907
+ absorb. Reads happen BEFORE BEGIN IMMEDIATE (SQLITE_BUSY_SNAPSHOT).
3908
+ """
3909
+ def _cols(table: str) -> set:
3910
+ return {str(r[1]) for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
3911
+
3912
+ def _table_exists(table: str) -> bool:
3913
+ return conn.execute(
3914
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)
3915
+ ).fetchone() is not None
3916
+
3917
+ claude_needs_rebuild = "vendor" not in _cols("budget_milestones")
3918
+ codex_present = _table_exists("codex_budget_milestones")
3919
+ if not claude_needs_rebuild and not codex_present:
3920
+ return # already unified, no Codex leftover -> dispatcher fast-stamps
3921
+
3922
+ new_table = """
3923
+ CREATE TABLE budget_milestones (
3924
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3925
+ vendor TEXT NOT NULL,
3926
+ period_start_at TEXT NOT NULL,
3927
+ period TEXT,
3928
+ threshold INTEGER NOT NULL,
3929
+ budget_usd REAL NOT NULL,
3930
+ spent_usd REAL NOT NULL,
3931
+ consumption_pct REAL NOT NULL,
3932
+ crossed_at_utc TEXT NOT NULL,
3933
+ alerted_at TEXT,
3934
+ UNIQUE(vendor, period_start_at, period, threshold)
3935
+ )
3936
+ """
3937
+ cols = ("vendor, period_start_at, period, threshold, budget_usd, spent_usd, "
3938
+ "consumption_pct, crossed_at_utc, alerted_at")
3939
+ conn.execute("BEGIN IMMEDIATE")
3940
+ try:
3941
+ if claude_needs_rebuild:
3942
+ conn.execute("ALTER TABLE budget_milestones RENAME TO budget_milestones_old_012")
3943
+ conn.execute(new_table)
3944
+ conn.execute(
3945
+ f"INSERT INTO budget_milestones ({cols}) "
3946
+ "SELECT 'claude', week_start_at, period, threshold, budget_usd, "
3947
+ "spent_usd, consumption_pct, crossed_at_utc, alerted_at "
3948
+ "FROM budget_milestones_old_012"
3949
+ )
3950
+ conn.execute("DROP TABLE budget_milestones_old_012")
3951
+ if codex_present:
3952
+ conn.execute(
3953
+ f"INSERT INTO budget_milestones ({cols}) "
3954
+ "SELECT 'codex', period_start_at, period, threshold, budget_usd, "
3955
+ "spent_usd, consumption_pct, crossed_at_utc, alerted_at "
3956
+ "FROM codex_budget_milestones"
3957
+ )
3958
+ conn.execute("DROP TABLE codex_budget_milestones")
3959
+ conn.commit()
3960
+ except Exception:
3961
+ conn.rollback()
3962
+ raise
3963
+
3964
+
3803
3965
  # === Region 8: Test-only migration registration (was bin/cctally:12086-12140) ===
3804
3966
 
3805
3967
  # ──────────────────────────────────────────────────────────────────────