From ab4057da9e2262156fe0652ba0095ea55269225c Mon Sep 17 00:00:00 2001 From: Benjamin Bachmann Date: Mon, 13 Apr 2026 10:17:43 +0200 Subject: [PATCH 1/2] fix(scripts): add recover-dev-db.sql for fork-branch migration mismatch Local dev DBs that were built up on a fork branch can have migration IDs 14-18 recorded that map to different migrations than the ones on main. The Effect-SQL migrator only tracks numeric IDs, so main's migrations 14-18 never run on those DBs, producing errors like: SQLiteError: no such column: default_model_selection_json ProviderSessionDirectoryPersistenceError: Unknown persisted provider 'claudeCode'. This script applies the missing schema deltas (migrations 14-18) and renames the legacy 'claudeCode' provider to 'claudeAgent', without touching effect_sql_migrations. Usage: cp ~/.t3/dev/state.sqlite ~/.t3/dev/state.sqlite.bak sqlite3 ~/.t3/dev/state.sqlite < scripts/recover-dev-db.sql --- scripts/recover-dev-db.sql | 249 +++++++++++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 scripts/recover-dev-db.sql diff --git a/scripts/recover-dev-db.sql b/scripts/recover-dev-db.sql new file mode 100644 index 00000000000..9f32c55a30d --- /dev/null +++ b/scripts/recover-dev-db.sql @@ -0,0 +1,249 @@ +-- ============================================================ +-- Recover dev DB after switching from a fork branch back to main +-- ============================================================ +-- +-- WHEN TO USE THIS +-- ---------------- +-- Run this if `bun run dev` fails with one of these errors: +-- * SQLiteError: no such column: default_model_selection_json +-- * ProviderSessionDirectoryPersistenceError: Unknown persisted +-- provider 'claudeCode'. +-- +-- WHY THIS HAPPENS +-- ---------------- +-- The Effect-SQL migrator tracks migrations by numeric ID only, +-- not by name. If your local dev DB was built up on a fork branch +-- whose migrations 14-18 differed from main, those IDs are marked +-- as "applied" and main's migrations 14-18 will never run. +-- +-- Concretely, the fork timeline used IDs 14-18 for: +-- 14 ProjectionThreadActivityParentAndItemId +-- 15 ProjectionThreadsJiraTicket +-- 16 ReviewComments +-- 17 ReviewRequests +-- 18 ReviewRequestsPrMeta +-- whereas main uses the same IDs for: +-- 14 ProjectionThreadProposedPlanImplementation +-- 15 ProjectionTurnsSourceProposedPlan +-- 16 CanonicalizeModelSelections <- adds default_model_selection_json +-- 17 ProjectionThreadsArchivedAt +-- 18 ProjectionThreadsArchivedAtIndex +-- +-- HOW TO USE +-- ---------- +-- 1. Stop your dev server. +-- 2. Make a backup: +-- cp ~/.t3/dev/state.sqlite ~/.t3/dev/state.sqlite.bak-$(date +%Y%m%d-%H%M%S) +-- 3. Apply this script: +-- sqlite3 ~/.t3/dev/state.sqlite < scripts/recover-dev-db.sql +-- 4. Start dev again: +-- bun run dev +-- +-- This script does NOT touch effect_sql_migrations -- IDs 14-21 are +-- already recorded as applied, so the migrator will not try to re-run +-- anything. We only fill in the schema deltas main expects. +-- +-- All operations are wrapped in a single transaction. Re-running the +-- script after a partial failure is not supported -- restore the backup +-- and try again. +-- ============================================================ + +BEGIN TRANSACTION; + +-- ============================================================ +-- STEP A: Rename legacy provider 'claudeCode' -> 'claudeAgent' +-- ============================================================ +UPDATE provider_session_runtime SET provider_name = 'claudeAgent' WHERE provider_name = 'claudeCode'; +UPDATE provider_session_runtime SET adapter_key = 'claudeAgent' WHERE adapter_key = 'claudeCode'; +UPDATE projection_thread_sessions SET provider_name = 'claudeAgent' WHERE provider_name = 'claudeCode'; +UPDATE orchestration_events + SET payload_json = REPLACE(payload_json, '"claudeCode"', '"claudeAgent"') + WHERE payload_json LIKE '%claudeCode%'; + +-- ============================================================ +-- STEP B: Migration 14 (main) -- ProjectionThreadProposedPlanImplementation +-- ============================================================ +ALTER TABLE projection_thread_proposed_plans ADD COLUMN implemented_at TEXT; +ALTER TABLE projection_thread_proposed_plans ADD COLUMN implementation_thread_id TEXT; + +-- ============================================================ +-- STEP C: Migration 15 -- ProjectionTurnsSourceProposedPlan +-- ============================================================ +ALTER TABLE projection_turns ADD COLUMN source_proposed_plan_thread_id TEXT; +ALTER TABLE projection_turns ADD COLUMN source_proposed_plan_id TEXT; + +-- ============================================================ +-- STEP D: Migration 16 -- CanonicalizeModelSelections +-- ============================================================ +ALTER TABLE projection_projects ADD COLUMN default_model_selection_json TEXT; + +UPDATE projection_projects + SET default_model_selection_json = CASE + WHEN default_model IS NULL THEN NULL + ELSE json_object( + 'provider', + CASE WHEN lower(default_model) LIKE '%claude%' THEN 'claudeAgent' ELSE 'codex' END, + 'model', + default_model + ) + END + WHERE default_model_selection_json IS NULL; + +ALTER TABLE projection_threads ADD COLUMN model_selection_json TEXT; + +UPDATE projection_threads + SET model_selection_json = json_object( + 'provider', + COALESCE( + (SELECT provider_name FROM projection_thread_sessions + WHERE projection_thread_sessions.thread_id = projection_threads.thread_id), + CASE WHEN lower(model) LIKE '%claude%' THEN 'claudeAgent' ELSE 'codex' END, + 'codex' + ), + 'model', + model + ) + WHERE model_selection_json IS NULL; + +ALTER TABLE projection_projects DROP COLUMN default_model; +ALTER TABLE projection_threads DROP COLUMN model; + +-- Event payload transforms (project.created / project.meta-updated) +UPDATE orchestration_events + SET payload_json = CASE + WHEN json_type(payload_json, '$.defaultModel') = 'null' THEN json_remove( + json_set(payload_json, '$.defaultModelSelection', json('null')), + '$.defaultProvider', '$.defaultModel', '$.defaultModelOptions' + ) + ELSE json_remove( + json_set(payload_json, '$.defaultModelSelection', + json_patch( + json_object( + 'provider', + CASE + WHEN json_extract(payload_json, '$.defaultProvider') IS NOT NULL + THEN json_extract(payload_json, '$.defaultProvider') + WHEN lower(json_extract(payload_json, '$.defaultModel')) LIKE '%claude%' + THEN 'claudeAgent' + ELSE 'codex' + END, + 'model', + json_extract(payload_json, '$.defaultModel') + ), + CASE + WHEN json_type(payload_json, '$.defaultModelOptions') IS NULL THEN '{}' + WHEN json_type(payload_json, '$.defaultModelOptions.codex') IS NOT NULL + OR json_type(payload_json, '$.defaultModelOptions.claudeAgent') IS NOT NULL + THEN CASE + WHEN ( + CASE + WHEN json_extract(payload_json, '$.defaultProvider') IS NOT NULL + THEN json_extract(payload_json, '$.defaultProvider') + WHEN lower(json_extract(payload_json, '$.defaultModel')) LIKE '%claude%' + THEN 'claudeAgent' + ELSE 'codex' + END + ) = 'claudeAgent' + THEN CASE + WHEN json_type(payload_json, '$.defaultModelOptions.claudeAgent') IS NOT NULL + THEN json_object('options', json(json_extract(payload_json, '$.defaultModelOptions.claudeAgent'))) + WHEN json_type(payload_json, '$.defaultModelOptions.codex') IS NOT NULL + THEN json_object('options', json(json_extract(payload_json, '$.defaultModelOptions.codex'))) + ELSE '{}' + END + ELSE CASE + WHEN json_type(payload_json, '$.defaultModelOptions.codex') IS NOT NULL + THEN json_object('options', json(json_extract(payload_json, '$.defaultModelOptions.codex'))) + WHEN json_type(payload_json, '$.defaultModelOptions.claudeAgent') IS NOT NULL + THEN json_object('options', json(json_extract(payload_json, '$.defaultModelOptions.claudeAgent'))) + ELSE '{}' + END + END + ELSE json_object('options', json(json_extract(payload_json, '$.defaultModelOptions'))) + END + ) + ), + '$.defaultProvider', '$.defaultModel', '$.defaultModelOptions' + ) + END + WHERE event_type IN ('project.created', 'project.meta-updated') + AND json_type(payload_json, '$.defaultModelSelection') IS NULL + AND json_type(payload_json, '$.defaultModel') IS NOT NULL; + +-- Event payload transforms (thread.created / thread.meta-updated / thread.turn-start-requested) +UPDATE orchestration_events + SET payload_json = json_remove( + json_set(payload_json, '$.modelSelection', + json_patch( + json_object( + 'provider', + CASE + WHEN json_extract(payload_json, '$.provider') IS NOT NULL + THEN json_extract(payload_json, '$.provider') + WHEN lower(json_extract(payload_json, '$.model')) LIKE '%claude%' + THEN 'claudeAgent' + ELSE 'codex' + END, + 'model', + json_extract(payload_json, '$.model') + ), + CASE + WHEN json_type(payload_json, '$.modelOptions') IS NULL THEN '{}' + WHEN json_type(payload_json, '$.modelOptions.codex') IS NOT NULL + OR json_type(payload_json, '$.modelOptions.claudeAgent') IS NOT NULL + THEN CASE + WHEN ( + CASE + WHEN json_extract(payload_json, '$.provider') IS NOT NULL + THEN json_extract(payload_json, '$.provider') + WHEN lower(json_extract(payload_json, '$.model')) LIKE '%claude%' + THEN 'claudeAgent' + ELSE 'codex' + END + ) = 'claudeAgent' + THEN CASE + WHEN json_type(payload_json, '$.modelOptions.claudeAgent') IS NOT NULL + THEN json_object('options', json(json_extract(payload_json, '$.modelOptions.claudeAgent'))) + WHEN json_type(payload_json, '$.modelOptions.codex') IS NOT NULL + THEN json_object('options', json(json_extract(payload_json, '$.modelOptions.codex'))) + ELSE '{}' + END + ELSE CASE + WHEN json_type(payload_json, '$.modelOptions.codex') IS NOT NULL + THEN json_object('options', json(json_extract(payload_json, '$.modelOptions.codex'))) + WHEN json_type(payload_json, '$.modelOptions.claudeAgent') IS NOT NULL + THEN json_object('options', json(json_extract(payload_json, '$.modelOptions.claudeAgent'))) + ELSE '{}' + END + END + ELSE json_object('options', json(json_extract(payload_json, '$.modelOptions'))) + END + ) + ), + '$.provider', '$.model', '$.modelOptions' + ) + WHERE event_type IN ('thread.created', 'thread.meta-updated', 'thread.turn-start-requested') + AND json_type(payload_json, '$.modelSelection') IS NULL + AND json_type(payload_json, '$.model') IS NOT NULL; + +-- Backfill thread.created events that predate the model field entirely +UPDATE orchestration_events + SET payload_json = json_set(payload_json, '$.modelSelection', + json(json_object('provider', 'codex', 'model', 'gpt-5.4')) + ) + WHERE event_type = 'thread.created' + AND json_type(payload_json, '$.modelSelection') IS NULL + AND json_type(payload_json, '$.model') IS NULL; + +-- ============================================================ +-- STEP E: Migration 17 -- ProjectionThreadsArchivedAt +-- ============================================================ +ALTER TABLE projection_threads ADD COLUMN archived_at TEXT; + +-- ============================================================ +-- STEP F: Migration 18 -- ProjectionThreadsArchivedAtIndex +-- ============================================================ +CREATE INDEX IF NOT EXISTS idx_projection_threads_project_archived_at + ON projection_threads(project_id, archived_at); + +COMMIT; From c75ae5c7f36e9493114e225eab66893c9b64c622 Mon Sep 17 00:00:00 2001 From: Tobias Graf <2226232+42tg@users.noreply.github.com> Date: Wed, 15 Apr 2026 09:39:35 +0200 Subject: [PATCH 2/2] fix(scripts): add idempotency docs, migration 19 coverage, and orphan schema notes Document assumptions about migrations 19-21, add prominent non-idempotency warning, include ProjectionSnapshotLookupIndexes (Step G), and note that fork-specific orphaned columns are harmless. --- scripts/recover-dev-db.sql | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/scripts/recover-dev-db.sql b/scripts/recover-dev-db.sql index 9f32c55a30d..bdd2ff26327 100644 --- a/scripts/recover-dev-db.sql +++ b/scripts/recover-dev-db.sql @@ -29,6 +29,23 @@ -- 17 ProjectionThreadsArchivedAt -- 18 ProjectionThreadsArchivedAtIndex -- +-- ASSUMPTIONS +-- ----------- +-- This script targets the specific Tobias-fork timeline above. +-- +-- IDs 19-21 on main (ProjectionSnapshotLookupIndexes, ReviewComments, +-- ReviewRequests) are assumed to have run successfully -- the fork +-- already created the ReviewComments and ReviewRequests tables at +-- IDs 16-17, and all three migrations use IF NOT EXISTS guards. +-- This script creates the ProjectionSnapshotLookupIndexes indexes +-- (Step G) defensively in case they were missed. +-- +-- Columns/tables created by the fork's 14-18 migrations that have +-- no equivalent on main (e.g. jira_ticket columns, pr_meta tables) +-- are left in place. SQLite ignores unused columns, and they won't +-- cause issues. If they bother you, drop them manually after +-- verifying `bun run dev` starts cleanly. +-- -- HOW TO USE -- ---------- -- 1. Stop your dev server. @@ -43,9 +60,13 @@ -- already recorded as applied, so the migrator will not try to re-run -- anything. We only fill in the schema deltas main expects. -- --- All operations are wrapped in a single transaction. Re-running the --- script after a partial failure is not supported -- restore the backup --- and try again. +-- WARNING: NOT IDEMPOTENT +-- ----------------------- +-- ALTER TABLE ADD COLUMN will fail if the column already exists, and +-- SQLite has no IF NOT EXISTS guard for ADD COLUMN. If this script +-- fails partway through, the transaction rolls back automatically. +-- Restore from your backup (step 2) and re-run. +-- Do NOT attempt to run this script a second time on the same DB. -- ============================================================ BEGIN TRANSACTION; @@ -246,4 +267,15 @@ ALTER TABLE projection_threads ADD COLUMN archived_at TEXT; CREATE INDEX IF NOT EXISTS idx_projection_threads_project_archived_at ON projection_threads(project_id, archived_at); +-- ============================================================ +-- STEP G: Migration 19 -- ProjectionSnapshotLookupIndexes +-- ============================================================ +-- These may already exist if main's migration 19 ran successfully. +-- IF NOT EXISTS makes this safe to include unconditionally. +CREATE INDEX IF NOT EXISTS idx_projection_projects_workspace_root_deleted_at + ON projection_projects(workspace_root, deleted_at); + +CREATE INDEX IF NOT EXISTS idx_projection_threads_project_deleted_created + ON projection_threads(project_id, deleted_at, created_at); + COMMIT;