~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/hatk

AT Protocol app framework

1
0
main
540 lines · 17.0 KB Raw History

Auto-Schema Migration Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: When a developer changes a lexicon, hatk automatically migrates the database schema on startup and re-syncs affected data — no migration files, no manual commands.

Architecture: Add a migrateSchema() function in database/db.ts that runs after initDatabase creates tables but before setup hooks. It compares each collection's current DB columns against generateTableSchema() output and emits ALTER TABLE statements. If any columns changed, all repos are marked for re-sync.

Tech Stack: SQLite (PRAGMA table_info), DuckDB (information_schema.columns), existing TableSchema/SqlDialect types


Task 1: Add introspection methods to SqlDialect

Files:

  • Modify: packages/hatk/src/database/dialect.ts

Step 1: Add introspectColumns to SqlDialect interface

Add a method that returns the SQL query to get column names and types for a given table. The query should return rows with column_name and data_type fields.

// Add to SqlDialect interface:
/** SQL to get columns for a table. Receives table name as $1/?. Returns rows with column_name, data_type. */
introspectColumnsQuery(tableName: string): string

Step 2: Implement for DuckDB

// In DUCKDB_DIALECT:
introspectColumnsQuery: (tableName: string) =>
  `SELECT column_name, data_type FROM information_schema.columns WHERE table_name = '${tableName}'`,

Step 3: Implement for SQLite

SQLite's PRAGMA table_info returns name and type columns. Wrap it to match the expected shape:

// In SQLITE_DIALECT:
introspectColumnsQuery: (tableName: string) =>
  `PRAGMA table_info("${tableName}")`,

Note: SQLite PRAGMA returns name and type instead of column_name and data_type. The migration function will need to normalize these.

Step 4: Verify the build compiles

Run: cd packages/hatk && npx tsc --noEmit Expected: No errors


Task 2: Write migrateSchema function — column introspection

Files:

  • Modify: packages/hatk/src/database/db.ts

Step 1: Add the migrateSchema function signature and column fetching

Add after initDatabase. This function queries each collection table for its current columns and compares against the expected schema.

interface MigrationChange {
  table: string
  action: 'add' | 'drop' | 'retype'
  column: string
  type?: string
}

export async function migrateSchema(tableSchemas: TableSchema[]): Promise<MigrationChange[]> {
  const changes: MigrationChange[] = []

  for (const schema of tableSchemas) {
    if (schema.columns.length === 0) continue // generic JSON storage, skip

    const tableName = schema.collection
    const existingCols = await getExistingColumns(tableName)
    if (existingCols.size === 0) continue // table just created, nothing to migrate

    // Compare columns (next task)
  }

  return changes
}

Step 2: Add getExistingColumns helper

This queries the DB and returns a Map of column name → type, normalizing across SQLite/DuckDB.

async function getExistingColumns(tableName: string): Promise<Map<string, string>> {
  const cols = new Map<string, string>()
  try {
    const query = dialect.introspectColumnsQuery(tableName)
    const rows = await all(query)
    for (const row of rows) {
      // SQLite PRAGMA returns { name, type }, DuckDB returns { column_name, data_type }
      const name = (row.column_name || row.name) as string
      const type = ((row.data_type || row.type) as string).toUpperCase()
      cols.set(name, type)
    }
  } catch {
    // Table doesn't exist yet
  }
  return cols
}

Step 3: Verify the build compiles

Run: cd packages/hatk && npx tsc --noEmit Expected: No errors


Task 3: Implement column diff logic

Files:

  • Modify: packages/hatk/src/database/db.ts

Step 1: Add column comparison inside migrateSchema

Replace the // Compare columns (next task) comment with:

    // Expected columns: base columns (uri, cid, did, indexed_at) + schema columns
    const expectedCols = new Map<string, string>()
    expectedCols.set('uri', 'TEXT')
    expectedCols.set('cid', 'TEXT')
    expectedCols.set('did', 'TEXT')
    expectedCols.set('indexed_at', dialect.timestampType.toUpperCase())
    for (const col of schema.columns) {
      expectedCols.set(col.name, col.sqlType.toUpperCase())
    }

    // Find new columns (in expected but not in existing)
    for (const [colName, colType] of expectedCols) {
      if (!existingCols.has(colName)) {
        changes.push({ table: tableName, action: 'add', column: colName, type: colType })
      }
    }

    // Find removed columns (in existing but not in expected)
    for (const [colName] of existingCols) {
      if (!expectedCols.has(colName)) {
        changes.push({ table: tableName, action: 'drop', column: colName })
      }
    }

    // Find type changes (same name, different type)
    for (const [colName, colType] of expectedCols) {
      const existingType = existingCols.get(colName)
      if (existingType && existingType !== colType) {
        changes.push({ table: tableName, action: 'retype', column: colName, type: colType })
      }
    }

Step 2: Verify the build compiles

Run: cd packages/hatk && npx tsc --noEmit Expected: No errors


Task 4: Implement child table and union table diffing

Files:

  • Modify: packages/hatk/src/database/db.ts

Step 1: Add child table migration logic

After the main table diff in migrateSchema, add diffing for child tables and union branch tables. These follow the same pattern but with parent_uri and parent_did as base columns instead of uri/cid/did/indexed_at.

    // Diff child tables
    for (const child of schema.children) {
      const childTable = child.tableName.replace(/"/g, '')
      const existingChildCols = await getExistingColumns(childTable)
      if (existingChildCols.size === 0) continue

      const expectedChildCols = new Map<string, string>()
      expectedChildCols.set('parent_uri', 'TEXT')
      expectedChildCols.set('parent_did', 'TEXT')
      for (const col of child.columns) {
        expectedChildCols.set(col.name, col.sqlType.toUpperCase())
      }

      for (const [colName, colType] of expectedChildCols) {
        if (!existingChildCols.has(colName)) {
          changes.push({ table: childTable, action: 'add', column: colName, type: colType })
        }
      }
      for (const [colName] of existingChildCols) {
        if (!expectedChildCols.has(colName)) {
          changes.push({ table: childTable, action: 'drop', column: colName })
        }
      }
      for (const [colName, colType] of expectedChildCols) {
        const existingType = existingChildCols.get(colName)
        if (existingType && existingType !== colType) {
          changes.push({ table: childTable, action: 'retype', column: colName, type: colType })
        }
      }
    }

    // Diff union branch tables
    for (const union of schema.unions) {
      for (const branch of union.branches) {
        const branchTable = branch.tableName.replace(/"/g, '')
        const existingBranchCols = await getExistingColumns(branchTable)
        if (existingBranchCols.size === 0) continue

        const expectedBranchCols = new Map<string, string>()
        expectedBranchCols.set('parent_uri', 'TEXT')
        expectedBranchCols.set('parent_did', 'TEXT')
        for (const col of branch.columns) {
          expectedBranchCols.set(col.name, col.sqlType.toUpperCase())
        }

        for (const [colName, colType] of expectedBranchCols) {
          if (!existingBranchCols.has(colName)) {
            changes.push({ table: branchTable, action: 'add', column: colName, type: colType })
          }
        }
        for (const [colName] of existingBranchCols) {
          if (!expectedBranchCols.has(colName)) {
            changes.push({ table: branchTable, action: 'drop', column: colName })
          }
        }
        for (const [colName, colType] of expectedBranchCols) {
          const existingType = existingBranchCols.get(colName)
          if (existingType && existingType !== colType) {
            changes.push({ table: branchTable, action: 'retype', column: colName, type: colType })
          }
        }
      }
    }

Step 2: Verify the build compiles

Run: cd packages/hatk && npx tsc --noEmit Expected: No errors


Task 5: Apply ALTER TABLE statements

Files:

  • Modify: packages/hatk/src/database/db.ts

Step 1: Add applyMigrationChanges function

This takes the list of changes and executes the corresponding SQL.

async function applyMigrationChanges(changes: MigrationChange[]): Promise<void> {
  for (const change of changes) {
    const quotedTable = `"${change.table}"`
    try {
      switch (change.action) {
        case 'add':
          await run(`ALTER TABLE ${quotedTable} ADD COLUMN ${change.column} ${change.type}`)
          emit('migration', `added column "${change.column}" ${change.type} to "${change.table}"`)
          break
        case 'drop':
          await run(`ALTER TABLE ${quotedTable} DROP COLUMN ${change.column}`)
          emit('migration', `dropped column "${change.column}" from "${change.table}"`)
          break
        case 'retype':
          // Drop and re-add with new type
          await run(`ALTER TABLE ${quotedTable} DROP COLUMN ${change.column}`)
          await run(`ALTER TABLE ${quotedTable} ADD COLUMN ${change.column} ${change.type}`)
          emit('migration', `changed column "${change.column}" type to ${change.type} in "${change.table}"`)
          break
      }
    } catch (err: any) {
      emit('migration', `failed to ${change.action} column "${change.column}" on "${change.table}": ${err.message}`)
    }
  }
}

Step 2: Call applyMigrationChanges from migrateSchema

At the end of migrateSchema, before return changes:

  if (changes.length > 0) {
    await applyMigrationChanges(changes)
  }

  return changes

Step 3: Verify the build compiles

Run: cd packages/hatk && npx tsc --noEmit Expected: No errors


Task 6: Handle orphaned child/union tables

Files:

  • Modify: packages/hatk/src/database/db.ts

Step 1: Add orphaned table detection and cleanup to migrateSchema

After diffing each collection's child/union tables, detect tables that exist in the DB with the collection prefix but are no longer referenced in the schema.

  // Detect orphaned child/union tables
  for (const schema of tableSchemas) {
    if (schema.columns.length === 0) continue

    const expectedTables = new Set<string>()
    for (const child of schema.children) {
      expectedTables.add(child.tableName.replace(/"/g, ''))
    }
    for (const union of schema.unions) {
      for (const branch of union.branches) {
        expectedTables.add(branch.tableName.replace(/"/g, ''))
      }
    }

    // Query existing tables that match the collection prefix
    try {
      const rows = await all(dialect.listTablesQuery)
      for (const row of rows) {
        const name = row.table_name as string
        if (name.startsWith(schema.collection + '__') && !expectedTables.has(name)) {
          await run(`DROP TABLE IF EXISTS "${name}"`)
          emit('migration', `dropped orphaned table "${name}"`)
          changes.push({ table: name, action: 'drop', column: '*' })
        }
      }
    } catch {}
  }

Step 2: Verify the build compiles

Run: cd packages/hatk && npx tsc --noEmit Expected: No errors


Task 7: Handle index creation for new ref columns

Files:

  • Modify: packages/hatk/src/database/db.ts

Step 1: Add index creation for newly added ref columns

When a new column is added, if it's a ref column, create the corresponding index. Add this logic inside the case 'add': block in applyMigrationChanges.

        case 'add': {
          await run(`ALTER TABLE ${quotedTable} ADD COLUMN ${change.column} ${change.type}`)
          emit('migration', `added column "${change.column}" ${change.type} to "${change.table}"`)
          // Create index for ref columns
          const schema = schemas.get(change.table)
          if (schema?.refColumns.includes(change.column)) {
            const prefix = change.table.replace(/\./g, '_')
            await run(`CREATE INDEX IF NOT EXISTS idx_${prefix}_${change.column} ON ${quotedTable}(${change.column})`)
            emit('migration', `created index for ref column "${change.column}" on "${change.table}"`)
          }
          break
        }

Step 2: Verify the build compiles

Run: cd packages/hatk && npx tsc --noEmit Expected: No errors


Task 8: Trigger backfill for new empty collection tables

Files:

  • Modify: packages/hatk/src/database/db.ts

Step 1: Add empty-table detection to migrateSchema

After applying ALTER changes and orphaned table cleanup, check if any collection table has zero rows. If so, mark all repos as pending so backfill picks up the new collection.

Add at the end of migrateSchema, before return changes:

  // Check for empty collection tables — these are newly added and need backfill
  for (const schema of tableSchemas) {
    if (schema.columns.length === 0) continue
    try {
      const [row] = await all(`SELECT 1 FROM ${schema.tableName} LIMIT 1`)
      if (!row) {
        await run(`UPDATE _repos SET status = 'pending'`)
        emit('migration', `new collection "${schema.collection}" detected, marking repos for backfill`)
        break // only need to mark once
      }
    } catch {}
  }

Step 2: Verify the build compiles

Run: cd packages/hatk && npx tsc --noEmit Expected: No errors


Task 9: Wire migrateSchema into main.ts

Files:

  • Modify: packages/hatk/src/main.ts

Step 1: Import migrateSchema

Add to the existing import from ./database/db.ts:

import { initDatabase, getCursor, querySQL, getSqlDialect, getSchemaDump, migrateSchema } from './database/db.ts'

Step 2: Call migrateSchema after initDatabase, before setup hooks

Insert between initDatabase (line 89) and the schema.sql write (line 94):

// Auto-migrate schema if lexicons changed
const migrationChanges = await migrateSchema(schemas)
if (migrationChanges.length > 0) {
  log(`[main] Applied ${migrationChanges.length} schema migration(s)`)
}

This runs after initDatabase (which creates new tables) but before setup hooks and the backfill cycle.

Step 3: Verify the build compiles

Run: cd packages/hatk && npx tsc --noEmit Expected: No errors


Task 10: Remove hardcoded handle migration

Files:

  • Modify: packages/hatk/src/database/db.ts

Step 1: Remove the manual handle migration from initDatabase

The existing hardcoded migration for the handle column in _repos (lines 74-79) is now superseded by the general migration system. However, _repos is an internal table not covered by lexicon schemas. Keep it as-is for now — it doesn't conflict and handles a one-time migration for existing databases.

No code change needed. This task is a deliberate no-op to document the decision.


Task 11: Manual integration test — add a column

Step 1: In the teal template, add a field to a lexicon

Edit ~/code/hatk-template-teal/lexicons/fm/teal/alpha/feed/play.json and add a rating integer field to the record properties.

Step 2: Restart the dev server

Run: cd ~/code/hatk-template-teal && npm run dev

Step 3: Check the logs

Expected output should include:

[migration] added column "rating" INTEGER to "fm.teal.alpha.feed.play"

Step 4: Verify the schema.sql file

Run: cat ~/code/hatk-template-teal/db/schema.sql

The fm.teal.alpha.feed.play table should now include the rating column.

Step 5: Revert the lexicon change

Remove the rating field from the lexicon file. Restart the dev server again.

Expected output:

[migration] dropped column "rating" from "fm.teal.alpha.feed.play"

Task 12: Manual integration test — remove a child table

Step 1: In the teal template, remove the artists array field from the play lexicon

This should cause the fm.teal.alpha.feed.play__artists table to be dropped on next startup.

Step 2: Restart the dev server

Expected output:

[migration] dropped orphaned table "fm.teal.alpha.feed.play__artists"

Step 3: Revert the lexicon change

Restore the artists array field. Restart — the table should be recreated automatically by CREATE TABLE IF NOT EXISTS.


Task 13: Build and verify

Step 1: Run the build

Run: cd packages/hatk && npm run build Expected: Clean build, no errors

Step 2: Verify with TypeScript

Run: cd packages/hatk && npx tsc --noEmit Expected: No errors


Task 14: Commit

git add packages/hatk/src/database/db.ts packages/hatk/src/database/dialect.ts packages/hatk/src/main.ts
git commit -m "feat: auto-schema migration on startup

Diff lexicon-derived schema against actual DB columns on every startup.
Emit ALTER TABLE ADD/DROP COLUMN for changes, drop orphaned child tables,
and trigger backfill when new empty collection tables are detected."