refactor(schema): split 852-LOC migration ledger into era files

schema.ts had grown to 852 LOC — migration history is append-only, so
the file gets bigger every release. Organize by era without fragmenting
the history.

| File                              | LOC | Contents                              |
|-----------------------------------|-----|---------------------------------------|
| schema.ts                         | 195 | helpers, runMigrations,               |
|                                   |     | ensureOptionalColumns, seed, init     |
| migrations/types.ts               |  47 | Migration/Deps types, column/table    |
|                                   |     | helpers                               |
| migrations/v1-v15.ts              |  50 | createMigrationsV1V15 — v1..v15       |
| migrations/v16-v25.ts             | 157 | createMigrationsV16V25 — v16..v25     |
| migrations/v26-v34.ts             | 158 | createMigrationsV26V34 — v26..v34     |
| migrations/v35-v37.ts             | 326 | createMigrationsV35V37 — three heavy  |
|                                   |     | table-rebuilds (runs/tasks/skills)    |

Each era exports a factory `createMigrationsVxxVyy(deps)` that takes
`{ db, readAll }` — migrations' `fn` bodies captured those names via
closure in the original; factory pattern preserves the closure shape
byte-for-byte while making the dependency flow explicit.

Master `schema.ts` imports all four and concatenates them into the
final ascending list. Order preserved including the quirky v31 -> v33
-> v32 -> v34 sequence that predated my changes.

All 37 migrations' `version`, `name`, and `sql`/`fn` bodies are
byte-for-byte identical. No behavior change; 578 tests green.

`database-base.ts` (449 LOC) kept as-is. It's a thin linear pass-
through facade — every method is 1–3 lines. Splitting a facade of
this kind by forcing mixins or abstract classes costs more than it
saves. Not a god component in the Linus sense (one responsibility:
delegate); flagged here so future readers know it's deliberate.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-17 16:45:03 -04:00
1 parent 00b0e0aaed
commit 4cfb926971
6 files changed
+757 -676

No files matched your search

+19 -676
View File
@@ -1,25 +1,16 @@
import { randomUUID } from "node:crypto";
import log from "electron-log/main.js";
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
type ReadAll = <T>(sql: string, ...params: SQLInputValue[]) => T[];
type Transaction = <T>(operation: () => T) => T;
function nowIso() {
return new Date().toISOString();
}
function columnExists(db: DatabaseSync, table: string, column: string): boolean {
const rows = db.prepare(`pragma table_info(${table})`).all() as Array<{ name: string }>;
return rows.some((row) => row.name === column);
}
function tableExists(db: DatabaseSync, table: string): boolean {
const row = db
.prepare("select name from sqlite_master where type = 'table' and name = ?")
.get(table) as { name: string } | undefined;
return !!row;
}
import type { DatabaseSync } from "node:sqlite";
import { createMigrationsV1V15 } from "./migrations/v1-v15";
import { createMigrationsV16V25 } from "./migrations/v16-v25";
import { createMigrationsV26V34 } from "./migrations/v26-v34";
import { createMigrationsV35V37 } from "./migrations/v35-v37";
import {
columnExists,
nowIso,
tableExists,
type Migration,
type ReadAll,
type Transaction,
} from "./migrations/types";
export function ensureOptionalColumns(db: DatabaseSync) {
const addColumnIfMissing = (table: string, column: string, sql: string) => {
@@ -175,660 +166,12 @@ export function runMigrations(
db.exec(`create table if not exists schema_migrations (version integer primary key, name text not null, applied_at text not null);`);
const applied = new Set(readAll<{ version: number }>("select version from schema_migrations").map((row) => row.version));
const migrations: Array<{ version: number; name: string; sql?: string; fn?: () => void }> = [
{ version: 1, name: "add_workspace_lock_lease", sql: `alter table workspaces add column lock_holder_run_id text; alter table workspaces add column lock_acquired_at text` },
{ version: 2, name: "add_approval_related_agent_id", sql: `alter table approvals add column related_agent_id text` },
{ version: 3, name: "add_profile_metadata", sql: `insert or ignore into settings (key, value) values ('profile_version', '1'); insert or ignore into settings (key, value) values ('profile_created_at', '${nowIso()}')` },
{ version: 4, name: "add_connector_capability_snapshot", sql: `alter table connectors add column capability_json text` },
{ version: 6, name: "add_agent_department", sql: `alter table agents add column department text` },
{ version: 7, name: "add_meetings_table", sql: `create table if not exists meetings (id text primary key, company_id text not null, type text not null, title text not null, organizer_agent_id text, participant_agent_ids text not null default '[]', scheduled_at text not null, duration_minutes integer not null default 30, agenda_json text not null default '[]', notes_json text not null default '[]', decisions_json text not null default '[]', action_items_json text not null default '[]', status text not null default 'scheduled', created_at text not null, updated_at text not null); create index if not exists idx_meetings_company on meetings(company_id); create index if not exists idx_meetings_organizer on meetings(organizer_agent_id)` },
{ version: 8, name: "add_documents_table", sql: `create table if not exists documents (id text primary key, company_id text not null, type text not null, title text not null, content text not null default '', author_agent_id text, reviewer_agent_id text, project_id text, goal_id text, parent_doc_id text, version integer not null default 1, status text not null default 'draft', tags_json text not null default '[]', created_at text not null, updated_at text not null); create index if not exists idx_documents_company on documents(company_id); create index if not exists idx_documents_author on documents(author_agent_id); create index if not exists idx_documents_project on documents(project_id)` },
{ version: 9, name: "add_knowledge_base_table", sql: `create table if not exists knowledge_base (id text primary key, company_id text not null, category text not null, topic text not null, content text not null, author_agent_id text, importance text not null default 'medium', tags_json text not null default '[]', referenced_entity_type text, referenced_entity_id text, created_at text not null, updated_at text not null); create index if not exists idx_knowledge_company on knowledge_base(company_id); create index if not exists idx_knowledge_category on knowledge_base(category)` },
{ version: 10, name: "add_sprints_table", sql: `create table if not exists sprints (id text primary key, company_id text not null, name text not null, goal text not null default '', start_date text not null, end_date text not null, status text not null default 'planning', retrospective_notes text not null default '', velocity_points integer not null default 0, completed_points integer not null default 0, created_at text not null, updated_at text not null); create index if not exists idx_sprints_company on sprints(company_id)` },
{ version: 11, name: "add_automation_messages_workflows", sql: `create table if not exists automation_rules (id text primary key, company_id text not null, name text not null, description text not null default '', trigger text not null, conditions_json text not null default '{}', action text not null, action_config_json text not null default '{}', source_department text, target_department text, priority integer not null default 50, status text not null default 'active', execution_count integer not null default 0, last_executed_at text, created_at text not null, updated_at text not null); create index if not exists idx_automation_rules_company on automation_rules(company_id); create index if not exists idx_automation_rules_trigger on automation_rules(trigger); create table if not exists agent_messages (id text primary key, company_id text not null, from_agent_id text not null, to_agent_id text, channel text not null default 'direct', channel_target_id text, subject text not null, body text not null, priority text not null default 'normal', read_at text, parent_message_id text, attachments_json text not null default '[]', created_at text not null); create index if not exists idx_agent_messages_company on agent_messages(company_id); create index if not exists idx_agent_messages_to on agent_messages(to_agent_id); create index if not exists idx_agent_messages_from on agent_messages(from_agent_id); create index if not exists idx_agent_messages_channel on agent_messages(channel); create table if not exists workflow_pipelines (id text primary key, company_id text not null, name text not null, description text not null default '', steps_json text not null default '[]', trigger_type text not null, trigger_config_json text not null default '{}', status text not null default 'draft', current_step_index integer not null default 0, run_count integer not null default 0, last_run_at text, created_at text not null, updated_at text not null); create index if not exists idx_workflows_company on workflow_pipelines(company_id); create table if not exists automation_log (id text primary key, company_id text not null, rule_id text not null, rule_name text not null, trigger text not null, action text not null, detail text not null default '', executed_at text not null); create index if not exists idx_automation_log_company on automation_log(company_id)` },
{ version: 12, name: "add_agent_message_reads", sql: `create table if not exists agent_message_reads (message_id text not null, agent_id text not null, read_at text not null, primary key (message_id, agent_id)); create index if not exists idx_agent_message_reads_agent on agent_message_reads(agent_id)` },
{ version: 13, name: "add_checkout_session_budget_columns", sql: `ALTER TABLE tasks ADD COLUMN checkout_run_id TEXT; ALTER TABLE tasks ADD COLUMN execution_locked_at TEXT; ALTER TABLE tasks ADD COLUMN active_session_id TEXT; ALTER TABLE cost_entries ADD COLUMN project_id TEXT; CREATE INDEX IF NOT EXISTS idx_cost_entries_project ON cost_entries(project_id)` },
{ version: 14, name: "create_skills_budget_wakeup_session_tables", sql: `CREATE TABLE IF NOT EXISTS company_skills (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, key TEXT NOT NULL, slug TEXT NOT NULL, name TEXT NOT NULL, description TEXT, markdown TEXT NOT NULL DEFAULT '', source_type TEXT NOT NULL DEFAULT 'local_path', source_locator TEXT, source_ref TEXT, trust_level TEXT NOT NULL DEFAULT 'markdown_only', compatibility TEXT NOT NULL DEFAULT 'compatible', file_inventory TEXT NOT NULL DEFAULT '[]', metadata TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE UNIQUE INDEX IF NOT EXISTS idx_company_skills_company_key ON company_skills(company_id, key); CREATE INDEX IF NOT EXISTS idx_company_skills_company_name ON company_skills(company_id, name); CREATE TABLE IF NOT EXISTS budget_policies (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, scope_type TEXT NOT NULL, scope_id TEXT NOT NULL, metric TEXT NOT NULL DEFAULT 'billed_cents', window_kind TEXT NOT NULL DEFAULT 'calendar_month_utc', amount INTEGER NOT NULL DEFAULT 0, warn_percent INTEGER NOT NULL DEFAULT 80, hard_stop_enabled INTEGER NOT NULL DEFAULT 1, notify_enabled INTEGER NOT NULL DEFAULT 1, is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_budget_policies_company_scope ON budget_policies(company_id, scope_type, scope_id, is_active); CREATE UNIQUE INDEX IF NOT EXISTS idx_budget_policies_unique ON budget_policies(company_id, scope_type, scope_id, metric, window_kind); CREATE TABLE IF NOT EXISTS budget_incidents (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, policy_id TEXT NOT NULL REFERENCES budget_policies(id), scope_type TEXT NOT NULL, scope_id TEXT NOT NULL, metric TEXT NOT NULL, window_kind TEXT NOT NULL, window_start TEXT NOT NULL, window_end TEXT NOT NULL, threshold_type TEXT NOT NULL, amount_limit INTEGER NOT NULL, amount_observed INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'open', resolved_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_budget_incidents_company_status ON budget_incidents(company_id, status); CREATE INDEX IF NOT EXISTS idx_budget_incidents_company_scope ON budget_incidents(company_id, scope_type, scope_id, status); CREATE INDEX IF NOT EXISTS idx_budget_incidents_policy ON budget_incidents(policy_id, window_start, threshold_type); CREATE TABLE IF NOT EXISTS task_sessions (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, task_id TEXT NOT NULL, agent_id TEXT NOT NULL, adapter_type TEXT NOT NULL, session_params_json TEXT, session_display_id TEXT, last_run_id TEXT, last_error TEXT, run_count INTEGER NOT NULL DEFAULT 0, total_input_tokens INTEGER NOT NULL DEFAULT 0, total_output_tokens INTEGER NOT NULL DEFAULT 0, rotated_at TEXT, rotation_reason TEXT, handoff_markdown TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_task_sessions_lookup ON task_sessions(company_id, agent_id, adapter_type, task_id); CREATE INDEX IF NOT EXISTS idx_task_sessions_company_agent ON task_sessions(company_id, agent_id, updated_at); CREATE INDEX IF NOT EXISTS idx_task_sessions_company_task ON task_sessions(company_id, task_id, updated_at); CREATE TABLE IF NOT EXISTS wakeup_requests (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, agent_id TEXT NOT NULL, source TEXT NOT NULL, trigger_detail TEXT, reason TEXT, payload TEXT, status TEXT NOT NULL DEFAULT 'queued', coalesced_count INTEGER NOT NULL DEFAULT 0, requested_by_actor_type TEXT, requested_by_actor_id TEXT, idempotency_key TEXT, run_id TEXT, requested_at TEXT NOT NULL, claimed_at TEXT, finished_at TEXT, error TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_wakeup_requests_company_agent_status ON wakeup_requests(company_id, agent_id, status); CREATE INDEX IF NOT EXISTS idx_wakeup_requests_company_requested ON wakeup_requests(company_id, requested_at); CREATE INDEX IF NOT EXISTS idx_wakeup_requests_agent_requested ON wakeup_requests(agent_id, requested_at)` },
{
version: 15, name: "migrate_session_data_to_task_sessions",
fn: () => {
const tasksWithSessions = readAll<{ id: string; companyId: string; assigneeAgentId: string | null; sessionJson: string | null; sessionDisplayId: string | null }>(
`SELECT id, company_id as companyId, assignee_agent_id as assigneeAgentId, session_json as sessionJson, session_display_id as sessionDisplayId FROM tasks WHERE session_json IS NOT NULL AND active_session_id IS NULL`,
);
let migrated = 0;
let skipped = 0;
for (const task of tasksWithSessions) {
try {
if (!task.sessionJson) continue;
JSON.parse(task.sessionJson);
const agent = task.assigneeAgentId ? db.prepare("SELECT connector_id FROM agents WHERE id = ?").get(task.assigneeAgentId) as { connector_id: string } | undefined : undefined;
const adapterType = agent?.connector_id ?? "unknown";
const sessionId = randomUUID();
const ts = nowIso();
db.prepare(`INSERT INTO task_sessions (id, company_id, task_id, agent_id, adapter_type, session_params_json, session_display_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(sessionId, task.companyId, task.id, task.assigneeAgentId ?? "unassigned", adapterType, task.sessionJson, task.sessionDisplayId, ts, ts);
db.prepare("UPDATE tasks SET active_session_id = ? WHERE id = ?").run(sessionId, task.id);
migrated++;
} catch (err) {
log.warn(`Session migration skipped task ${task.id}: ${err instanceof Error ? err.message : String(err)}`);
skipped++;
}
}
log.info(`Session migration complete: ${migrated} migrated, ${skipped} skipped`);
},
},
{ version: 16, name: "create_agent_skill_assignments", sql: `CREATE TABLE IF NOT EXISTS agent_skill_assignments (agent_id TEXT NOT NULL, skill_id TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (agent_id, skill_id), FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE, FOREIGN KEY (skill_id) REFERENCES company_skills(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_agent_skill_assignments_skill ON agent_skill_assignments(skill_id)` },
{
version: 17, name: "migrate_pending_wakes_to_wakeup_requests",
fn: () => {
const existingWakes = readAll<{ id: string; companyId: string; agentId: string; triggerReason: string; createdAt: string }>(
`SELECT id, company_id AS companyId, agent_id AS agentId, trigger_reason AS triggerReason, created_at AS createdAt FROM pending_wakes`,
);
const ts = nowIso();
for (const wake of existingWakes) {
const triggerDetail = JSON.stringify([{ trigger: wake.triggerReason, at: wake.createdAt, detail: wake.triggerReason }]);
db.prepare(`INSERT OR IGNORE INTO wakeup_requests (id, company_id, agent_id, source, trigger_detail, status, coalesced_count, requested_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`).run(wake.id, wake.companyId, wake.agentId, wake.triggerReason, triggerDetail, wake.createdAt, ts, ts);
}
db.exec("DROP TABLE IF EXISTS pending_wakes");
},
},
{
version: 18, name: "migrate_agent_budget_to_policies",
fn: () => {
const agents = readAll<{ id: string; companyId: string; budgetMonthlyUsd: number }>("SELECT id, company_id as companyId, budget_monthly_usd as budgetMonthlyUsd FROM agents WHERE budget_monthly_usd > 0");
const ts = nowIso();
for (const agent of agents) {
const existing = db.prepare("SELECT id FROM budget_policies WHERE company_id = ? AND scope_type = 'agent' AND scope_id = ? AND metric = 'billed_cents' AND window_kind = 'calendar_month_utc'").get(agent.companyId, agent.id);
if (!existing) {
db.prepare("INSERT INTO budget_policies (id, company_id, scope_type, scope_id, metric, window_kind, amount, warn_percent, hard_stop_enabled, notify_enabled, is_active, created_at, updated_at) VALUES (?, ?, 'agent', ?, 'billed_cents', 'calendar_month_utc', ?, 80, 1, 1, 1, ?, ?)").run(randomUUID(), agent.companyId, agent.id, Math.round(agent.budgetMonthlyUsd * 100), ts, ts);
}
}
log.info(`Budget migration: converted ${agents.length} agent budget(s) to policy rows`);
},
},
{
version: 19, name: "add_document_revisions_and_approval_comments",
fn: () => {
db.exec(`CREATE TABLE IF NOT EXISTS document_revisions (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, document_id TEXT NOT NULL, revision_number INTEGER NOT NULL, body TEXT NOT NULL, change_summary TEXT, created_by_agent_id TEXT, created_by_user_id TEXT, created_at TEXT NOT NULL, FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE); CREATE UNIQUE INDEX IF NOT EXISTS idx_doc_rev_doc_num ON document_revisions(document_id, revision_number); CREATE INDEX IF NOT EXISTS idx_doc_rev_company ON document_revisions(company_id, document_id, created_at); CREATE TABLE IF NOT EXISTS approval_comments (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, approval_id TEXT NOT NULL, author_agent_id TEXT, author_user_id TEXT, body TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (approval_id) REFERENCES approvals(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_approval_comments_approval ON approval_comments(approval_id, created_at)`);
if (!columnExists(db, "documents", "latest_revision_id")) {
db.prepare("ALTER TABLE documents ADD COLUMN latest_revision_id TEXT").run();
}
},
},
{
version: 20, name: "add_task_timestamps_and_task_approval_links",
fn: () => {
const addCol = (table: string, column: string, sql: string) => {
if (!columnExists(db, table, column)) db.prepare(sql).run();
};
addCol("tasks", "started_at", "ALTER TABLE tasks ADD COLUMN started_at TEXT");
addCol("tasks", "completed_at", "ALTER TABLE tasks ADD COLUMN completed_at TEXT");
addCol("tasks", "cancelled_at", "ALTER TABLE tasks ADD COLUMN cancelled_at TEXT");
db.exec(`CREATE TABLE IF NOT EXISTS task_approval_links (task_id TEXT NOT NULL, approval_id TEXT NOT NULL, company_id TEXT NOT NULL, linked_by_agent_id TEXT, created_at TEXT NOT NULL, PRIMARY KEY (task_id, approval_id), FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, FOREIGN KEY (approval_id) REFERENCES approvals(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_task_approval_links_task ON task_approval_links(task_id); CREATE INDEX IF NOT EXISTS idx_task_approval_links_approval ON task_approval_links(approval_id)`);
db.prepare("UPDATE tasks SET started_at = updated_at WHERE started_at IS NULL AND status IN ('in_progress', 'in_review', 'done')").run();
db.prepare("UPDATE tasks SET completed_at = updated_at WHERE completed_at IS NULL AND status = 'done'").run();
db.prepare("UPDATE tasks SET cancelled_at = updated_at WHERE cancelled_at IS NULL AND status = 'cancelled'").run();
},
},
{
version: 21, name: "add_work_products_task_documents_wakeup_context",
fn: () => {
db.exec(`CREATE TABLE IF NOT EXISTS work_products (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, project_id TEXT, task_id TEXT NOT NULL, type TEXT NOT NULL, provider TEXT NOT NULL, external_id TEXT, title TEXT NOT NULL, url TEXT, status TEXT NOT NULL DEFAULT 'active', review_state TEXT NOT NULL DEFAULT 'none', is_primary INTEGER NOT NULL DEFAULT 0, summary TEXT, metadata_json TEXT, created_by_run_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_work_products_task ON work_products(company_id, task_id, type); CREATE INDEX IF NOT EXISTS idx_work_products_updated ON work_products(company_id, updated_at); CREATE TABLE IF NOT EXISTS task_documents (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, task_id TEXT NOT NULL, document_id TEXT NOT NULL, key TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE); CREATE UNIQUE INDEX IF NOT EXISTS idx_task_documents_task_key ON task_documents(company_id, task_id, key); CREATE UNIQUE INDEX IF NOT EXISTS idx_task_documents_doc ON task_documents(document_id)`);
if (!columnExists(db, "wakeup_requests", "context_snapshot")) {
db.prepare("ALTER TABLE wakeup_requests ADD COLUMN context_snapshot TEXT").run();
}
},
},
{
version: 22, name: "add_config_revisions_run_events_agent_permissions",
fn: () => {
db.exec(`CREATE TABLE IF NOT EXISTS agent_config_revisions (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, agent_id TEXT NOT NULL, revision_number INTEGER NOT NULL, changed_keys TEXT NOT NULL DEFAULT '[]', config_before TEXT NOT NULL, config_after TEXT NOT NULL, source TEXT NOT NULL DEFAULT 'patch', rolled_back_from_revision_id TEXT, created_by_agent_id TEXT, created_by_user_id TEXT, created_at TEXT NOT NULL, FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE); CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_config_rev_num ON agent_config_revisions(agent_id, revision_number); CREATE INDEX IF NOT EXISTS idx_agent_config_rev_agent ON agent_config_revisions(company_id, agent_id, created_at); CREATE TABLE IF NOT EXISTS run_events (id INTEGER PRIMARY KEY AUTOINCREMENT, company_id TEXT NOT NULL, run_id TEXT NOT NULL, agent_id TEXT NOT NULL, seq INTEGER NOT NULL, event_type TEXT NOT NULL, stream TEXT, level TEXT, message TEXT, payload TEXT, created_at TEXT NOT NULL, FOREIGN KEY (run_id) REFERENCES runs(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_run_events_run ON run_events(run_id, seq); CREATE INDEX IF NOT EXISTS idx_run_events_company ON run_events(company_id, run_id)`);
const addCol = (table: string, column: string, sql: string) => {
if (!columnExists(db, table, column)) db.prepare(sql).run();
};
addCol("agents", "permissions_json", "ALTER TABLE agents ADD COLUMN permissions_json TEXT NOT NULL DEFAULT '{}'");
addCol("agents", "heartbeat_policy_json", "ALTER TABLE agents ADD COLUMN heartbeat_policy_json TEXT");
addCol("agents", "runtime_state_json", "ALTER TABLE agents ADD COLUMN runtime_state_json TEXT");
addCol("runs", "context_snapshot_json", "ALTER TABLE runs ADD COLUMN context_snapshot_json TEXT");
addCol("runs", "actor_type", "ALTER TABLE runs ADD COLUMN actor_type TEXT");
addCol("runs", "actor_id", "ALTER TABLE runs ADD COLUMN actor_id TEXT");
},
},
{
version: 23, name: "create_thread_runtime_tables",
fn: () => {
db.exec(`
CREATE TABLE IF NOT EXISTS conversation_threads (
id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
title TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'task',
related_entity_type TEXT,
related_entity_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_conv_threads_company ON conversation_threads(company_id);
CREATE INDEX IF NOT EXISTS idx_conv_threads_entity ON conversation_threads(related_entity_type, related_entity_id);
CREATE TABLE IF NOT EXISTS conversation_events (
id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE,
actor_id TEXT NOT NULL,
actor_type TEXT NOT NULL,
event_type TEXT NOT NULL,
body TEXT NOT NULL,
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_conv_events_thread ON conversation_events(thread_id, created_at);
CREATE TABLE IF NOT EXISTS thread_wait_states (
id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE,
company_id TEXT NOT NULL,
waiting_for TEXT NOT NULL,
related_approval_id TEXT,
reason TEXT NOT NULL,
created_at TEXT NOT NULL,
resolved_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_thread_wait_company ON thread_wait_states(company_id, resolved_at);
CREATE INDEX IF NOT EXISTS idx_thread_wait_thread ON thread_wait_states(thread_id);
CREATE TABLE IF NOT EXISTS conversation_participants (
thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE,
participant_id TEXT NOT NULL,
participant_type TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
joined_at TEXT NOT NULL,
PRIMARY KEY (thread_id, participant_id)
);
CREATE INDEX IF NOT EXISTS idx_conv_participants_participant ON conversation_participants(participant_id);
`);
},
},
{
version: 24, name: "create_thread_subscriptions",
sql: `CREATE TABLE IF NOT EXISTS thread_subscriptions (
thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE,
subscriber_id TEXT NOT NULL,
subscriber_type TEXT NOT NULL,
notify_on TEXT NOT NULL DEFAULT 'all',
subscribed_at TEXT NOT NULL,
PRIMARY KEY (thread_id, subscriber_id)
);
CREATE INDEX IF NOT EXISTS idx_thread_subs_subscriber ON thread_subscriptions(subscriber_id)`,
},
{
version: 25, name: "add_sender_kind_to_messages",
sql: `ALTER TABLE agent_messages ADD COLUMN sender_kind TEXT NOT NULL DEFAULT 'agent'`,
},
{
version: 26, name: "add_source_run_id_for_causal_binding",
sql: `ALTER TABLE documents ADD COLUMN source_run_id TEXT; ALTER TABLE knowledge_base ADD COLUMN source_run_id TEXT`,
},
{
version: 27, name: "backfill_task_sessions_from_tasks_session_json",
fn: () => {
const legacyTasks = readAll<{
id: string;
companyId: string;
assigneeAgentId: string | null;
sessionJson: string;
sessionDisplayId: string | null;
updatedAt: string;
connectorId: string | null;
}>(
`SELECT t.id AS id, t.company_id AS companyId, t.assignee_agent_id AS assigneeAgentId,
t.session_json AS sessionJson, t.session_display_id AS sessionDisplayId,
t.updated_at AS updatedAt, a.connector_id AS connectorId
FROM tasks t
LEFT JOIN agents a ON a.id = t.assignee_agent_id
WHERE t.session_json IS NOT NULL
AND t.id NOT IN (SELECT task_id FROM task_sessions)`,
);
for (const task of legacyTasks) {
const sessionId = randomUUID();
db.prepare(
`INSERT OR IGNORE INTO task_sessions (id, company_id, task_id, agent_id, adapter_type, session_params_json, session_display_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
sessionId,
task.companyId,
task.id,
task.assigneeAgentId ?? "unassigned",
task.connectorId ?? "unknown",
task.sessionJson,
task.sessionDisplayId,
task.updatedAt,
task.updatedAt,
);
db.prepare(
"UPDATE tasks SET active_session_id = ? WHERE id = ? AND active_session_id IS NULL",
).run(sessionId, task.id);
}
},
},
{
version: 28, name: "add_snapshot_hot_path_indexes",
fn: () => {
// Some tables (task_comments / documents / meetings / sprints / agent_messages)
// are created by ensureOptionalColumns, which runs AFTER runMigrations on
// a fresh DB. Guard each CREATE INDEX with a tableExists check so the
// migration is safe on both fresh and migrated databases.
const indexPlan: Array<{ table: string; sql: string }> = [
{ table: "tasks", sql: "CREATE INDEX IF NOT EXISTS idx_tasks_company_created_at ON tasks(company_id, created_at DESC)" },
{ table: "tasks", sql: "CREATE INDEX IF NOT EXISTS idx_tasks_company_updated_at ON tasks(company_id, updated_at DESC)" },
{ table: "approvals", sql: "CREATE INDEX IF NOT EXISTS idx_approvals_company_created_at ON approvals(company_id, created_at DESC)" },
{ table: "task_comments", sql: "CREATE INDEX IF NOT EXISTS idx_task_comments_company_created_at ON task_comments(company_id, created_at)" },
{ table: "documents", sql: "CREATE INDEX IF NOT EXISTS idx_documents_company_updated_at ON documents(company_id, updated_at DESC)" },
{ table: "meetings", sql: "CREATE INDEX IF NOT EXISTS idx_meetings_company_scheduled_at ON meetings(company_id, scheduled_at DESC)" },
{ table: "sprints", sql: "CREATE INDEX IF NOT EXISTS idx_sprints_company_start_date ON sprints(company_id, start_date DESC)" },
{ table: "runs", sql: "CREATE INDEX IF NOT EXISTS idx_runs_agent_created_at ON runs(agent_id, created_at DESC)" },
{ table: "cost_entries", sql: "CREATE INDEX IF NOT EXISTS idx_cost_entries_agent_created_at ON cost_entries(agent_id, created_at DESC)" },
{ table: "activity", sql: "CREATE INDEX IF NOT EXISTS idx_activity_company_actor_action ON activity(company_id, actor, action)" },
{ table: "tasks", sql: "CREATE INDEX IF NOT EXISTS idx_tasks_company_assignee_status ON tasks(company_id, assignee_agent_id, status)" },
];
for (const { table, sql } of indexPlan) {
if (tableExists(db, table)) db.exec(sql);
}
},
},
{
version: 29, name: "add_run_events_seq_unique_index",
sql: `CREATE UNIQUE INDEX IF NOT EXISTS idx_run_events_run_seq_unique ON run_events(run_id, seq)`,
},
{
// Supports checkpoint-engine `shouldPauseHeartbeat` join + filter on
// (company_id, state, type) -> assignee_agent_id lookup. Without this
// SQLite scans the full approvals table on every heartbeat tick.
version: 30, name: "add_checkpoint_pause_indexes",
sql: `CREATE INDEX IF NOT EXISTS idx_approvals_company_state_type ON approvals(company_id, state, type);
CREATE INDEX IF NOT EXISTS idx_approvals_related_task ON approvals(related_task_id)`,
},
{
// Typed seniority enum replaces English keyword substring matching for
// leadership detection. Free-text `role` stays UI-editable; `role_level`
// is inferred once on insert/update via inferRoleLevel().
version: 31, name: "add_agents_role_level",
fn: () => {
if (!columnExists(db, "agents", "role_level")) {
db.prepare("ALTER TABLE agents ADD COLUMN role_level TEXT NOT NULL DEFAULT 'ic'").run();
}
},
},
{
// Explicit flag for synthesized heartbeat tasks. Replaces title-prefix
// sniffing (`title LIKE '[Heartbeat]%'`) across queries, filters, and
// automation gates. Backfills from existing `[Heartbeat]` titles so the
// new column agrees with historical data on first boot.
version: 33, name: "add_tasks_is_heartbeat",
fn: () => {
if (!columnExists(db, "tasks", "is_heartbeat")) {
db.prepare("ALTER TABLE tasks ADD COLUMN is_heartbeat INTEGER NOT NULL DEFAULT 0").run();
}
db.prepare("UPDATE tasks SET is_heartbeat = 1 WHERE is_heartbeat = 0 AND title LIKE '[Heartbeat]%'").run();
db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_is_heartbeat ON tasks(is_heartbeat)");
},
},
{
// pi-mono session transcripts + credential vault. Idempotent; safe to
// re-apply on fresh DBs or DBs that already have the tables.
version: 32, name: "create_pi_mono_session_and_credentials_tables",
sql: `CREATE TABLE IF NOT EXISTS session_entries (
id TEXT PRIMARY KEY,
task_session_id TEXT NOT NULL,
sequence INTEGER NOT NULL,
kind TEXT NOT NULL,
role TEXT,
payload_json TEXT NOT NULL,
is_superseded INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_session_entries_session ON session_entries(task_session_id, sequence);
CREATE INDEX IF NOT EXISTS idx_session_entries_active ON session_entries(task_session_id, is_superseded, sequence);
CREATE TABLE IF NOT EXISTS credentials (
connector_id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
backend TEXT NOT NULL,
ciphertext BLOB NOT NULL,
display_hint TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
expires_at TEXT,
last_refreshed_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_credentials_status ON credentials(status)`,
},
{
// Per-agent session scope. Agents carry a single active session across
// tasks — switching tasks becomes a "now work on X" user turn on the
// agent's ongoing transcript. Additive-only: existing task_sessions
// rows remain as historical records; new runs use the agent-scoped
// session created lazily on first post-upgrade run.
version: 34, name: "add_agents_session_id",
sql: `ALTER TABLE agents ADD COLUMN session_id TEXT;
CREATE INDEX IF NOT EXISTS idx_agents_session_id ON agents(session_id)`,
},
{
// Rebuild runs table with task_id NULLABLE. Heartbeat runs (agent
// wake-ups with no real task) no longer need to synthesize a fake
// task row; they simply insert a run with task_id = NULL. The old
// workaround (is_heartbeat flag + acquireHeartbeatTask + cleanup
// sweeps) becomes dead code in v36.
//
// SQLite can't ALTER a column's NOT NULL constraint, so we rebuild:
// 1. Snapshot row count
// 2. Discover actual column set via PRAGMA (drift safe)
// 3. CREATE runs_new with the same columns + task_id nullable
// 4. Copy every row
// 5. Assert copied count == snapshot count (abort before DROP
// if any row went missing)
// 6. DROP old, RENAME new
// 7. Recreate every index on runs
//
// FK from run_events(run_id) references runs(id); PRAGMA foreign_keys
// OFF during the rebuild (we're inside a transaction already, so we
// toggle the defer_foreign_keys flag instead — SQLite forbids
// changing foreign_keys mid-transaction).
version: 35, name: "make_runs_task_id_nullable",
fn: () => {
const beforeCount = (db
.prepare("SELECT COUNT(*) as cnt FROM runs")
.get() as { cnt: number }).cnt;
const existingCols = (db
.prepare("PRAGMA table_info(runs)")
.all() as Array<{ name: string; type: string; notnull: number; dflt_value: unknown; pk: number }>);
if (existingCols.length === 0) {
throw new Error("runs table is missing; refusing to migrate");
}
const colNames = existingCols.map((c) => c.name);
// Declare the known v34 column set. If the live DB has drift (extra
// columns we did not declare), preserve those verbatim using the
// column metadata from PRAGMA.
const knownCols = new Set([
"id", "company_id", "task_id", "agent_id", "workspace_id",
"connector_id", "status", "summary", "error_message", "exit_code",
"signal", "model", "session_display_id", "cost_usd",
"unattributed_cost", "started_at", "finished_at", "log_path",
"created_at", "updated_at", "context_snapshot_json",
"actor_type", "actor_id",
]);
const driftCols = existingCols.filter((c) => !knownCols.has(c.name));
// Defer FK checks within this transaction.
db.exec("PRAGMA defer_foreign_keys = ON");
// Build CREATE TABLE runs_new. task_id is nullable. Other columns
// reuse the declared types / constraints. For drift columns we
// emit a best-effort column definition (type, NULL, default).
const declaredDdl = [
"id TEXT PRIMARY KEY",
"company_id TEXT NOT NULL",
"task_id TEXT", // NULLABLE (was NOT NULL)
"agent_id TEXT NOT NULL",
"workspace_id TEXT",
"connector_id TEXT NOT NULL",
"status TEXT NOT NULL",
"summary TEXT NOT NULL",
"error_message TEXT",
"exit_code INTEGER",
"signal TEXT",
"model TEXT",
"session_display_id TEXT",
"cost_usd REAL",
"unattributed_cost INTEGER NOT NULL DEFAULT 0",
"started_at TEXT",
"finished_at TEXT",
"log_path TEXT",
"created_at TEXT NOT NULL",
"updated_at TEXT NOT NULL",
"context_snapshot_json TEXT",
"actor_type TEXT",
"actor_id TEXT",
];
for (const drift of driftCols) {
const type = drift.type || "TEXT";
declaredDdl.push(`${drift.name} ${type}`);
}
db.exec(`CREATE TABLE runs_new (${declaredDdl.join(", ")})`);
// Copy preserving every column present in the old table.
const copyCols = colNames.join(", ");
db.exec(`INSERT INTO runs_new (${copyCols}) SELECT ${copyCols} FROM runs`);
const afterCount = (db
.prepare("SELECT COUNT(*) as cnt FROM runs_new")
.get() as { cnt: number }).cnt;
if (afterCount !== beforeCount) {
throw new Error(
`runs copy row count mismatch: before=${beforeCount} after=${afterCount}; aborting before DROP`,
);
}
db.exec("DROP TABLE runs");
db.exec("ALTER TABLE runs_new RENAME TO runs");
// Recreate every index on runs.
db.exec("CREATE INDEX IF NOT EXISTS idx_runs_company ON runs(company_id)");
db.exec("CREATE INDEX IF NOT EXISTS idx_runs_task ON runs(task_id)");
db.exec("CREATE INDEX IF NOT EXISTS idx_runs_agent ON runs(agent_id)");
db.exec("CREATE INDEX IF NOT EXISTS idx_runs_company_status ON runs(company_id, status)");
db.exec("CREATE INDEX IF NOT EXISTS idx_runs_company_created_at ON runs(company_id, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_runs_agent_created_at ON runs(agent_id, created_at DESC)");
log.info(`[migration v35] runs table rebuilt with nullable task_id (${afterCount} rows preserved)`);
},
},
{
// Drop the `tasks.is_heartbeat` column and all synthetic heartbeat
// task rows. Prerequisite: v35 must have made runs.task_id nullable
// so we can null-out runs that pointed at heartbeat tasks before
// deleting those task rows.
version: 36, name: "drop_tasks_is_heartbeat_and_synthetic_rows",
fn: () => {
const hasColumn = columnExists(db, "tasks", "is_heartbeat");
if (!hasColumn) {
log.info("[migration v36] tasks.is_heartbeat already absent; skipping");
return;
}
// 1. NULL-out runs that pointed at heartbeat tasks.
const updated = db
.prepare(
"UPDATE runs SET task_id = NULL WHERE task_id IN (SELECT id FROM tasks WHERE is_heartbeat = 1)",
)
.run();
const runsNulledOut = Number(updated.changes);
// 2. Delete synthetic heartbeat tasks. Cost entries referring to
// these tasks remain with their task_id pointing at now-deleted
// ids — acceptable, cost_entries.task_id has no FK constraint.
// task_comments and approvals attached to these tasks are
// already cascaded by the task row delete or would remain
// orphaned; heartbeat tasks carry neither in practice.
const beforeHeartbeatCount = (db
.prepare("SELECT COUNT(*) as cnt FROM tasks WHERE is_heartbeat = 1")
.get() as { cnt: number }).cnt;
db.exec("DELETE FROM tasks WHERE is_heartbeat = 1");
// 3. Rebuild `tasks` without the is_heartbeat column.
const beforeTasksCount = (db
.prepare("SELECT COUNT(*) as cnt FROM tasks")
.get() as { cnt: number }).cnt;
const existingCols = (db
.prepare("PRAGMA table_info(tasks)")
.all() as Array<{ name: string; type: string; notnull: number; dflt_value: unknown; pk: number }>);
const keepCols = existingCols.filter((c) => c.name !== "is_heartbeat");
db.exec("PRAGMA defer_foreign_keys = ON");
// Declared v20 + v13 + v38+v39/v20 column set for `tasks`. Drift
// columns (any column we haven't declared here) are preserved
// verbatim using PRAGMA metadata — same drift-safe shape as v35.
const declaredCols = new Map<string, string>([
["id", "TEXT PRIMARY KEY"],
["company_id", "TEXT NOT NULL"],
["project_id", "TEXT"],
["goal_id", "TEXT"],
["parent_id", "TEXT"],
["title", "TEXT NOT NULL"],
["description", "TEXT NOT NULL"],
["assignee_agent_id", "TEXT"],
["workspace_id", "TEXT"],
["status", "TEXT NOT NULL"],
["priority", "TEXT NOT NULL"],
["active_run_id", "TEXT"],
["session_json", "TEXT"],
["session_display_id", "TEXT"],
["cost_usd", "REAL NOT NULL DEFAULT 0"],
["created_at", "TEXT NOT NULL"],
["updated_at", "TEXT NOT NULL"],
["task_type", "TEXT NOT NULL DEFAULT 'general'"],
["requires_user_review", "INTEGER NOT NULL DEFAULT 0"],
["retry_count", "INTEGER NOT NULL DEFAULT 0"],
["last_failure_at", "TEXT"],
["checkout_run_id", "TEXT"],
["execution_locked_at", "TEXT"],
["active_session_id", "TEXT"],
["started_at", "TEXT"],
["completed_at", "TEXT"],
["cancelled_at", "TEXT"],
]);
const ddl = keepCols.map((c) => {
const declared = declaredCols.get(c.name);
if (declared) return `${c.name} ${declared}`;
return `${c.name} ${c.type || "TEXT"}`;
});
db.exec(`CREATE TABLE tasks_new (${ddl.join(", ")})`);
const copyCols = keepCols.map((c) => c.name).join(", ");
db.exec(`INSERT INTO tasks_new (${copyCols}) SELECT ${copyCols} FROM tasks`);
const afterTasksCount = (db
.prepare("SELECT COUNT(*) as cnt FROM tasks_new")
.get() as { cnt: number }).cnt;
if (afterTasksCount !== beforeTasksCount) {
throw new Error(
`tasks copy row count mismatch: before=${beforeTasksCount} after=${afterTasksCount}; aborting before DROP`,
);
}
db.exec("DROP TABLE tasks");
db.exec("ALTER TABLE tasks_new RENAME TO tasks");
// Recreate every index on tasks.
db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company ON tasks(company_id)");
db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee_agent_id)");
db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id)");
db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company_status ON tasks(company_id, status)");
db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company_created_at ON tasks(company_id, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company_updated_at ON tasks(company_id, updated_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company_assignee_status ON tasks(company_id, assignee_agent_id, status)");
log.info(
`[migration v36] removed tasks.is_heartbeat; deleted ${beforeHeartbeatCount} synthetic heartbeat task row(s); nulled task_id on ${runsNulledOut} run(s); preserved ${afterTasksCount} task row(s)`,
);
},
},
{
// Drop the `markdown` column from company_skills. SKILL.md on disk
// (profile/skills/{companyId}/{slug}/SKILL.md) is the authoritative
// source of truth read by pi-coding-agent's loadSkills. The DB column
// duplicated that content. UI now loads the body live via IPC.
//
// SQLite can't DROP COLUMN cleanly pre-3.35 and even the supported path
// is implemented as an invisible rebuild, so we do an explicit
// drift-safe rebuild mirroring v35/v36:
// 1. Snapshot row count
// 2. PRAGMA table_info to discover the live column set
// 3. CREATE company_skills_new without `markdown`
// 4. Copy every kept column
// 5. Assert copied count matches
// 6. DROP old, RENAME new, recreate indexes
//
// FK from agent_skill_assignments(skill_id) -> company_skills(id) must
// survive. PRAGMA defer_foreign_keys = ON keeps checks deferred until
// transaction commit (by which time RENAME has restored the table).
version: 37, name: "drop_company_skills_markdown",
fn: () => {
if (!tableExists(db, "company_skills")) {
log.info("[migration v37] company_skills table absent; skipping");
return;
}
if (!columnExists(db, "company_skills", "markdown")) {
log.info("[migration v37] company_skills.markdown already absent; skipping");
return;
}
const beforeCount = (db
.prepare("SELECT COUNT(*) as cnt FROM company_skills")
.get() as { cnt: number }).cnt;
const existingCols = (db
.prepare("PRAGMA table_info(company_skills)")
.all() as Array<{ name: string; type: string; notnull: number; dflt_value: unknown; pk: number }>);
const keepCols = existingCols.filter((c) => c.name !== "markdown");
db.exec("PRAGMA defer_foreign_keys = ON");
// Declared v14 column set (minus markdown). Drift columns preserved
// verbatim from PRAGMA metadata.
const declaredCols = new Map<string, string>([
["id", "TEXT PRIMARY KEY"],
["company_id", "TEXT NOT NULL"],
["key", "TEXT NOT NULL"],
["slug", "TEXT NOT NULL"],
["name", "TEXT NOT NULL"],
["description", "TEXT"],
["source_type", "TEXT NOT NULL DEFAULT 'local_path'"],
["source_locator", "TEXT"],
["source_ref", "TEXT"],
["trust_level", "TEXT NOT NULL DEFAULT 'markdown_only'"],
["compatibility", "TEXT NOT NULL DEFAULT 'compatible'"],
["file_inventory", "TEXT NOT NULL DEFAULT '[]'"],
["metadata", "TEXT"],
["created_at", "TEXT NOT NULL"],
["updated_at", "TEXT NOT NULL"],
]);
const ddl = keepCols.map((c) => {
const declared = declaredCols.get(c.name);
if (declared) return `${c.name} ${declared}`;
return `${c.name} ${c.type || "TEXT"}`;
});
db.exec(`CREATE TABLE company_skills_new (${ddl.join(", ")})`);
const copyCols = keepCols.map((c) => c.name).join(", ");
db.exec(`INSERT INTO company_skills_new (${copyCols}) SELECT ${copyCols} FROM company_skills`);
const afterCount = (db
.prepare("SELECT COUNT(*) as cnt FROM company_skills_new")
.get() as { cnt: number }).cnt;
if (afterCount !== beforeCount) {
throw new Error(
`company_skills copy row count mismatch: before=${beforeCount} after=${afterCount}; aborting before DROP`,
);
}
db.exec("DROP TABLE company_skills");
db.exec("ALTER TABLE company_skills_new RENAME TO company_skills");
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_company_skills_company_key ON company_skills(company_id, key)");
db.exec("CREATE INDEX IF NOT EXISTS idx_company_skills_company_name ON company_skills(company_id, name)");
log.info(`[migration v37] dropped company_skills.markdown; preserved ${afterCount} row(s)`);
},
},
const deps = { db, readAll };
const migrations: Migration[] = [
...createMigrationsV1V15(deps),
...createMigrationsV16V25(deps),
...createMigrationsV26V34(deps),
...createMigrationsV35V37(deps),
];
for (const migration of migrations) {