0
0
# Multi-Database Support Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Refactor hatk's data layer into a hexagonal architecture supporting DuckDB and SQLite (and future PostgreSQL) via a `DatabasePort` interface.
**Architecture:** Extract a low-level `DatabasePort` interface for SQL execution, transactions, and bulk inserts. Keep all business logic (record ops, queries, repo tracking) in a shared `database/db.ts`. Each engine gets a thin adapter. FTS uses an optional `SearchPort` with `LIKE` fallback.
**Tech Stack:** TypeScript, DuckDB (`@duckdb/node-api`), SQLite (`better-sqlite3`), Node.js 25+
---
## Overview of Current State
**`src/db.ts` (1556 lines)** — monolithic data access layer tightly coupled to DuckDB. Module-level state: `instance`, `con` (write connection), `readCon` (read connection). Uses `writeQueue`/`readQueue` promise chains for serialization. Exports ~45 functions consumed by 13 other files.
**`src/schema.ts` (468 lines)** — generates `TableSchema` objects and DuckDB-specific DDL from AT Protocol lexicons. Uses `duckdbType` field name throughout. Consumed by `db.ts`, `fts.ts`, `main.ts`, `cli.ts`, `test.ts`, `views.ts`, `xrpc.ts`, `indexer.ts`, `server.ts`, `seed.ts`.
**`src/fts.ts` (801 lines)** — builds DuckDB FTS shadow tables using `PRAGMA create_fts_index`. Consumed by `db.ts`, `indexer.ts`, `main.ts`.
**`src/oauth/db.ts` (244 lines)** — OAuth DDL and CRUD. Uses `querySQL`/`runSQL` from `db.ts`. Already uses `$1`-style params and portable SQL.
**`src/config.ts`** — `HatkConfig.database` is currently the file path string. Need a new `databaseEngine` field.
### Files that import from db.ts (all need path updates)
- `server.ts`, `indexer.ts`, `main.ts`, `cli.ts` (not in list but uses schema.ts)
- `backfill.ts`, `feeds.ts`, `xrpc.ts`, `opengraph.ts`
- `hydrate.ts`, `labels.ts`, `hooks.ts`, `setup.ts`, `test.ts`, `seed.ts`
- `fts.ts`, `oauth/db.ts`, `oauth/server.ts`
### Files that import from schema.ts
- `db.ts`, `fts.ts`, `main.ts`, `cli.ts`, `test.ts`
- `views.ts`, `xrpc.ts`, `indexer.ts`, `server.ts`, `seed.ts`
### Files that import from fts.ts
- `db.ts`, `indexer.ts`, `main.ts`
---
## Task 1: Create `src/database/ports.ts` — Port Interfaces
**Files:**
- Create: `packages/hatk/src/database/ports.ts`
**Step 1: Write the port interfaces file**
```typescript
// packages/hatk/src/database/ports.ts
export type Dialect = 'duckdb' | 'sqlite' | 'postgres'
export interface DatabasePort {
/** Dialect identifier for SQL generation differences */
dialect: Dialect
/** Open a database connection. path is file path or ':memory:' */
open(path: string): Promise<void>
/** Close all connections and release resources */
close(): void
/** Execute a read query, return rows as plain objects */
query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>
/** Execute a write statement (INSERT, UPDATE, DELETE, DDL) */
execute(sql: string, params?: unknown[]): Promise<void>
/** Execute multiple statements in sequence (for DDL batches) */
executeMultiple(sql: string): Promise<void>
/** Begin a transaction */
beginTransaction(): Promise<void>
/** Commit the current transaction */
commit(): Promise<void>
/** Rollback the current transaction */
rollback(): Promise<void>
/** Create a bulk inserter for high-throughput writes */
createBulkInserter(table: string, columns: string[]): Promise<BulkInserter>
}
export interface BulkInserter {
/** Append a single row of values */
append(values: unknown[]): void
/** Flush buffered rows to the database */
flush(): Promise<void>
/** Close the inserter and release resources */
close(): Promise<void>
}
export interface SearchPort {
/** Build/rebuild an FTS index for a table */
buildIndex(
shadowTable: string,
sourceQuery: string,
searchColumns: string[],
): Promise<void>
/** Search a table, returning URIs with scores */
search(
shadowTable: string,
query: string,
searchColumns: string[],
limit: number,
offset: number,
): Promise<Array<{ uri: string; score: number }>>
}
```
**Step 2: Verify the file compiles**
Run: `cd /Users/chadmiller/code/hatk && npx tsc --noEmit packages/hatk/src/database/ports.ts`
Expected: No errors
**Step 3: Commit**
```bash
git add packages/hatk/src/database/ports.ts
git commit -m "feat: add DatabasePort, BulkInserter, and SearchPort interfaces"
```
---
## Task 2: Create `src/database/dialect.ts` — SQL Dialect Configs
**Files:**
- Create: `packages/hatk/src/database/dialect.ts`
**Context:** This replaces the hardcoded DuckDB types in `schema.ts:54-74` (`mapType` function). The `duckdbType` field on `ColumnDef` will be renamed to `sqlType` in a later task.
**Step 1: Write the dialect configuration file**
```typescript
// packages/hatk/src/database/dialect.ts
import type { Dialect } from './ports.ts'
export interface SqlDialect {
/** Map from lexicon type key to SQL column type */
typeMap: Record<string, string>
/** Timestamp type name */
timestampType: string
/** JSON type name */
jsonType: string
/** Parameter placeholder for index (1-based). DuckDB/Postgres: $1 SQLite: ? */
param(index: number): string
/** Whether the engine supports native bulk appenders (DuckDB) vs batched INSERT */
supportsAppender: boolean
/** SQL for upsert — 'INSERT OR REPLACE' (DuckDB/SQLite) vs 'ON CONFLICT DO UPDATE' */
upsertPrefix: string
/** Extract a string value from a JSON column. Returns SQL expression. */
jsonExtractString(column: string, path: string): string
/** Aggregate strings from a JSON array. Returns SQL expression. */
jsonArrayStringAgg(column: string, jsonPath: string): string
/** Information schema query to list user tables */
listTablesQuery: string
/** CHECKPOINT or equivalent (for WAL compaction). null if not needed. */
checkpointSQL: string | null
/** Current timestamp expression */
currentTimestamp: string
}
export const DUCKDB_DIALECT: SqlDialect = {
typeMap: {
text: 'TEXT',
integer: 'INTEGER',
bigint: 'BIGINT',
boolean: 'BOOLEAN',
blob: 'BLOB',
timestamp: 'TIMESTAMP',
timestamptz: 'TIMESTAMPTZ',
json: 'JSON',
},
timestampType: 'TIMESTAMP',
jsonType: 'JSON',
param: (i: number) => `$${i}`,
supportsAppender: true,
upsertPrefix: 'INSERT OR REPLACE INTO',
jsonExtractString: (col, path) => `json_extract_string(${col}, '${path}')`,
jsonArrayStringAgg: (col, path) => `list_string_agg(json_extract_string(${col}, '${path}'))`,
listTablesQuery: `SELECT table_name FROM information_schema.tables WHERE table_schema = 'main' AND table_name NOT LIKE '\\_%' ESCAPE '\\\\'`,
checkpointSQL: 'CHECKPOINT',
currentTimestamp: 'CURRENT_TIMESTAMP',
}
export const SQLITE_DIALECT: SqlDialect = {
typeMap: {
text: 'TEXT',
integer: 'INTEGER',
bigint: 'INTEGER',
boolean: 'INTEGER',
blob: 'BLOB',
timestamp: 'TEXT',
timestamptz: 'TEXT',
json: 'TEXT',
},
timestampType: 'TEXT',
jsonType: 'TEXT',
param: (_i: number) => '?',
supportsAppender: false,
upsertPrefix: 'INSERT OR REPLACE INTO',
jsonExtractString: (col, path) => `json_extract(${col}, '${path}')`,
jsonArrayStringAgg: (col, path) => {
// SQLite doesn't have list_string_agg — use json_each + group_concat
return `(SELECT group_concat(je.value, ' ') FROM json_each(${col}, '${path}') je)`
},
listTablesQuery: `SELECT name AS table_name FROM sqlite_master WHERE type='table' AND name NOT LIKE '\\_%' ESCAPE '\\\\'`,
checkpointSQL: null,
currentTimestamp: "datetime('now')",
}
export function getDialect(dialect: Dialect): SqlDialect {
switch (dialect) {
case 'duckdb': return DUCKDB_DIALECT
case 'sqlite': return SQLITE_DIALECT
case 'postgres': throw new Error('PostgreSQL adapter not yet implemented')
}
}
```
**Step 2: Verify the file compiles**
Run: `cd /Users/chadmiller/code/hatk && npx tsc --noEmit packages/hatk/src/database/dialect.ts`
Expected: No errors
**Step 3: Commit**
```bash
git add packages/hatk/src/database/dialect.ts
git commit -m "feat: add SQL dialect configs for DuckDB and SQLite"
```
---
## Task 3: Create DuckDB Adapter — `src/database/adapters/duckdb.ts`
**Files:**
- Create: `packages/hatk/src/database/adapters/duckdb.ts`
**Context:** Extract from `src/db.ts` lines 1-100 (the DuckDB instance, connections, `bindParams`, `enqueue`). The adapter wraps `@duckdb/node-api` and implements `DatabasePort`. It keeps the read/write connection separation and queuing.
**Step 1: Write the DuckDB adapter**
```typescript
// packages/hatk/src/database/adapters/duckdb.ts
import { DuckDBInstance } from '@duckdb/node-api'
import type { DatabasePort, BulkInserter } from '../ports.ts'
import type { Dialect } from '../ports.ts'
export class DuckDBAdapter implements DatabasePort {
dialect: Dialect = 'duckdb'
private instance!: DuckDBInstance
private writeCon!: Awaited<ReturnType<DuckDBInstance['connect']>>
private readCon!: Awaited<ReturnType<DuckDBInstance['connect']>>
private writeQueue = Promise.resolve()
private readQueue = Promise.resolve()
async open(path: string): Promise<void> {
this.instance = await DuckDBInstance.create(path)
this.writeCon = await this.instance.connect()
this.readCon = await this.instance.connect()
}
close(): void {
try { this.readCon?.closeSync() } catch {}
try { this.writeCon?.closeSync() } catch {}
try { this.instance?.closeSync() } catch {}
}
async query<T = Record<string, unknown>>(sql: string, params: unknown[] = []): Promise<T[]> {
return this.enqueue('read', async () => {
if (params.length === 0) {
const reader = await this.readCon.runAndReadAll(sql)
return this.rowsToObjects(reader) as T[]
}
const prepared = await this.readCon.prepare(sql)
this.bindParams(prepared, params)
const reader = await prepared.runAndReadAll()
return this.rowsToObjects(reader) as T[]
})
}
async execute(sql: string, params: unknown[] = []): Promise<void> {
return this.enqueue('write', async () => {
if (params.length === 0) {
await this.writeCon.run(sql)
return
}
const prepared = await this.writeCon.prepare(sql)
this.bindParams(prepared, params)
await prepared.run()
})
}
async executeMultiple(sql: string): Promise<void> {
return this.enqueue('write', async () => {
await this.writeCon.run(sql)
})
}
async beginTransaction(): Promise<void> {
return this.enqueue('write', async () => {
await this.writeCon.run('BEGIN TRANSACTION')
})
}
async commit(): Promise<void> {
return this.enqueue('write', async () => {
await this.writeCon.run('COMMIT')
})
}
async rollback(): Promise<void> {
return this.enqueue('write', async () => {
await this.writeCon.run('ROLLBACK')
})
}
async createBulkInserter(table: string, columns: string[]): Promise<BulkInserter> {
// DuckDB appender uses the write connection's table appender
// Note: DuckDB appender doesn't take column names — it appends in table column order
const appender = await this.writeCon.createAppender('main', table.replace(/"/g, ''))
return {
append(values: unknown[]) {
appender.appendRow(...values)
},
async flush() {
appender.flush()
},
async close() {
appender.close()
},
}
}
// --- Internal helpers ---
private enqueue<T>(queue: 'read' | 'write', fn: () => Promise<T>): Promise<T> {
if (queue === 'write') {
const p = this.writeQueue.then(fn)
this.writeQueue = p.then(() => {}, () => {})
return p
} else {
const p = this.readQueue.then(fn)
this.readQueue = p.then(() => {}, () => {})
return p
}
}
private bindParams(prepared: any, params: unknown[]): void {
for (let i = 0; i < params.length; i++) {
const idx = i + 1
const value = params[i]
if (value === null || value === undefined) {
prepared.bindNull(idx)
} else if (typeof value === 'string') {
prepared.bindVarchar(idx, value)
} else if (typeof value === 'number') {
if (Number.isInteger(value)) {
prepared.bindInteger(idx, value)
} else {
prepared.bindDouble(idx, value)
}
} else if (typeof value === 'boolean') {
prepared.bindBoolean(idx, value)
} else if (value instanceof Uint8Array) {
prepared.bindBlob(idx, value)
} else {
prepared.bindVarchar(idx, JSON.stringify(value))
}
}
}
private rowsToObjects(reader: any): Record<string, unknown>[] {
const columns = reader.columnNames()
const rows = reader.getRows()
return rows.map((row: any[]) => {
const obj: Record<string, unknown> = {}
for (let i = 0; i < columns.length; i++) {
obj[columns[i]] = row[i]
}
return obj
})
}
}
```
**Important notes for implementer:**
- The `bindParams` method is copied from `src/db.ts:46-80`. Check the original for any additional type bindings (BigInt, Date, etc.) that may have been added since this plan was written.
- The `rowsToObjects` method replaces the inline conversion in `src/db.ts`. Check `querySQL` (line ~1241) and `queryRecords` (line ~846) for how results are currently converted — the DuckDB reader API returns column-based data that needs flattening.
- `createBulkInserter` wraps DuckDB's `createAppender`. The current code at `db.ts:572-845` (`bulkInsertRecords`) manually manages appenders — this adapter provides the low-level appender and the shared `db.ts` will manage the staging/batch logic.
**Step 2: Verify the file compiles**
Run: `cd /Users/chadmiller/code/hatk && npx tsc --noEmit packages/hatk/src/database/adapters/duckdb.ts`
Expected: No errors (may need to check exact DuckDB API method names)
**Step 3: Commit**
```bash
git add packages/hatk/src/database/adapters/duckdb.ts
git commit -m "feat: add DuckDB adapter implementing DatabasePort"
```
---
## Task 4: Create SQLite Adapter — `src/database/adapters/sqlite.ts`
**Files:**
- Create: `packages/hatk/src/database/adapters/sqlite.ts`
**Context:** New adapter using `better-sqlite3`. SQLite is synchronous, so we wrap in promises. Uses WAL mode for concurrent reads. `BulkInserter` batches rows into multi-row INSERT within a transaction.
**Step 1: Add `better-sqlite3` dependency**
Run: `cd /Users/chadmiller/code/hatk/packages/hatk && npm install better-sqlite3 && npm install -D @types/better-sqlite3`
**Step 2: Write the SQLite adapter**
```typescript
// packages/hatk/src/database/adapters/sqlite.ts
import Database from 'better-sqlite3'
import type { DatabasePort, BulkInserter, Dialect } from '../ports.ts'
export class SQLiteAdapter implements DatabasePort {
dialect: Dialect = 'sqlite'
private db!: Database.Database
async open(path: string): Promise<void> {
this.db = new Database(path === ':memory:' ? ':memory:' : path)
this.db.pragma('journal_mode = WAL')
this.db.pragma('synchronous = NORMAL')
this.db.pragma('foreign_keys = ON')
}
close(): void {
try { this.db?.close() } catch {}
}
async query<T = Record<string, unknown>>(sql: string, params: unknown[] = []): Promise<T[]> {
const stmt = this.db.prepare(sql)
return stmt.all(...params) as T[]
}
async execute(sql: string, params: unknown[] = []): Promise<void> {
const stmt = this.db.prepare(sql)
stmt.run(...params)
}
async executeMultiple(sql: string): Promise<void> {
this.db.exec(sql)
}
async beginTransaction(): Promise<void> {
this.db.exec('BEGIN')
}
async commit(): Promise<void> {
this.db.exec('COMMIT')
}
async rollback(): Promise<void> {
this.db.exec('ROLLBACK')
}
async createBulkInserter(table: string, columns: string[]): Promise<BulkInserter> {
const placeholders = columns.map(() => '?').join(', ')
const sql = `INSERT INTO ${table} (${columns.join(', ')}) VALUES (${placeholders})`
const stmt = this.db.prepare(sql)
const buffer: unknown[][] = []
const BATCH_SIZE = 500
const self = this
return {
append(values: unknown[]) {
buffer.push(values)
if (buffer.length >= BATCH_SIZE) {
const tx = self.db.transaction(() => {
for (const row of buffer) {
stmt.run(...row)
}
})
tx()
buffer.length = 0
}
},
async flush() {
if (buffer.length > 0) {
const tx = self.db.transaction(() => {
for (const row of buffer) {
stmt.run(...row)
}
})
tx()
buffer.length = 0
}
},
async close() {
// flush remaining
if (buffer.length > 0) {
const tx = self.db.transaction(() => {
for (const row of buffer) {
stmt.run(...row)
}
})
tx()
buffer.length = 0
}
},
}
}
}
```
**Step 3: Verify the file compiles**
Run: `cd /Users/chadmiller/code/hatk && npx tsc --noEmit packages/hatk/src/database/adapters/sqlite.ts`
Expected: No errors
**Step 4: Commit**
```bash
git add packages/hatk/src/database/adapters/sqlite.ts
git commit -m "feat: add SQLite adapter implementing DatabasePort"
```
---
## Task 5: Rename `duckdbType` to `sqlType` in `schema.ts`
**Files:**
- Modify: `packages/hatk/src/schema.ts` — rename `duckdbType` field to `sqlType` everywhere
- Modify: `packages/hatk/src/db.ts` — update all references to `duckdbType`
- Modify: `packages/hatk/src/fts.ts` — update all references to `duckdbType`
**Context:** The `ColumnDef` interface at `schema.ts:4-10` has `duckdbType: string`. This needs to become `sqlType: string` since it will hold dialect-specific types. This is a mechanical rename.
**Step 1: Rename in schema.ts**
In `packages/hatk/src/schema.ts`:
- Line 8: `duckdbType: string` → `sqlType: string`
- All occurrences of `duckdbType` in the file (in `mapType` return values, `generateTableSchema`, `generateCreateTableSQL`, etc.) → `sqlType`
- Rename `TypeMapping.duckdbType` (line 50) → `TypeMapping.sqlType`
- Update all `mapType` return values: `{ duckdbType: 'TEXT', ... }` → `{ sqlType: 'TEXT', ... }`
**Step 2: Rename in db.ts**
In `packages/hatk/src/db.ts`:
- Search for all `duckdbType` references and replace with `sqlType`
- These appear in `insertRecord`, `bulkInsertRecords`, `queryRecords`, `reshapeRow`, and a few other functions where column type is checked
**Step 3: Rename in fts.ts**
In `packages/hatk/src/fts.ts`:
- Search for all `duckdbType` references and replace with `sqlType`
- These appear in `buildFtsIndex` where it checks column types
**Step 4: Verify everything compiles**
Run: `cd /Users/chadmiller/code/hatk && npx tsc --noEmit`
Expected: No errors
**Step 5: Run tests**
Run: `cd /Users/chadmiller/code/hatk && npm test`
Expected: All tests pass
**Step 6: Commit**
```bash
git add packages/hatk/src/schema.ts packages/hatk/src/db.ts packages/hatk/src/fts.ts
git commit -m "refactor: rename duckdbType to sqlType for dialect neutrality"
```
---
## Task 6: Make `schema.ts` Dialect-Aware
**Files:**
- Modify: `packages/hatk/src/schema.ts`
**Context:** The `mapType` function at `schema.ts:54-74` returns hardcoded DuckDB type names. Refactor it to accept a dialect and use the dialect's type map. The function is called from `generateTableSchema` (line 255) and `resolveUnionBranch` (line 239).
**Step 1: Update `mapType` to accept a type map**
The current `mapType` returns strings like `'TEXT'`, `'INTEGER'`, `'BOOLEAN'`, `'BLOB'`, `'JSON'`, `'TIMESTAMP'`. Replace these with lookups from a type map parameter:
```typescript
import type { SqlDialect } from './database/dialect.ts'
function mapType(prop: any, dialect: SqlDialect): TypeMapping {
if (prop.type === 'string') {
if (prop.format === 'datetime') return { sqlType: dialect.typeMap.timestamp, isRef: false }
if (prop.format === 'at-uri') return { sqlType: dialect.typeMap.text, isRef: true }
return { sqlType: dialect.typeMap.text, isRef: false }
}
if (prop.type === 'integer') return { sqlType: dialect.typeMap.integer, isRef: false }
if (prop.type === 'boolean') return { sqlType: dialect.typeMap.boolean, isRef: false }
if (prop.type === 'bytes') return { sqlType: dialect.typeMap.blob, isRef: false }
if (prop.type === 'cid-link') return { sqlType: dialect.typeMap.text, isRef: false }
if (prop.type === 'array') return { sqlType: dialect.jsonType, isRef: false }
if (prop.type === 'blob') return { sqlType: dialect.jsonType, isRef: false }
if (prop.type === 'union') return { sqlType: dialect.jsonType, isRef: false }
if (prop.type === 'unknown') return { sqlType: dialect.jsonType, isRef: false }
if (prop.type === 'object') return { sqlType: dialect.jsonType, isRef: false }
if (prop.type === 'ref') {
if (prop.ref === 'com.atproto.repo.strongRef') return { sqlType: 'STRONG_REF', isRef: true }
return { sqlType: dialect.jsonType, isRef: false }
}
return { sqlType: dialect.typeMap.text, isRef: false }
}
```
**Step 2: Thread dialect through `generateTableSchema`, `resolveUnionBranch`, `generateCreateTableSQL`, `buildSchemas`**
All of these functions need a `dialect: SqlDialect` parameter added. Thread it through the call chain:
- `buildSchemas(lexicons, collections, dialect)` → passes to `generateTableSchema`
- `generateTableSchema(nsid, lexicon, lexicons, dialect)` → passes to `mapType` and `resolveUnionBranch`
- `resolveUnionBranch(..., dialect)` → passes to `mapType`
- `generateCreateTableSQL(schema, dialect)` → uses `dialect.timestampType` for the `indexed_at` column and system columns
Update `generateCreateTableSQL` to use `dialect.timestampType` for the `indexed_at TIMESTAMP` column (line 369):
```typescript
// Before:
' indexed_at TIMESTAMP NOT NULL',
// After:
` indexed_at ${dialect.timestampType} NOT NULL`,
```
And `TIMESTAMP DEFAULT CURRENT_TIMESTAMP` in OAuth DDL becomes dialect-aware too.
**Step 3: Update callers of `buildSchemas`**
- `main.ts:70` — `buildSchemas(lexicons, collections)` → `buildSchemas(lexicons, collections, dialect)` (dialect comes from adapter)
- `cli.ts` — same pattern
**Step 4: Verify everything compiles**
Run: `cd /Users/chadmiller/code/hatk && npx tsc --noEmit`
Expected: No errors
**Step 5: Run tests**
Run: `cd /Users/chadmiller/code/hatk && npm test`
Expected: All tests pass
**Step 6: Commit**
```bash
git add packages/hatk/src/schema.ts packages/hatk/src/main.ts packages/hatk/src/cli.ts
git commit -m "refactor: make schema generation dialect-aware via SqlDialect"
```
---
## Task 7: Refactor `db.ts` to Use `DatabasePort`
**Files:**
- Modify: `packages/hatk/src/db.ts` (will become `packages/hatk/src/database/db.ts`)
**Context:** This is the largest task. The current `db.ts` has module-level DuckDB state. Replace with a module-level `DatabasePort` reference. All functions that call DuckDB directly (`con.run()`, `readCon.prepare()`, etc.) switch to `port.query()` / `port.execute()`.
**Step 1: Replace module-level DuckDB state with port reference**
Remove:
```typescript
import { DuckDBInstance } from '@duckdb/node-api'
let instance: DuckDBInstance
let con: ...
let readCon: ...
let writeQueue = Promise.resolve()
let readQueue = Promise.resolve()
function enqueue(...) { ... }
function bindParams(...) { ... }
```
Replace with:
```typescript
import type { DatabasePort, BulkInserter } from './ports.ts'
import { getDialect, type SqlDialect } from './dialect.ts'
let port: DatabasePort
let dialect: SqlDialect
export function getDatabasePort(): DatabasePort { return port }
export function getSqlDialect(): SqlDialect { return dialect }
```
**Step 2: Refactor `initDatabase`**
Current `initDatabase` (line 121) creates a DuckDB instance and runs DDL. Replace with:
```typescript
export async function initDatabase(
adapter: DatabasePort,
dbPath: string,
tableSchemas: TableSchema[],
ddlStatements: string[],
): Promise<void> {
port = adapter
dialect = getDialect(adapter.dialect)
await port.open(dbPath)
// Run system table DDL
await port.executeMultiple(SYSTEM_DDL)
// Run collection DDL
for (const ddl of ddlStatements) {
await port.executeMultiple(ddl)
}
// Store schemas in memory
for (const s of tableSchemas) {
schemas.set(s.collection, s)
}
}
```
**Step 3: Refactor `querySQL` and `runSQL`**
Current implementations use the read/write connections directly. Replace with port calls:
```typescript
export async function querySQL(sql: string, params: any[] = []): Promise<any[]> {
return port.query(sql, params)
}
export async function runSQL(sql: string, ...params: any[]): Promise<void> {
return port.execute(sql, params)
}
```
**Step 4: Refactor `closeDatabase`**
```typescript
export function closeDatabase(): void {
port?.close()
}
```
**Step 5: Refactor all other functions**
Every function that currently uses `enqueue('write', ...)` or `enqueue('read', ...)` with raw DuckDB calls needs to switch to `port.query()` or `port.execute()`. Key functions:
- `getCursor`, `setCursor` — simple query/execute, straightforward
- `setRepoStatus`, `getRepoStatus`, etc. — simple query/execute
- `insertRecord` — uses `con.prepare()` and `bindParams`. Replace with `port.execute(sql, params)`
- `deleteRecord` — similar
- `bulkInsertRecords` — currently manages DuckDB appenders directly. Replace appender creation with `port.createBulkInserter()`. The staging table logic needs adjustment based on `dialect.supportsAppender`.
- `queryRecords` — builds SQL and reads results. Replace query execution with `port.query()`. The result row conversion (DuckDB reader → objects) moves into the adapter.
- `searchRecords` — builds FTS query (DuckDB-specific `match_bm25`). This needs to dispatch to SearchPort or LIKE fallback.
- `runBatch` — iterates operations, executes each. Use `port.execute()`.
**Step 6: Handle SQL dialect differences in query building**
Places where SQL differs by dialect (found in current `db.ts`):
1. **Parameter placeholders:** Current code uses `$1`, `$2`, etc. For SQLite, these become `?`. Use `dialect.param(i)` when building parameterized queries.
2. **`INSERT OR REPLACE`:** Used in `setRepoStatus`, `setCursor`, `storeServerKey`, etc. This syntax works in both DuckDB and SQLite. No change needed.
3. **`CURRENT_TIMESTAMP`:** Used in several places. Works in both. No change needed.
4. **`string_agg`:** Used in `searchRecords`. SQLite uses `group_concat`. Parameterize.
5. **`information_schema.tables`:** Used in `getSchemaDump` and `main.ts` orphan detection. Use `dialect.listTablesQuery`.
6. **`json_extract_string` / `list_string_agg`:** DuckDB-specific JSON functions. Use dialect helpers.
**Step 7: Move file to `packages/hatk/src/database/db.ts`**
Move the refactored `db.ts` to its new location. Update the import of `schema.ts` and `fts.ts` to be relative within `database/`.
**Step 8: Verify everything compiles**
Run: `cd /Users/chadmiller/code/hatk && npx tsc --noEmit`
Expected: Compilation errors from files still importing old path — these are fixed in Task 9.
**Step 9: Commit**
```bash
git add packages/hatk/src/database/db.ts
git commit -m "refactor: rewrite db.ts to use DatabasePort instead of direct DuckDB calls"
```
---
## Task 8: Move `schema.ts` and `fts.ts` into `database/`
**Files:**
- Move: `packages/hatk/src/schema.ts` → `packages/hatk/src/database/schema.ts`
- Move: `packages/hatk/src/fts.ts` → `packages/hatk/src/database/fts.ts`
**Step 1: Move schema.ts**
Move the file. Update its internal imports if any (currently it has no imports from other hatk files).
**Step 2: Move fts.ts**
Move the file. Update its imports:
- `from './db.ts'` → `from './db.ts'` (same since both are now in `database/`)
- `from './schema.ts'` → `from './schema.ts'` (same)
**Step 3: Refactor `fts.ts` for SearchPort**
The current `fts.ts` uses DuckDB-specific `PRAGMA create_fts_index` and shadow tables. Refactor:
- Keep `stripStopWords`, `getSearchColumns`, `getLastRebuiltAt`, `ftsTableName` as-is (utility functions)
- `buildFtsIndex` should check if a `SearchPort` is available. If yes, delegate. If no, skip FTS (the `LIKE` fallback is in `searchRecords` in `db.ts`)
- `rebuildAllIndexes` stays as orchestrator
```typescript
import type { SearchPort } from './ports.ts'
let searchPort: SearchPort | null = null
export function setSearchPort(port: SearchPort | null): void {
searchPort = port
}
export function hasSearchPort(): boolean {
return searchPort !== null
}
export async function buildFtsIndex(collection: string): Promise<void> {
if (!searchPort) return // No FTS support for this adapter
// ... existing shadow table query building logic ...
// Instead of running PRAGMA directly, call:
await searchPort.buildIndex(safeName, sourceQuery, searchColNames)
}
```
**Step 4: Commit**
```bash
git add packages/hatk/src/database/schema.ts packages/hatk/src/database/fts.ts
git rm packages/hatk/src/schema.ts packages/hatk/src/fts.ts
git commit -m "refactor: move schema.ts and fts.ts into database/ directory"
```
---
## Task 9: Create DuckDB SearchPort — `src/database/adapters/duckdb-search.ts`
**Files:**
- Create: `packages/hatk/src/database/adapters/duckdb-search.ts`
**Context:** Extract the DuckDB FTS PRAGMA calls from `fts.ts` into a `SearchPort` implementation.
**Step 1: Write the DuckDB search adapter**
```typescript
// packages/hatk/src/database/adapters/duckdb-search.ts
import type { SearchPort } from '../ports.ts'
import type { DatabasePort } from '../ports.ts'
export class DuckDBSearchPort implements SearchPort {
constructor(private port: DatabasePort) {}
async buildIndex(
shadowTable: string,
sourceQuery: string,
searchColumns: string[],
): Promise<void> {
// Create shadow table
await this.port.execute(`CREATE OR REPLACE TABLE ${shadowTable} AS ${sourceQuery}`)
// Drop existing index
try {
await this.port.execute(`PRAGMA drop_fts_index('${shadowTable}')`)
} catch {}
// Build FTS index
const colList = searchColumns.map((c) => `'${c}'`).join(', ')
await this.port.execute(
`PRAGMA create_fts_index('${shadowTable}', 'uri', ${colList}, stemmer='porter', stopwords='english', strip_accents=1, lower=1, overwrite=1)`
)
}
async search(
shadowTable: string,
query: string,
searchColumns: string[],
limit: number,
offset: number,
): Promise<Array<{ uri: string; score: number }>> {
const colList = searchColumns.map((c) => `'${c}'`).join(', ')
const sql = `SELECT uri, fts_main_${shadowTable}.match_bm25(uri, $1, fields := ${colList}) AS score
FROM ${shadowTable}
WHERE score IS NOT NULL
ORDER BY score DESC
LIMIT $2 OFFSET $3`
return this.port.query(sql, [query, limit, offset])
}
}
```
**Step 2: Commit**
```bash
git add packages/hatk/src/database/adapters/duckdb-search.ts
git commit -m "feat: add DuckDB SearchPort for FTS via PRAGMA"
```
---
## Task 10: Update All Import Paths
**Files to modify (every file that imports from `db.ts`, `schema.ts`, or `fts.ts`):**
- `packages/hatk/src/server.ts` — `'./db.ts'` → `'./database/db.ts'`, `'./schema.ts'` → `'./database/schema.ts'`
- `packages/hatk/src/indexer.ts` — `'./db.ts'` → `'./database/db.ts'`, `'./schema.ts'` → `'./database/schema.ts'`, `'./fts.ts'` → `'./database/fts.ts'`
- `packages/hatk/src/main.ts` — `'./db.ts'` → `'./database/db.ts'`, `'./schema.ts'` → `'./database/schema.ts'`, `'./fts.ts'` → `'./database/fts.ts'`
- `packages/hatk/src/cli.ts` — `'./schema.ts'` → `'./database/schema.ts'`
- `packages/hatk/src/backfill.ts` — `'./db.ts'` → `'./database/db.ts'`
- `packages/hatk/src/feeds.ts` — `'./db.ts'` → `'./database/db.ts'`
- `packages/hatk/src/xrpc.ts` — `'./db.ts'` → `'./database/db.ts'`, `'./schema.ts'` → `'./database/schema.ts'`
- `packages/hatk/src/opengraph.ts` — `'./db.ts'` → `'./database/db.ts'`
- `packages/hatk/src/hydrate.ts` — `'./db.ts'` → `'./database/db.ts'`
- `packages/hatk/src/labels.ts` — `'./db.ts'` → `'./database/db.ts'`
- `packages/hatk/src/hooks.ts` — `'./db.ts'` → `'./database/db.ts'`
- `packages/hatk/src/setup.ts` — `'./db.ts'` → `'./database/db.ts'`
- `packages/hatk/src/test.ts` — `'./db.ts'` → `'./database/db.ts'`, `'./schema.ts'` → `'./database/schema.ts'`
- `packages/hatk/src/seed.ts` — `'./schema.ts'` → `'./database/schema.ts'`
- `packages/hatk/src/views.ts` — `'./schema.ts'` → `'./database/schema.ts'`
- `packages/hatk/src/oauth/db.ts` — `'../db.ts'` → `'../database/db.ts'`
- `packages/hatk/src/oauth/server.ts` — `'../db.ts'` → `'../database/db.ts'`
**Step 1: Update all imports**
Mechanical find-and-replace in each file listed above. Also delete the old `src/db.ts`, `src/schema.ts`, `src/fts.ts` files if not already done.
**Step 2: Verify everything compiles**
Run: `cd /Users/chadmiller/code/hatk && npx tsc --noEmit`
Expected: No errors
**Step 3: Run tests**
Run: `cd /Users/chadmiller/code/hatk && npm test`
Expected: All tests pass
**Step 4: Commit**
```bash
git add -A
git commit -m "refactor: update all import paths to database/ directory"
```
---
## Task 11: Add `databaseEngine` Config Option and Adapter Factory
**Files:**
- Modify: `packages/hatk/src/config.ts` — add `databaseEngine` field
- Create: `packages/hatk/src/database/adapter-factory.ts` — dynamic import and instantiation
- Modify: `packages/hatk/src/main.ts` — use adapter factory
**Step 1: Add config field**
In `packages/hatk/src/config.ts`, add to `HatkConfig` interface (line 40-51):
```typescript
databaseEngine: 'duckdb' | 'sqlite' // which database adapter to use
```
In `loadConfig` (line 69), add default:
```typescript
databaseEngine: (env.DATABASE_ENGINE as any) || parsed.databaseEngine || 'duckdb',
```
Update `HatkConfigInput` to make it optional (it defaults to `'duckdb'`).
**Step 2: Create adapter factory**
```typescript
// packages/hatk/src/database/adapter-factory.ts
import type { DatabasePort } from './ports.ts'
import type { SearchPort } from './ports.ts'
export async function createAdapter(engine: 'duckdb' | 'sqlite'): Promise<{
adapter: DatabasePort
searchPort: SearchPort | null
}> {
switch (engine) {
case 'duckdb': {
const { DuckDBAdapter } = await import('./adapters/duckdb.ts')
const { DuckDBSearchPort } = await import('./adapters/duckdb-search.ts')
const adapter = new DuckDBAdapter()
const searchPort = new DuckDBSearchPort(adapter)
return { adapter, searchPort }
}
case 'sqlite': {
const { SQLiteAdapter } = await import('./adapters/sqlite.ts')
return { adapter: new SQLiteAdapter(), searchPort: null }
}
default:
throw new Error(`Unsupported database engine: ${engine}`)
}
}
```
**Step 3: Update `main.ts` startup**
Replace the current direct DuckDB initialization with:
```typescript
import { createAdapter } from './database/adapter-factory.ts'
import { setSearchPort } from './database/fts.ts'
// ... after config loaded ...
const { adapter, searchPort } = await createAdapter(config.databaseEngine)
setSearchPort(searchPort)
if (config.database !== ':memory:') {
mkdirSync(dirname(config.database), { recursive: true })
}
await initDatabase(adapter, config.database, schemas, ddlStatements)
log(`[main] Database initialized (${config.databaseEngine}, ${config.database === ':memory:' ? 'in-memory' : config.database})`)
```
**Step 4: Update `test.ts` startup**
The test utility at `packages/hatk/src/test.ts` also calls `initDatabase`. Update it similarly to create a DuckDB adapter (or make the adapter configurable for test).
**Step 5: Verify everything compiles**
Run: `cd /Users/chadmiller/code/hatk && npx tsc --noEmit`
Expected: No errors
**Step 6: Run tests**
Run: `cd /Users/chadmiller/code/hatk && npm test`
Expected: All tests pass
**Step 7: Commit**
```bash
git add packages/hatk/src/config.ts packages/hatk/src/database/adapter-factory.ts packages/hatk/src/main.ts packages/hatk/src/test.ts
git commit -m "feat: add databaseEngine config and adapter factory for DuckDB/SQLite selection"
```
---
## Task 12: Create `database/index.ts` Re-Export
**Files:**
- Create: `packages/hatk/src/database/index.ts`
**Context:** Provide a clean public API for the database module. External consumers (user-written feeds, xrpc handlers) may import from hatk — provide a barrel export.
**Step 1: Write the index file**
```typescript
// packages/hatk/src/database/index.ts
export type { DatabasePort, BulkInserter, SearchPort, Dialect } from './ports.ts'
export type { SqlDialect } from './dialect.ts'
export { getDialect, DUCKDB_DIALECT, SQLITE_DIALECT } from './dialect.ts'
export { createAdapter } from './adapter-factory.ts'
// Re-export commonly used functions from db.ts
export {
initDatabase,
closeDatabase,
querySQL,
runSQL,
insertRecord,
deleteRecord,
queryRecords,
searchRecords,
getRecordByUri,
getCursor,
setCursor,
bulkInsertRecords,
packCursor,
unpackCursor,
} from './db.ts'
// Re-export schema utilities
export {
type TableSchema,
type ColumnDef,
type ChildTableSchema,
loadLexicons,
discoverCollections,
buildSchemas,
generateTableSchema,
generateCreateTableSQL,
toSnakeCase,
getLexicon,
getLexiconArray,
getAllLexicons,
storeLexicons,
} from './schema.ts'
```
**Step 2: Commit**
```bash
git add packages/hatk/src/database/index.ts
git commit -m "feat: add database module barrel export"
```
---
## Task 13: End-to-End Verification
**Step 1: Full type check**
Run: `cd /Users/chadmiller/code/hatk && npx tsc --noEmit`
Expected: No errors
**Step 2: Run all tests**
Run: `cd /Users/chadmiller/code/hatk && npm test`
Expected: All tests pass
**Step 3: Build**
Run: `cd /Users/chadmiller/code/hatk && npm run build`
Expected: Clean build
**Step 4: Manual smoke test with DuckDB**
Run: `cd /Users/chadmiller/code/hatk && node packages/hatk/dist/cli.js dev`
Expected: Server starts, connects to DuckDB, runs normally
**Step 5: Manual smoke test with SQLite**
Create a test config with `databaseEngine: 'sqlite'` and `database: 'test.db'`. Run and verify tables are created in SQLite format.
**Step 6: Commit any fixes**
If any issues were found, fix and commit.
---
## Task 14: Update `hatk new` Scaffolding
**Files:**
- Modify: `packages/hatk/src/cli.ts` — add database engine prompt to `hatk new`
**Context:** The `hatk new` command scaffolds a new project. Add a prompt asking which database engine to use, and set the default in the generated `hatk.config.ts`.
**Step 1: Find the `hatk new` command implementation**
Look in `cli.ts` for the `new` command handler. Add a selection prompt for database engine.
**Step 2: Update generated `hatk.config.ts` template**
The generated config should include:
```typescript
databaseEngine: 'duckdb', // or 'sqlite' based on selection
```
**Step 3: Update generated `package.json` dependencies**
If user selects SQLite, include `better-sqlite3` instead of `@duckdb/node-api` in the generated `package.json`.
**Step 4: Verify scaffolding works**
Run: `cd /tmp && hatk new test-project` (select each database option)
Expected: Project scaffolded with correct config and dependencies
**Step 5: Commit**
```bash
git add packages/hatk/src/cli.ts
git commit -m "feat: add database engine selection to hatk new scaffolding"
```
---
## Summary
| Task | Description | Key files |
|------|-------------|-----------|
| 1 | Port interfaces | `database/ports.ts` |
| 2 | Dialect configs | `database/dialect.ts` |
| 3 | DuckDB adapter | `database/adapters/duckdb.ts` |
| 4 | SQLite adapter | `database/adapters/sqlite.ts` |
| 5 | Rename duckdbType→sqlType | `schema.ts`, `db.ts`, `fts.ts` |
| 6 | Dialect-aware schema gen | `schema.ts` |
| 7 | Refactor db.ts to use port | `database/db.ts` |
| 8 | Move schema+fts to database/ | `database/schema.ts`, `database/fts.ts` |
| 9 | DuckDB SearchPort | `database/adapters/duckdb-search.ts` |
| 10 | Update all import paths | 17 files |
| 11 | Config + adapter factory | `config.ts`, `database/adapter-factory.ts`, `main.ts` |
| 12 | Barrel export | `database/index.ts` |
| 13 | End-to-end verification | — |
| 14 | Update hatk new scaffolding | `cli.ts` |