db: split legacy schema bundle migrations

This commit is contained in:
ejclaw
2026-04-11 06:34:41 +09:00
parent f81a6032f6
commit 0e12daed8b
9 changed files with 296 additions and 235 deletions

View File

@@ -20,6 +20,20 @@ function getAppliedSchemaMigrations(
.all() as Array<{ version: number; name: string }>;
}
function getExpectedSchemaMigrations(): Array<{
version: number;
name: string;
}> {
return [
{ version: 1, name: 'legacy_schema_bundle' },
{ version: 2, name: 'scheduled_task_columns' },
{ version: 3, name: 'room_registration_canonical_columns' },
{ version: 4, name: 'message_seq_and_work_item_indexes' },
{ version: 5, name: 'sessions_composite_key' },
{ version: 6, name: 'chat_channel_metadata' },
];
}
describe('initializeDatabaseSchema', () => {
const tempDirs: string[] = [];
@@ -35,12 +49,9 @@ describe('initializeDatabaseSchema', () => {
try {
initializeDatabaseSchema(database);
expect(getAppliedSchemaMigrations(database)).toEqual([
{
version: 1,
name: 'legacy_schema_bundle',
},
]);
expect(getAppliedSchemaMigrations(database)).toEqual(
getExpectedSchemaMigrations(),
);
} finally {
database.close();
}
@@ -53,12 +64,9 @@ describe('initializeDatabaseSchema', () => {
initializeDatabaseSchema(database);
initializeDatabaseSchema(database);
expect(getAppliedSchemaMigrations(database)).toEqual([
{
version: 1,
name: 'legacy_schema_bundle',
},
]);
expect(getAppliedSchemaMigrations(database)).toEqual(
getExpectedSchemaMigrations(),
);
} finally {
database.close();
}
@@ -86,12 +94,9 @@ describe('initializeDatabaseSchema', () => {
try {
initializeDatabaseSchema(reopened);
expect(getAppliedSchemaMigrations(reopened)).toEqual([
{
version: 1,
name: 'legacy_schema_bundle',
},
]);
expect(getAppliedSchemaMigrations(reopened)).toEqual(
getExpectedSchemaMigrations(),
);
} finally {
reopened.close();
}

View File

@@ -0,0 +1,41 @@
import type { SchemaMigrationDefinition } from './types.js';
import { tryExecMigration } from './helpers.js';
export const SCHEDULED_TASK_COLUMNS_MIGRATION = {
version: 2,
name: 'scheduled_task_columns',
apply(database) {
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN context_mode TEXT DEFAULT 'isolated'`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN agent_type TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN ci_provider TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN ci_metadata TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN max_duration_ms INTEGER`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN status_message_id TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN status_started_at TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN suspended_until TEXT`,
);
},
} satisfies SchemaMigrationDefinition;

View File

@@ -0,0 +1,72 @@
import type { SchemaMigrationDefinition } from './types.js';
import { tableHasColumn, tryExecMigration } from './helpers.js';
export const ROOM_REGISTRATION_CANONICAL_COLUMNS_MIGRATION = {
version: 3,
name: 'room_registration_canonical_columns',
apply(database) {
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN mode_source TEXT NOT NULL DEFAULT 'explicit'`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN name TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN folder TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN trigger_pattern TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN requires_trigger INTEGER DEFAULT 1`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN is_main INTEGER DEFAULT 0`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN owner_agent_type TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN work_dir TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN created_at TEXT`,
);
if (tableHasColumn(database, 'room_settings', 'created_at')) {
database.exec(`
UPDATE room_settings
SET created_at = COALESCE(created_at, updated_at, CURRENT_TIMESTAMP)
`);
}
database.exec(`
CREATE TABLE IF NOT EXISTS room_role_overrides (
chat_jid TEXT NOT NULL,
role TEXT NOT NULL,
agent_type TEXT NOT NULL,
agent_config_json TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (chat_jid, role),
CHECK (role IN ('owner', 'reviewer', 'arbiter')),
CHECK (agent_type IN ('claude-code', 'codex'))
)
`);
database.exec(
`UPDATE room_settings
SET mode_source = 'explicit'
WHERE COALESCE(mode_source, '') NOT IN ('explicit', 'inferred')`,
);
},
} satisfies SchemaMigrationDefinition;

View File

@@ -0,0 +1,36 @@
import type { SchemaMigrationDefinition } from './types.js';
import { backfillMessageSeq, tryExecMigration } from './helpers.js';
export const MESSAGE_SEQ_AND_WORK_ITEM_INDEXES_MIGRATION = {
version: 4,
name: 'message_seq_and_work_item_indexes',
apply(database, args) {
try {
database.exec(
`ALTER TABLE messages ADD COLUMN is_bot_message INTEGER DEFAULT 0`,
);
database
.prepare(`UPDATE messages SET is_bot_message = 1 WHERE content LIKE ?`)
.run(`${args.assistantName}:%`);
} catch {
/* column already exists */
}
tryExecMigration(database, `ALTER TABLE messages ADD COLUMN seq INTEGER`);
backfillMessageSeq(database);
database.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq);
CREATE INDEX IF NOT EXISTS idx_messages_chat_jid_seq ON messages(chat_jid, seq);
`);
database.exec(`DROP INDEX IF EXISTS idx_work_items_group_agent;`);
database.exec(`DROP INDEX IF EXISTS idx_work_items_open;`);
database.exec(`
CREATE INDEX IF NOT EXISTS idx_work_items_group_agent
ON work_items(chat_jid, agent_type, service_id, delivery_role, status);
CREATE UNIQUE INDEX IF NOT EXISTS idx_work_items_open
ON work_items(chat_jid, agent_type, IFNULL(service_id, ''), IFNULL(delivery_role, ''))
WHERE status IN ('produced', 'delivery_retry');
`);
},
} satisfies SchemaMigrationDefinition;

View File

@@ -0,0 +1,17 @@
import { inferAgentTypeFromServiceShadow } from '../../role-service-shadow.js';
import {
backfillLegacyServiceSessions,
dropLegacyServiceSessionsTable,
migrateSessionsTableToCompositePk,
} from '../sessions.js';
import type { SchemaMigrationDefinition } from './types.js';
export const SESSIONS_COMPOSITE_KEY_MIGRATION = {
version: 5,
name: 'sessions_composite_key',
apply(database) {
migrateSessionsTableToCompositePk(database, 'claude-code');
backfillLegacyServiceSessions(database, inferAgentTypeFromServiceShadow);
dropLegacyServiceSessionsTable(database);
},
} satisfies SchemaMigrationDefinition;

View File

@@ -0,0 +1,26 @@
import type { SchemaMigrationDefinition } from './types.js';
export const CHAT_CHANNEL_METADATA_MIGRATION = {
version: 6,
name: 'chat_channel_metadata',
apply(database) {
try {
database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`);
database.exec(`ALTER TABLE chats ADD COLUMN is_group INTEGER DEFAULT 0`);
database.exec(
`UPDATE chats SET channel = 'whatsapp', is_group = 1 WHERE jid LIKE '%@g.us'`,
);
database.exec(
`UPDATE chats SET channel = 'whatsapp', is_group = 0 WHERE jid LIKE '%@s.whatsapp.net'`,
);
database.exec(
`UPDATE chats SET channel = 'discord', is_group = 1 WHERE jid LIKE 'dc:%'`,
);
database.exec(
`UPDATE chats SET channel = 'telegram', is_group = 1 WHERE jid LIKE 'tg:%'`,
);
} catch {
/* columns already exist */
}
},
} satisfies SchemaMigrationDefinition;

View File

@@ -0,0 +1,64 @@
import { Database } from 'bun:sqlite';
function getTableColumns(database: Database, tableName: string): string[] {
return (
database.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{
name: string;
}>
).map((column) => column.name);
}
export function tableHasColumn(
database: Database,
tableName: string,
columnName: string,
): boolean {
return getTableColumns(database, tableName).includes(columnName);
}
export function tryExecMigration(database: Database, sql: string): void {
try {
database.exec(sql);
} catch {
/* column already exists */
}
}
export function backfillMessageSeq(database: Database): void {
const rows = database
.prepare(
`SELECT rowid, seq
FROM messages
ORDER BY CASE WHEN seq IS NULL THEN 1 ELSE 0 END, seq, timestamp, rowid`,
)
.all() as Array<{ rowid: number; seq: number | null }>;
if (rows.length === 0) {
return;
}
let nextSeq = 1;
const assignSeq = database.prepare(
'UPDATE messages SET seq = ? WHERE rowid = ? AND seq IS NULL',
);
const tx = database.transaction(() => {
for (const row of rows) {
if (row.seq === null) {
assignSeq.run(nextSeq, row.rowid);
}
nextSeq = Math.max(nextSeq, (row.seq ?? nextSeq) + 1);
}
});
tx();
const maxSeqRow = database
.prepare('SELECT MAX(seq) AS maxSeq FROM messages')
.get() as { maxSeq: number | null };
const maxSeq = maxSeqRow.maxSeq ?? 0;
if (maxSeq > 0) {
database
.prepare('INSERT OR IGNORE INTO message_sequence (id) VALUES (?)')
.run(maxSeq);
}
}
export { getTableColumns };

View File

@@ -1,6 +1,11 @@
import type { Database } from 'bun:sqlite';
import { LEGACY_SCHEMA_BUNDLE_MIGRATION } from './001_legacy-schema-bundle.js';
import { SCHEDULED_TASK_COLUMNS_MIGRATION } from './002_scheduled-task-columns.js';
import { ROOM_REGISTRATION_CANONICAL_COLUMNS_MIGRATION } from './003_room-registration-canonical-columns.js';
import { MESSAGE_SEQ_AND_WORK_ITEM_INDEXES_MIGRATION } from './004_message-seq-and-work-item-indexes.js';
import { SESSIONS_COMPOSITE_KEY_MIGRATION } from './005_sessions-composite-key.js';
import { CHAT_CHANNEL_METADATA_MIGRATION } from './006_chat-channel-metadata.js';
import type {
SchemaMigrationArgs,
SchemaMigrationDefinition,
@@ -10,6 +15,11 @@ const SCHEMA_MIGRATIONS_TABLE = 'schema_migrations';
const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [
LEGACY_SCHEMA_BUNDLE_MIGRATION,
SCHEDULED_TASK_COLUMNS_MIGRATION,
ROOM_REGISTRATION_CANONICAL_COLUMNS_MIGRATION,
MESSAGE_SEQ_AND_WORK_ITEM_INDEXES_MIGRATION,
SESSIONS_COMPOSITE_KEY_MIGRATION,
CHAT_CHANNEL_METADATA_MIGRATION,
];
function ensureSchemaMigrationsTable(database: Database): void {

View File

@@ -1,10 +1,7 @@
import { Database } from 'bun:sqlite';
import { SERVICE_SESSION_SCOPE, normalizeServiceId } from '../config.js';
import {
inferAgentTypeFromServiceShadow,
resolveRoleServiceShadow,
} from '../role-service-shadow.js';
import { resolveRoleServiceShadow } from '../role-service-shadow.js';
import {
fillCanonicalChannelOwnerLeaseMetadata,
fillCanonicalPairedTaskMetadata,
@@ -15,33 +12,17 @@ import {
backfillPairedTurnAttemptsFromTurns,
buildPairedTurnAttemptId,
} from './paired-turn-attempts.js';
import { normalizeStoredAgentType } from './room-registration.js';
import {
backfillLegacyServiceSessions,
dropLegacyServiceSessionsTable,
migrateSessionsTableToCompositePk,
} from './sessions.js';
getTableColumns,
tableHasColumn,
tryExecMigration,
} from './migrations/helpers.js';
import { normalizeStoredAgentType } from './room-registration.js';
// Legacy monolithic migration bundle kept for compatibility with databases
// that predate ordered schema migration tracking. New schema changes belong in
// src/db/migrations/*.
function getTableColumns(database: Database, tableName: string): string[] {
return (
database.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{
name: string;
}>
).map((column) => column.name);
}
export function tableHasColumn(
database: Database,
tableName: string,
columnName: string,
): boolean {
return getTableColumns(database, tableName).includes(columnName);
}
function getTableSql(database: Database, tableName: string): string | null {
const row = database
.prepare(
@@ -56,14 +37,6 @@ function getTableSql(database: Database, tableName: string): string | null {
return row?.sql ?? null;
}
function tryExecMigration(database: Database, sql: string): void {
try {
database.exec(sql);
} catch {
/* column already exists */
}
}
function buildPairedTurnAttemptIdSql(
turnIdExpr: string,
attemptNoExpr: string,
@@ -183,43 +156,6 @@ function tableHasForeignKey(
return false;
}
function backfillMessageSeq(database: Database): void {
const rows = database
.prepare(
`SELECT rowid, seq
FROM messages
ORDER BY CASE WHEN seq IS NULL THEN 1 ELSE 0 END, seq, timestamp, rowid`,
)
.all() as Array<{ rowid: number; seq: number | null }>;
if (rows.length === 0) {
return;
}
let nextSeq = 1;
const assignSeq = database.prepare(
'UPDATE messages SET seq = ? WHERE rowid = ? AND seq IS NULL',
);
const tx = database.transaction(() => {
for (const row of rows) {
if (row.seq === null) {
assignSeq.run(nextSeq, row.rowid);
}
nextSeq = Math.max(nextSeq, (row.seq ?? nextSeq) + 1);
}
});
tx();
const maxSeqRow = database
.prepare('SELECT MAX(seq) AS maxSeq FROM messages')
.get() as { maxSeq: number | null };
const maxSeq = maxSeqRow.maxSeq ?? 0;
if (maxSeq > 0) {
database
.prepare('INSERT OR IGNORE INTO message_sequence (id) VALUES (?)')
.run(maxSeq);
}
}
interface LegacyExecutionLeaseServiceRow {
rowid: number;
role: string;
@@ -2574,98 +2510,10 @@ function backfillCanonicalWorkItemServiceIds(database: Database): void {
export function applyLegacySchemaMigrations(
database: Database,
args: {
_args: {
assistantName: string;
},
): void {
const { assistantName } = args;
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN context_mode TEXT DEFAULT 'isolated'`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN agent_type TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN ci_provider TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN ci_metadata TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN max_duration_ms INTEGER`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN status_message_id TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN status_started_at TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN suspended_until TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN mode_source TEXT NOT NULL DEFAULT 'explicit'`,
);
tryExecMigration(database, `ALTER TABLE room_settings ADD COLUMN name TEXT`);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN folder TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN trigger_pattern TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN requires_trigger INTEGER DEFAULT 1`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN is_main INTEGER DEFAULT 0`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN owner_agent_type TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN work_dir TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN created_at TEXT`,
);
if (tableHasColumn(database, 'room_settings', 'created_at')) {
database.exec(`
UPDATE room_settings
SET created_at = COALESCE(created_at, updated_at, CURRENT_TIMESTAMP)
`);
}
database.exec(`
CREATE TABLE IF NOT EXISTS room_role_overrides (
chat_jid TEXT NOT NULL,
role TEXT NOT NULL,
agent_type TEXT NOT NULL,
agent_config_json TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (chat_jid, role),
CHECK (role IN ('owner', 'reviewer', 'arbiter')),
CHECK (agent_type IN ('claude-code', 'codex'))
)
`);
tryExecMigration(
database,
`ALTER TABLE service_handoffs ADD COLUMN intended_role TEXT`,
@@ -2935,41 +2783,6 @@ export function applyLegacySchemaMigrations(
backfillCanonicalServiceHandoffServiceIds(database);
backfillCanonicalWorkItemServiceIds(database);
database.exec(
`UPDATE room_settings
SET mode_source = 'explicit'
WHERE COALESCE(mode_source, '') NOT IN ('explicit', 'inferred')`,
);
try {
database.exec(
`ALTER TABLE messages ADD COLUMN is_bot_message INTEGER DEFAULT 0`,
);
database
.prepare(`UPDATE messages SET is_bot_message = 1 WHERE content LIKE ?`)
.run(`${assistantName}:%`);
} catch {
/* column already exists */
}
tryExecMigration(database, `ALTER TABLE messages ADD COLUMN seq INTEGER`);
backfillMessageSeq(database);
database.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq);
CREATE INDEX IF NOT EXISTS idx_messages_chat_jid_seq ON messages(chat_jid, seq);
`);
database.exec(`DROP INDEX IF EXISTS idx_work_items_group_agent;`);
database.exec(`DROP INDEX IF EXISTS idx_work_items_open;`);
database.exec(`
CREATE INDEX IF NOT EXISTS idx_work_items_group_agent
ON work_items(chat_jid, agent_type, service_id, delivery_role, status);
CREATE UNIQUE INDEX IF NOT EXISTS idx_work_items_open
ON work_items(chat_jid, agent_type, IFNULL(service_id, ''), IFNULL(delivery_role, ''))
WHERE status IN ('produced', 'delivery_retry');
`);
const pairedTasksSqlRow = database
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_tasks'`,
@@ -3134,29 +2947,6 @@ export function applyLegacySchemaMigrations(
);
`);
}
migrateSessionsTableToCompositePk(database, 'claude-code');
backfillLegacyServiceSessions(database, inferAgentTypeFromServiceShadow);
dropLegacyServiceSessionsTable(database);
try {
database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`);
database.exec(`ALTER TABLE chats ADD COLUMN is_group INTEGER DEFAULT 0`);
database.exec(
`UPDATE chats SET channel = 'whatsapp', is_group = 1 WHERE jid LIKE '%@g.us'`,
);
database.exec(
`UPDATE chats SET channel = 'whatsapp', is_group = 0 WHERE jid LIKE '%@s.whatsapp.net'`,
);
database.exec(
`UPDATE chats SET channel = 'discord', is_group = 1 WHERE jid LIKE 'dc:%'`,
);
database.exec(
`UPDATE chats SET channel = 'telegram', is_group = 1 WHERE jid LIKE 'tg:%'`,
);
} catch {
/* columns already exist */
}
}
/** @deprecated New schema changes should be added under src/db/migrations/*. */