/**
 * src/db/sqlite-client.ts
 *
 * A drop-in subset of the @supabase/supabase-js v2 client backed by better-sqlite3.
 * Implements the chained-query API surface actually used by the gateway (audited via
 * grep — see docs/SQLITE_PORT.md). Existing call sites don't change shape:
 *
 *   const { data, error } = await supabase
 *     .from('tenants')
 *     .select('id, business_name')
 *     .eq('owner_id', userId)
 *     .single();
 *
 * Why a shim instead of rewriting every call site to raw SQL or Kysely:
 * - Phase 1 ships device mode tonight. ~108 call sites would each need a rewrite.
 * - Shim is bounded — implements only what we use (audit captures real usage).
 * - control-plane mode keeps using @supabase/supabase-js verbatim, no divergence.
 *
 * Notable adaptations (Postgres → SQLite):
 * - JSON columns (settings/metadata/payload/delivery/items/tags/working_hours) are
 *   auto-stringified on write and parsed on read. List in JSON_COLUMNS below.
 * - UUID is generated via crypto.randomUUID() on insert when `id` is missing.
 * - Booleans collapse to 0/1 on write, expand to true/false on read for any column
 *   whose name appears in BOOL_COLUMNS or starts with `is_`/`has_`/`enabled`.
 * - .ilike falls back to LOWER(col) LIKE LOWER(pattern) — SQLite has no native ILIKE.
 * - .textSearch maps to LIKE '%term%' (good enough for the one call site).
 * - .rpc() implements the 4 known RPCs in TS: match_documents (vector cosine via
 *   sqlite-vec), upsert_daily_usage (INSERT...ON CONFLICT), increment_counter,
 *   exec_sql (fall-through to db.exec).
 */
import Database from 'better-sqlite3';
import * as sqliteVec from 'sqlite-vec';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomUUID } from 'node:crypto';
import { createChildLogger } from '../utils/logger.js';
const log = createChildLogger('db:sqlite');
// Columns whose values are JSON in Postgres but stored as TEXT in SQLite.
// We stringify on write and JSON.parse on read.
const JSON_COLUMNS = new Set([
    'settings', 'metadata', 'working_hours', 'payload', 'delivery',
    'items', 'zatca_clearance_response', 'tool_args', 'tags',
    // knowledge graph columns
    'properties', 'source_files',
    // autonomy layer columns
    'conditions', 'alternatives',
]);
// Columns to coerce to/from boolean across the JS↔SQLite boundary.
const BOOL_COLUMNS = new Set(['enabled', 'thinking', 'success', 'overage_allowed']);
const isBoolColumn = (col) => BOOL_COLUMNS.has(col) || col.startsWith('is_') || col.startsWith('has_');
// ─── SQL injection guards ─────────────────────────────────────────────
// Identifier interpolation in this shim is unavoidable for ORDER BY columns,
// table names in `from(...)`, and the select-cols string. better-sqlite3
// doesn't support placeholders for those positions. The defense is strict
// allowlist validation: only [A-Za-z_][A-Za-z0-9_.]* is acceptable. Anything
// containing whitespace, quotes, parens, semicolons etc. is rejected.
const SAFE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?$/;
function assertSafeIdentifier(ident) {
    if (!SAFE_IDENT_RE.test(ident)) {
        throw new Error(`Unsafe SQL identifier rejected: ${JSON.stringify(ident)}`);
    }
    return ident;
}
const SAFE_SELECT_RE = /^[A-Za-z_*][A-Za-z0-9_,.\s*]*$/;
function assertSafeSelectCols(cols) {
    // Empty string handled by caller; '*' allowed; comma-separated identifiers allowed.
    if (cols === '*' || cols === '')
        return cols;
    // Strip whitespace; each comma-separated piece must be a safe identifier or *.
    const parts = cols.split(',').map(p => p.trim()).filter(Boolean);
    for (const p of parts) {
        if (p === '*')
            continue;
        if (!SAFE_IDENT_RE.test(p)) {
            throw new Error(`Unsafe SQL select column rejected: ${JSON.stringify(p)}`);
        }
    }
    if (!SAFE_SELECT_RE.test(cols)) {
        throw new Error(`Unsafe SQL select clause rejected: ${JSON.stringify(cols)}`);
    }
    return cols;
}
function assertSafeNumber(n) {
    if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0 || n > 1_000_000) {
        throw new Error(`Unsafe SQL number rejected: ${n}`);
    }
    return n;
}
// ─── Per-row encode/decode helpers ────────────────────────────────────
function encodeRow(row) {
    const out = {};
    for (const [k, v] of Object.entries(row)) {
        if (v === undefined)
            continue;
        if (v === null) {
            out[k] = null;
            continue;
        }
        if (JSON_COLUMNS.has(k) && typeof v === 'object') {
            out[k] = JSON.stringify(v);
        }
        else if (isBoolColumn(k) && typeof v === 'boolean') {
            out[k] = v ? 1 : 0;
        }
        else if (v instanceof Date) {
            out[k] = v.toISOString();
        }
        else {
            out[k] = v;
        }
    }
    return out;
}
function decodeRow(row) {
    if (!row)
        return null;
    const out = {};
    for (const [k, v] of Object.entries(row)) {
        if (JSON_COLUMNS.has(k) && typeof v === 'string') {
            try {
                out[k] = JSON.parse(v);
            }
            catch {
                out[k] = v;
            }
        }
        else if (isBoolColumn(k) && (v === 0 || v === 1)) {
            out[k] = v === 1;
        }
        else {
            out[k] = v;
        }
    }
    return out;
}
// ─── Query builder ────────────────────────────────────────────────────
class QueryBuilder {
    db;
    table;
    filters = [];
    orderBy = [];
    limitN;
    rangeFromN;
    rangeToN;
    selectCols = '*';
    op = { kind: 'select' };
    singleMode = null;
    constructor(db, table) {
        this.db = db;
        this.table = table;
        // Defense-in-depth: validate table name at construction time so any
        // injection attempt fails fast at the from(...) call site instead of
        // surfacing in a query later.
        assertSafeIdentifier(table);
    }
    // ─── Operations ────────────────────────────────────────────────────
    select(cols = '*', opts = {}) {
        this.selectCols = assertSafeSelectCols(cols || '*');
        if (this.op.kind === 'select') {
            this.op = { kind: 'select', head: opts.head, count: opts.count };
        }
        else if (this.op.kind === 'insert' || this.op.kind === 'update' || this.op.kind === 'upsert' || this.op.kind === 'delete') {
            // .select() chained after a mutation = "RETURNING ..."
            this.op.returning = true;
        }
        return this;
    }
    insert(rows) {
        const arr = Array.isArray(rows) ? rows : [rows];
        this.op = { kind: 'insert', rows: arr.map(addId), returning: false };
        return this;
    }
    update(row) {
        this.op = { kind: 'update', row, returning: false };
        return this;
    }
    upsert(rows, opts = {}) {
        const arr = Array.isArray(rows) ? rows : [rows];
        this.op = { kind: 'upsert', rows: arr.map(addId), onConflict: opts.onConflict, returning: false };
        return this;
    }
    delete() {
        this.op = { kind: 'delete', returning: false };
        return this;
    }
    // ─── Filters ───────────────────────────────────────────────────────
    eq(col, val) { this.filters.push({ kind: 'eq', col, val }); return this; }
    neq(col, val) { this.filters.push({ kind: 'neq', col, val }); return this; }
    gt(col, val) { this.filters.push({ kind: 'gt', col, val }); return this; }
    gte(col, val) { this.filters.push({ kind: 'gte', col, val }); return this; }
    lt(col, val) { this.filters.push({ kind: 'lt', col, val }); return this; }
    lte(col, val) { this.filters.push({ kind: 'lte', col, val }); return this; }
    like(col, pattern) { this.filters.push({ kind: 'like', col, val: pattern }); return this; }
    ilike(col, pattern) { this.filters.push({ kind: 'ilike', col, val: pattern }); return this; }
    in(col, vals) { this.filters.push({ kind: 'in', col, vals }); return this; }
    is(col, val) { this.filters.push({ kind: 'is', col, val }); return this; }
    not(col, op, val) { this.filters.push({ kind: 'not', col, op, val }); return this; }
    textSearch(col, query) { this.filters.push({ kind: 'textSearch', col, query }); return this; }
    // ─── Ordering / paging ─────────────────────────────────────────────
    order(col, opts = {}) {
        this.orderBy.push({ col, ascending: opts.ascending !== false });
        return this;
    }
    limit(n) { this.limitN = n; return this; }
    range(from, to) { this.rangeFromN = from; this.rangeToN = to; return this; }
    // ─── Result shape ──────────────────────────────────────────────────
    single() { this.singleMode = 'single'; return this; }
    maybeSingle() { this.singleMode = 'maybeSingle'; return this; }
    // ─── Thenable ──────────────────────────────────────────────────────
    then(onfulfilled, onrejected) {
        return Promise.resolve()
            .then(() => this.execute())
            .then(onfulfilled ?? (v => v), onrejected);
    }
    // ─── Execution ─────────────────────────────────────────────────────
    execute() {
        try {
            switch (this.op.kind) {
                case 'select': return this.runSelect();
                case 'insert': return this.runInsert();
                case 'update': return this.runUpdate();
                case 'upsert': return this.runUpsert();
                case 'delete': return this.runDelete();
            }
        }
        catch (err) {
            const e = err;
            log.warn({ table: this.table, op: this.op.kind, error: e.message }, 'sqlite query failed');
            return { data: null, error: { message: e.message, code: e.code } };
        }
    }
    // ─── Internals: SQL builders ───────────────────────────────────────
    buildWhere() {
        if (this.filters.length === 0)
            return { clause: '', params: [] };
        const parts = [];
        const params = [];
        const push = (v) => { params.push(coerceBindValue(v)); };
        for (const f of this.filters) {
            switch (f.kind) {
                case 'eq':
                    parts.push(`${f.col} = ?`);
                    push(f.val);
                    break;
                case 'neq':
                    parts.push(`${f.col} <> ?`);
                    push(f.val);
                    break;
                case 'gt':
                    parts.push(`${f.col} > ?`);
                    push(f.val);
                    break;
                case 'gte':
                    parts.push(`${f.col} >= ?`);
                    push(f.val);
                    break;
                case 'lt':
                    parts.push(`${f.col} < ?`);
                    push(f.val);
                    break;
                case 'lte':
                    parts.push(`${f.col} <= ?`);
                    push(f.val);
                    break;
                case 'like':
                    parts.push(`${f.col} LIKE ?`);
                    push(f.val);
                    break;
                case 'ilike':
                    parts.push(`LOWER(${f.col}) LIKE LOWER(?)`);
                    push(f.val);
                    break;
                case 'in': {
                    if (f.vals.length === 0) {
                        parts.push('0');
                        break;
                    }
                    parts.push(`${f.col} IN (${f.vals.map(() => '?').join(',')})`);
                    for (const v of f.vals)
                        push(v);
                    break;
                }
                case 'is':
                    parts.push(f.val === null ? `${f.col} IS NULL` : `${f.col} IS ?`);
                    if (f.val !== null)
                        params.push(f.val ? 1 : 0);
                    break;
                case 'not':
                    parts.push(`NOT (${f.col} ${f.op} ?)`);
                    push(f.val);
                    break;
                case 'textSearch':
                    parts.push(`${f.col} LIKE ?`);
                    params.push(`%${f.query}%`);
                    break;
            }
        }
        return { clause: ' WHERE ' + parts.join(' AND '), params };
    }
    buildOrderLimit() {
        let sql = '';
        if (this.orderBy.length > 0) {
            sql += ' ORDER BY ' + this.orderBy.map(o => `${assertSafeIdentifier(o.col)} ${o.ascending ? 'ASC' : 'DESC'}`).join(', ');
        }
        if (this.limitN !== undefined)
            sql += ` LIMIT ${assertSafeNumber(this.limitN)}`;
        if (this.rangeFromN !== undefined && this.rangeToN !== undefined) {
            const len = this.rangeToN - this.rangeFromN + 1;
            sql += ` LIMIT ${assertSafeNumber(len)} OFFSET ${assertSafeNumber(this.rangeFromN)}`;
        }
        return sql;
    }
    // ─── Run helpers ───────────────────────────────────────────────────
    runSelect() {
        const op = this.op;
        if (op.count === 'exact') {
            const where = this.buildWhere();
            const sql = `SELECT count(*) AS n FROM ${this.table}${where.clause}`;
            const r = this.db.prepare(sql).get(...where.params);
            if (op.head)
                return { data: null, error: null, count: r.n };
            // count + data both
            const dataSql = `SELECT ${this.selectCols} FROM ${this.table}${where.clause}${this.buildOrderLimit()}`;
            const rows = this.db.prepare(dataSql).all(...where.params).map(decodeRow);
            return { data: rows, error: null, count: r.n };
        }
        const where = this.buildWhere();
        const sql = `SELECT ${this.selectCols} FROM ${this.table}${where.clause}${this.buildOrderLimit()}`;
        const rows = this.db.prepare(sql).all(...where.params);
        if (this.singleMode === 'single') {
            if (rows.length === 0)
                return { data: null, error: { message: 'No rows', code: 'PGRST116' } };
            if (rows.length > 1)
                return { data: null, error: { message: `Expected 1 row, got ${rows.length}`, code: 'PGRST117' } };
            return { data: decodeRow(rows[0]), error: null };
        }
        if (this.singleMode === 'maybeSingle') {
            if (rows.length === 0)
                return { data: null, error: null };
            if (rows.length > 1)
                return { data: null, error: { message: `Expected 0 or 1 rows, got ${rows.length}`, code: 'PGRST117' } };
            return { data: decodeRow(rows[0]), error: null };
        }
        return { data: rows.map(decodeRow), error: null };
    }
    runInsert() {
        const op = this.op;
        const inserted = [];
        const insertOne = (raw) => {
            const row = encodeRow(raw);
            const cols = Object.keys(row);
            const placeholders = cols.map(() => '?').join(',');
            const sql = `INSERT INTO ${this.table} (${cols.join(',')}) VALUES (${placeholders})`;
            this.db.prepare(sql).run(...cols.map(c => row[c]));
            if (op.returning && row.id !== undefined) {
                const back = this.db.prepare(`SELECT ${this.selectCols} FROM ${this.table} WHERE id = ?`).get(row.id);
                if (back)
                    inserted.push(decodeRow(back));
            }
        };
        for (const r of op.rows)
            insertOne(r);
        if (op.returning) {
            if (this.singleMode)
                return { data: inserted[0] ?? null, error: null };
            return { data: inserted, error: null };
        }
        return { data: null, error: null };
    }
    runUpdate() {
        const op = this.op;
        const row = encodeRow(op.row);
        const cols = Object.keys(row);
        if (cols.length === 0)
            return { data: null, error: { message: 'UPDATE with no columns' } };
        const setClause = cols.map(c => `${c} = ?`).join(', ');
        const where = this.buildWhere();
        const sql = `UPDATE ${this.table} SET ${setClause}${where.clause}`;
        this.db.prepare(sql).run(...cols.map(c => row[c]), ...where.params);
        if (op.returning) {
            const back = this.db.prepare(`SELECT ${this.selectCols} FROM ${this.table}${where.clause}`).all(...where.params);
            const decoded = back.map(decodeRow);
            if (this.singleMode)
                return { data: decoded[0] ?? null, error: null };
            return { data: decoded, error: null };
        }
        return { data: null, error: null };
    }
    runUpsert() {
        const op = this.op;
        const conflictCols = (op.onConflict ?? 'id').split(',').map(s => s.trim());
        const inserted = [];
        for (const raw of op.rows) {
            const row = encodeRow(raw);
            const cols = Object.keys(row);
            const placeholders = cols.map(() => '?').join(',');
            const updateCols = cols.filter(c => !conflictCols.includes(c));
            const updateClause = updateCols.length > 0
                ? `DO UPDATE SET ${updateCols.map(c => `${c} = excluded.${c}`).join(', ')}`
                : 'DO NOTHING';
            const sql = `INSERT INTO ${this.table} (${cols.join(',')}) VALUES (${placeholders}) ON CONFLICT(${conflictCols.join(',')}) ${updateClause}`;
            this.db.prepare(sql).run(...cols.map(c => row[c]));
            if (op.returning && row.id !== undefined) {
                const back = this.db.prepare(`SELECT ${this.selectCols} FROM ${this.table} WHERE id = ?`).get(row.id);
                if (back)
                    inserted.push(decodeRow(back));
            }
        }
        if (op.returning) {
            if (this.singleMode)
                return { data: inserted[0] ?? null, error: null };
            return { data: inserted, error: null };
        }
        return { data: null, error: null };
    }
    runDelete() {
        const where = this.buildWhere();
        const sql = `DELETE FROM ${this.table}${where.clause}`;
        this.db.prepare(sql).run(...where.params);
        return { data: null, error: null };
    }
}
// ─── Add UUID id when missing ─────────────────────────────────────────
function addId(row) {
    if (row.id !== undefined && row.id !== null)
        return row;
    return { id: randomUUID(), ...row };
}
/**
 * Coerce a JS value into something better-sqlite3 can bind. better-sqlite3
 * accepts only number | string | bigint | Buffer | null. Booleans become 0/1,
 * Dates become ISO strings, undefined becomes null.
 */
function coerceBindValue(v) {
    if (v === undefined)
        return null;
    if (typeof v === 'boolean')
        return v ? 1 : 0;
    if (v instanceof Date)
        return v.toISOString();
    return v;
}
// ─── Auth shim (device mode has no Supabase Auth) ─────────────────────
class DeviceAuth {
    /**
     * Device mode is single-tenant. There's no JWT to verify — the device's
     * gateway is the privileged caller. Return a stable synthetic user so any
     * code path that expects an authenticated user keeps working.
     */
    async getUser(_token) {
        // Stable v4-format UUID for the synthetic device user. Single-tenant device
        // mode means there's only ever one logical user; this id matches the tenant's
        // owner_id by convention so the existing tenantMiddleware check passes.
        return { data: { user: { id: '00000000-0000-4000-8000-00000000d317', email: null, phone: null } }, error: null };
    }
}
// ─── Storage shim (no-op for now; logged so we notice if real usage hits it) ──
class DeviceStorage {
    async createBucket(_name, _opts) {
        log.warn('storage.createBucket called in device mode — no-op');
        return { data: null, error: null };
    }
    from(_bucket) {
        return {
            upload: async () => { log.warn('storage.upload called in device mode — no-op'); return { data: null, error: null }; },
            download: async () => ({ data: null, error: { message: 'storage not implemented in device mode' } }),
        };
    }
}
// ─── The client itself ────────────────────────────────────────────────
export class SqliteClient {
    db;
    auth = new DeviceAuth();
    storage = new DeviceStorage();
    constructor(db) {
        this.db = db;
    }
    from(table) {
        return new QueryBuilder(this.db, table);
    }
    /**
     * RPC function bridge — translates the 4 known Supabase RPCs to TS.
     * Add new branches here as the gateway code introduces more RPC calls.
     */
    async rpc(name, params = {}) {
        try {
            switch (name) {
                case 'exec_sql': {
                    // Removed in v1.1: this RPC was a general-purpose escape hatch that
                    // executed arbitrary multi-statement SQL via better-sqlite3's exec().
                    // If a tool ever passed user-controlled SQL, it would mean total DB
                    // compromise (DROP TABLE, SELECT *, UPDATE owner_id, ...). Callers
                    // who need to run static DDL (e.g. workflows/recorder.ts schema
                    // bootstrap) should use db.prepare(...).run() with a hardcoded
                    // string at the call site, not route through this generic RPC.
                    return { data: null, error: { message: 'exec_sql RPC has been removed for security — call db.prepare directly with a hardcoded statement' } };
                }
                case 'upsert_daily_usage': {
                    const sql = `
            INSERT INTO usage (id, tenant_id, date, messages_count, tokens_input, tokens_output, cost_usd, customers_new, customers_active)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(tenant_id, date) DO UPDATE SET
              messages_count = usage.messages_count + excluded.messages_count,
              tokens_input = usage.tokens_input + excluded.tokens_input,
              tokens_output = usage.tokens_output + excluded.tokens_output,
              cost_usd = usage.cost_usd + excluded.cost_usd,
              customers_new = usage.customers_new + excluded.customers_new,
              customers_active = MAX(usage.customers_active, excluded.customers_active)
          `;
                    this.db.prepare(sql).run(randomUUID(), params.p_tenant_id ?? null, params.p_date ?? null, params.p_messages ?? 0, params.p_tokens_in ?? 0, params.p_tokens_out ?? 0, params.p_cost ?? 0, params.p_new_customers ?? 0, params.p_active_customers ?? 0);
                    return { data: null, error: null };
                }
                case 'increment_counter': {
                    // Not implemented yet — caller should fall back to manual UPDATE.
                    log.warn({ name, params }, 'rpc:increment_counter — not implemented in sqlite shim');
                    return { data: null, error: null };
                }
                case 'match_documents': {
                    // Vector cosine search via sqlite-vec virtual table.
                    const queryEmbedding = params.query_embedding;
                    const tenantId = params.match_tenant_id;
                    const threshold = params.match_threshold ?? 0.7;
                    const count = params.match_count ?? 5;
                    if (!queryEmbedding || !tenantId) {
                        return { data: null, error: { message: 'match_documents: missing query_embedding or match_tenant_id' } };
                    }
                    const sql = `
            SELECT
              dc.id, dc.document_id, dc.content, dc.metadata,
              1 - vec_distance_cosine(v.embedding, ?) AS similarity
            FROM vec_document_chunks v
            JOIN document_chunks dc ON dc.id = v.id
            WHERE dc.tenant_id = ?
              AND (1 - vec_distance_cosine(v.embedding, ?)) > ?
            ORDER BY v.embedding <=> ?
            LIMIT ?
          `;
                    const blob = Float32Array.from(queryEmbedding);
                    const rows = this.db.prepare(sql).all(blob, tenantId, blob, threshold, blob, count);
                    return { data: rows.map(decodeRow), error: null };
                }
                default:
                    log.warn({ name }, 'rpc: unknown function in sqlite shim');
                    return { data: null, error: { message: `rpc(${name}) not implemented in sqlite shim` } };
            }
        }
        catch (err) {
            const e = err;
            return { data: null, error: { message: e.message } };
        }
    }
}
// ─── Factory ──────────────────────────────────────────────────────────
let cachedClient = null;
export function getSqliteClient(dbPath) {
    if (cachedClient)
        return cachedClient;
    log.info({ dbPath }, 'opening sqlite database');
    const db = new Database(dbPath);
    db.pragma('journal_mode = WAL');
    db.pragma('foreign_keys = ON');
    db.pragma('synchronous = NORMAL');
    // Load sqlite-vec for vector similarity (best-effort — RAG features degrade if missing)
    try {
        sqliteVec.load(db);
        log.info('sqlite-vec extension loaded');
        db.exec(`
      CREATE VIRTUAL TABLE IF NOT EXISTS vec_document_chunks USING vec0(
        id TEXT PRIMARY KEY,
        embedding FLOAT[1536]
      )
    `);
    }
    catch (err) {
        log.warn({ error: err.message }, 'sqlite-vec failed to load — vector search will not work');
    }
    // Apply schema (idempotent — uses CREATE IF NOT EXISTS everywhere)
    const here = dirname(fileURLToPath(import.meta.url));
    const schemaPath = join(here, '..', '..', 'supabase', 'sqlite-schema.sql');
    const schemaSql = readFileSync(schemaPath, 'utf8');
    db.exec(schemaSql);
    log.info('sqlite schema applied');
    // v2 migration: existing 1.0.x installs have an old kg_entities table
    // with `CHECK (kind IN ('conversation','document'))` baked in. SQLite
    // can't ALTER that constraint, so we detect + recreate the table when
    // it doesn't accept the new kinds. Idempotent, runs once per DB.
    migrateKgEntitiesKindConstraint(db);
    // v2 migration: pre-hash-chain installs of journal_entries lack the
    // `prev_hash` and `entry_hash` columns. Without them every tool call
    // logs "no such column: entry_hash" and the journal record fails.
    // ALTER TABLE ADD COLUMN is safe — copies default NULL to existing rows.
    migrateJournalEntriesHashChain(db);
    // v2.5.2 migration: customer_memories gained `namespace` and `metadata`
    // columns for the PROFILE.md self-learning feature. Existing tenant
    // DBs from v2.4.x need the columns added. ALTER TABLE ADD COLUMN is
    // idempotent — we check pragma_table_info first.
    migrateCustomerMemoriesProfile(db);
    cachedClient = new SqliteClient(db);
    return cachedClient;
}
/**
 * (v2.5.2) Add namespace + metadata columns to existing customer_memories
 * tables. New v2.5.2+ installs get the columns from the schema; pre-v2.5.0
 * installs need them added. Idempotent.
 */
function migrateCustomerMemoriesProfile(db) {
    try {
        const cols = db.prepare(`PRAGMA table_info(customer_memories)`).all();
        const have = new Set(cols.map((c) => c.name));
        const adds = [];
        if (!have.has('namespace'))
            adds.push(`ALTER TABLE customer_memories ADD COLUMN namespace TEXT NOT NULL DEFAULT ''`);
        if (!have.has('metadata'))
            adds.push(`ALTER TABLE customer_memories ADD COLUMN metadata TEXT NOT NULL DEFAULT '{}'`);
        for (const sql of adds)
            db.exec(sql);
        if (adds.length > 0)
            log.info({ added: adds.length }, 'customer_memories migrated for profile (v2.5.2)');
    }
    catch (err) {
        log.warn({ err: err.message }, 'customer_memories migration skipped');
    }
}
/**
 * Replace the legacy kg_entities CHECK constraint with the v2 expanded one.
 * v1 only allowed `kind IN ('conversation', 'document')`, which silently
 * blocked the KG-seeder's `skill / cron / policy / tool / research` writes.
 *
 * SQLite has no ALTER TABLE ... DROP/ADD CHECK, so we:
 *   1. Probe with a test insert/delete using the new kind 'skill'.
 *   2. If the probe fails with a CHECK error, recreate the table with
 *      the v2 schema, copy rows, drop indexes, recreate.
 */
function migrateKgEntitiesKindConstraint(db) {
    // v3 target schema — the journal-mirror needs these kinds. The probe
    // tests with 'goal' (newest kind) so we catch BOTH legacy v1 installs
    // and v2 installs that lack the journal-mirror kinds.
    const TARGET_KINDS = ['conversation', 'document', 'skill', 'cron', 'policy', 'tool', 'research', 'action', 'lesson', 'decision', 'goal'];
    const TARGET_KINDS_SQL = TARGET_KINDS.map(k => `'${k}'`).join(', ');
    try {
        const probe = db.transaction(() => {
            db.prepare(`
        INSERT INTO kg_entities (id, tenant_id, kind, type, name, name_normalized, properties, source_files, episode_count, created_at, updated_at)
        VALUES ('__migration_probe__', '00000000-0000-0000-0000-000000000000', 'goal', 'Goal', '__probe__', '__probe__', '{}', '[]', 0, datetime('now'), datetime('now'))
      `).run();
            throw new Error('__rollback__');
        });
        try {
            probe();
        }
        catch (e) {
            if (!e.message.includes('__rollback__')) {
                if (/CHECK constraint failed.*kind/i.test(e.message)) {
                    log.info('Migrating kg_entities to v3 kind constraint (adding action/lesson/decision/goal)...');
                    db.exec(`
            BEGIN TRANSACTION;
            CREATE TABLE kg_entities_v3 (
              id TEXT PRIMARY KEY,
              tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
              kind TEXT NOT NULL CHECK (kind IN (${TARGET_KINDS_SQL})),
              customer_id TEXT,
              type TEXT NOT NULL,
              name TEXT NOT NULL,
              name_normalized TEXT NOT NULL,
              properties TEXT DEFAULT '{}',
              source_files TEXT DEFAULT '[]',
              episode_count INTEGER NOT NULL DEFAULT 0,
              created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
              updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
            );
            INSERT INTO kg_entities_v3 SELECT * FROM kg_entities;
            DROP TABLE kg_entities;
            ALTER TABLE kg_entities_v3 RENAME TO kg_entities;
            CREATE UNIQUE INDEX IF NOT EXISTS idx_kg_entities_unique
              ON kg_entities(tenant_id, kind, COALESCE(customer_id, ''), type, name_normalized);
            CREATE INDEX IF NOT EXISTS idx_kg_entities_tenant     ON kg_entities(tenant_id, kind);
            CREATE INDEX IF NOT EXISTS idx_kg_entities_customer   ON kg_entities(tenant_id, customer_id) WHERE customer_id IS NOT NULL;
            CREATE INDEX IF NOT EXISTS idx_kg_entities_name       ON kg_entities(tenant_id, name_normalized);
            COMMIT;
          `);
                    log.info('✅ kg_entities migration complete — v3 kinds (action/lesson/decision/goal) now allowed');
                }
            }
        }
    }
    catch (err) {
        log.warn({ err: err.message }, 'kg_entities migration probe failed (non-fatal)');
    }
}
/**
 * Add `prev_hash` and `entry_hash` columns to journal_entries on legacy
 * installs. Pre-hash-chain DBs have neither, which breaks every
 * journalToolCall write. ALTER TABLE ADD COLUMN is safe in SQLite —
 * existing rows get NULL, the hash chain treats NULL as "pre-chain"
 * and skips integrity-checking those rows.
 */
function migrateJournalEntriesHashChain(db) {
    try {
        const cols = db.prepare(`PRAGMA table_info(journal_entries)`).all();
        if (cols.length === 0)
            return; // table doesn't exist yet — schema apply will create it
        const have = new Set(cols.map(c => c.name));
        const adds = [];
        if (!have.has('prev_hash'))
            adds.push(`ALTER TABLE journal_entries ADD COLUMN prev_hash TEXT`);
        if (!have.has('entry_hash'))
            adds.push(`ALTER TABLE journal_entries ADD COLUMN entry_hash TEXT`);
        if (adds.length === 0)
            return;
        db.exec(`BEGIN TRANSACTION; ${adds.join('; ')}; COMMIT;`);
        log.info({ added: adds.length }, '✅ journal_entries hash-chain columns added (legacy install migration)');
    }
    catch (err) {
        log.warn({ err: err.message }, 'journal_entries hash-chain migration failed (non-fatal)');
    }
}
//# sourceMappingURL=sqlite-client.js.map