import * as fs from 'fs';
import * as path from 'path';
import { createRequire } from 'module';
import { createPhysicsTable } from './physics-state.js';
import { cleanupArchivedMirrors } from './raw-archive-mirror-cleanup.js';
import { PACKAGE_VERSION, compareSemver } from './version.js';
const require = createRequire(import.meta.url);
const { DatabaseSync } = require('node:sqlite');
const CURRENT_SCHEMA_VERSION = 30;
const MIGRATIONS = [
    {
        version: 1,
        up: (db) => {
            db.exec(`
        CREATE TABLE IF NOT EXISTS memories (
          id TEXT PRIMARY KEY,
          created TEXT NOT NULL,
          last_retrieved TEXT NOT NULL,
          retrieval_count INTEGER NOT NULL,
          strength REAL NOT NULL,
          half_life_days REAL NOT NULL,
          layer TEXT NOT NULL,
          tags_json TEXT NOT NULL,
          emotional_valence TEXT NOT NULL,
          schema_fit REAL NOT NULL,
          source TEXT NOT NULL,
          outcome_score REAL,
          conflicts_with_json TEXT NOT NULL,
          pinned INTEGER NOT NULL,
          confidence TEXT NOT NULL,
          content TEXT NOT NULL,
          updated_at TEXT NOT NULL DEFAULT (datetime('now'))
        );

        CREATE INDEX IF NOT EXISTS idx_memories_layer_created ON memories(layer, created);
        CREATE INDEX IF NOT EXISTS idx_memories_last_retrieved ON memories(last_retrieved);

        CREATE TABLE IF NOT EXISTS consolidation_runs (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          timestamp TEXT NOT NULL,
          decayed INTEGER NOT NULL,
          merged INTEGER NOT NULL,
          removed INTEGER NOT NULL
        );
      `);
        },
    },
    {
        version: 2,
        up: (db) => {
            db.exec(`
        CREATE TABLE IF NOT EXISTS task_snapshots (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          task TEXT NOT NULL,
          summary TEXT NOT NULL,
          next_step TEXT NOT NULL,
          status TEXT NOT NULL,
          source TEXT NOT NULL,
          created_at TEXT NOT NULL,
          updated_at TEXT NOT NULL
        );

        CREATE INDEX IF NOT EXISTS idx_task_snapshots_status_updated
        ON task_snapshots(status, updated_at DESC, id DESC);
      `);
        },
    },
    {
        version: 3,
        up: (db) => {
            db.exec(`
        CREATE TABLE IF NOT EXISTS memory_conflicts (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          memory_a_id TEXT NOT NULL,
          memory_b_id TEXT NOT NULL,
          reason TEXT NOT NULL,
          score REAL NOT NULL DEFAULT 0,
          status TEXT NOT NULL,
          detected_at TEXT NOT NULL,
          updated_at TEXT NOT NULL,
          UNIQUE(memory_a_id, memory_b_id)
        );

        CREATE INDEX IF NOT EXISTS idx_memory_conflicts_status_updated
        ON memory_conflicts(status, updated_at DESC, id DESC);
      `);
        },
    },
    {
        version: 4,
        up: (db) => {
            if (!tableHasColumn(db, 'task_snapshots', 'session_id')) {
                db.exec(`ALTER TABLE task_snapshots ADD COLUMN session_id TEXT`);
            }
            db.exec(`
        CREATE TABLE IF NOT EXISTS session_events (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          session_id TEXT NOT NULL,
          task TEXT,
          event_type TEXT NOT NULL,
          content TEXT NOT NULL,
          source TEXT NOT NULL,
          metadata_json TEXT NOT NULL,
          created_at TEXT NOT NULL
        );

        CREATE INDEX IF NOT EXISTS idx_session_events_session_created
        ON session_events(session_id, created_at DESC, id DESC);

        CREATE INDEX IF NOT EXISTS idx_session_events_task_created
        ON session_events(task, created_at DESC, id DESC);
      `);
        },
    },
    {
        version: 5,
        up: (db) => {
            db.exec(`
        CREATE TABLE IF NOT EXISTS session_handoffs (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          session_id TEXT NOT NULL,
          repo_root TEXT,
          task_id TEXT,
          summary TEXT NOT NULL,
          next_action TEXT,
          artifacts_json TEXT NOT NULL DEFAULT '[]',
          created_at TEXT NOT NULL
        );

        CREATE INDEX IF NOT EXISTS idx_session_handoffs_session
        ON session_handoffs(session_id, created_at DESC);
      `);
        },
    },
    {
        version: 6,
        up: (db) => {
            db.exec(`
        CREATE TABLE IF NOT EXISTS working_memory (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          scope TEXT NOT NULL,
          session_id TEXT,
          task_id TEXT,
          importance REAL NOT NULL DEFAULT 0,
          content TEXT NOT NULL,
          metadata_json TEXT NOT NULL DEFAULT '{}',
          created_at TEXT NOT NULL,
          updated_at TEXT NOT NULL
        );

        CREATE INDEX IF NOT EXISTS idx_working_memory_scope
        ON working_memory(scope, importance DESC, created_at DESC);

        CREATE INDEX IF NOT EXISTS idx_working_memory_session
        ON working_memory(session_id, created_at DESC);
      `);
        },
    },
    {
        version: 7,
        up: (db) => {
            if (!tableHasColumn(db, 'memories', 'outcome_positive')) {
                db.exec(`ALTER TABLE memories ADD COLUMN outcome_positive INTEGER NOT NULL DEFAULT 0`);
            }
            if (!tableHasColumn(db, 'memories', 'outcome_negative')) {
                db.exec(`ALTER TABLE memories ADD COLUMN outcome_negative INTEGER NOT NULL DEFAULT 0`);
            }
        },
    },
    {
        version: 8,
        up: (db) => {
            createPhysicsTable(db);
        },
    },
    {
        version: 9,
        up: (db) => {
            if (!tableHasColumn(db, 'memories', 'parents_json')) {
                db.exec(`ALTER TABLE memories ADD COLUMN parents_json TEXT NOT NULL DEFAULT '[]'`);
            }
            if (!tableHasColumn(db, 'memories', 'starred')) {
                db.exec(`ALTER TABLE memories ADD COLUMN starred INTEGER NOT NULL DEFAULT 0`);
            }
        },
    },
    {
        version: 10,
        up: (db) => {
            if (!tableHasColumn(db, 'memories', 'trace_outcome')) {
                db.exec(`ALTER TABLE memories ADD COLUMN trace_outcome TEXT`);
            }
            if (!tableHasColumn(db, 'memories', 'source_session_id')) {
                db.exec(`ALTER TABLE memories ADD COLUMN source_session_id TEXT`);
            }
            db.exec(`
        CREATE INDEX IF NOT EXISTS idx_memories_source_session_id
        ON memories(source_session_id) WHERE source_session_id IS NOT NULL
      `);
        },
    },
    {
        version: 11,
        up: (db) => {
            if (!tableHasColumn(db, 'memories', 'valid_from')) {
                db.exec(`ALTER TABLE memories ADD COLUMN valid_from TEXT`);
                db.exec(`UPDATE memories SET valid_from = created WHERE valid_from IS NULL`);
            }
            if (!tableHasColumn(db, 'memories', 'superseded_by')) {
                db.exec(`ALTER TABLE memories ADD COLUMN superseded_by TEXT`);
            }
            db.exec(`CREATE INDEX IF NOT EXISTS idx_memories_current ON memories(layer, created) WHERE superseded_by IS NULL`);
        },
    },
    {
        version: 12,
        up: (db) => {
            if (!tableHasColumn(db, 'memories', 'extracted_from')) {
                db.exec(`ALTER TABLE memories ADD COLUMN extracted_from TEXT`);
            }
            db.exec(`CREATE INDEX IF NOT EXISTS idx_memories_extracted_from ON memories(extracted_from) WHERE extracted_from IS NOT NULL`);
        },
    },
    {
        version: 13,
        up: (db) => {
            if (!tableHasColumn(db, 'memories', 'dag_level')) {
                db.exec(`ALTER TABLE memories ADD COLUMN dag_level INTEGER NOT NULL DEFAULT 0`);
            }
            if (!tableHasColumn(db, 'memories', 'dag_parent_id')) {
                db.exec(`ALTER TABLE memories ADD COLUMN dag_parent_id TEXT`);
            }
            db.exec(`UPDATE memories SET dag_level = 1 WHERE extracted_from IS NOT NULL AND dag_level = 0`);
            db.exec(`CREATE INDEX IF NOT EXISTS idx_memories_dag_parent ON memories(dag_parent_id) WHERE dag_parent_id IS NOT NULL`);
        },
    },
    {
        version: 14,
        up: (db) => {
            // A3 provenance envelope: kind, scope, owner, artifact_ref.
            // SQLite ALTER TABLE ADD COLUMN cannot add CHECK; CHECK enforcement lives
            // in INSERT/UPDATE triggers added later in this migration.
            if (!tableHasColumn(db, 'memories', 'kind')) {
                db.exec(`ALTER TABLE memories ADD COLUMN kind TEXT DEFAULT 'distilled'`);
            }
            if (!tableHasColumn(db, 'memories', 'scope')) {
                db.exec(`ALTER TABLE memories ADD COLUMN scope TEXT`);
                db.exec(`CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(scope) WHERE scope IS NOT NULL`);
            }
            if (!tableHasColumn(db, 'memories', 'owner')) {
                db.exec(`ALTER TABLE memories ADD COLUMN owner TEXT`);
            }
            if (!tableHasColumn(db, 'memories', 'artifact_ref')) {
                db.exec(`ALTER TABLE memories ADD COLUMN artifact_ref TEXT`);
            }
            // Backfill kind for any rows where it's NULL (pre-migration data).
            db.exec(`UPDATE memories SET kind = 'superseded' WHERE kind IS NULL AND superseded_by IS NOT NULL`);
            db.exec(`UPDATE memories SET kind = 'distilled' WHERE kind IS NULL`);
            // raw_archive: legitimate path for kind='raw' removal (used by archiveRawMemory).
            db.exec(`
        CREATE TABLE IF NOT EXISTS raw_archive (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          memory_id TEXT NOT NULL,
          archived_at TEXT NOT NULL,
          reason TEXT NOT NULL,
          archived_by TEXT,
          payload_json TEXT NOT NULL
        )
      `);
            db.exec(`CREATE INDEX IF NOT EXISTS idx_raw_archive_memory_id ON raw_archive(memory_id)`);
            // Append-only invariant: kind='raw' rows cannot be deleted directly.
            // Use raw_archive flow: archive-then-update-then-delete (see src/raw-archive.ts).
            db.exec(`
        CREATE TRIGGER IF NOT EXISTS trg_memories_raw_append_only
        BEFORE DELETE ON memories
        WHEN OLD.kind = 'raw'
        BEGIN
          SELECT RAISE(ABORT, 'raw is append-only');
        END
      `);
            // CHECK substitute: ALTER TABLE cannot add CHECK, so enforce kind allowed-set
            // via INSERT/UPDATE triggers.
            db.exec(`
        CREATE TRIGGER IF NOT EXISTS trg_memories_kind_check_insert
        BEFORE INSERT ON memories
        WHEN NEW.kind IS NOT NULL AND NEW.kind NOT IN ('raw','distilled','superseded','archived')
        BEGIN
          SELECT RAISE(ABORT, 'invalid kind: must be raw|distilled|superseded|archived');
        END
      `);
            db.exec(`
        CREATE TRIGGER IF NOT EXISTS trg_memories_kind_check_update
        BEFORE UPDATE ON memories
        WHEN NEW.kind IS NOT NULL AND NEW.kind NOT IN ('raw','distilled','superseded','archived')
        BEGIN
          SELECT RAISE(ABORT, 'invalid kind: must be raw|distilled|superseded|archived');
        END
      `);
        },
    },
    {
        version: 15,
        up: (db) => {
            // A3 hardening (post-review): close the NULL-kind bypass and add raw_archive
            // dedup safety. Both findings landed in /review on commits 41b1f4d..6456e7d.
            //
            // (1) Original v14 triggers used `WHEN NEW.kind IS NOT NULL AND NEW.kind NOT IN (...)`.
            //     A direct INSERT/UPDATE setting kind=NULL bypassed the CHECK substitute. Replace
            //     with `WHEN NEW.kind IS NULL OR NEW.kind NOT IN (...)` so NULL is rejected too.
            // (2) Add UNIQUE(memory_id, archived_at) to raw_archive so re-archiving the same id
            //     in the same instant cannot produce ambiguous audit rows. Per-id history is still
            //     allowed (different timestamps).
            db.exec(`DROP TRIGGER IF EXISTS trg_memories_kind_check_insert`);
            db.exec(`DROP TRIGGER IF EXISTS trg_memories_kind_check_update`);
            db.exec(`
        CREATE TRIGGER trg_memories_kind_check_insert
        BEFORE INSERT ON memories
        WHEN NEW.kind IS NULL OR NEW.kind NOT IN ('raw','distilled','superseded','archived')
        BEGIN
          SELECT RAISE(ABORT, 'invalid kind: must be raw|distilled|superseded|archived (not null)');
        END
      `);
            db.exec(`
        CREATE TRIGGER trg_memories_kind_check_update
        BEFORE UPDATE ON memories
        WHEN NEW.kind IS NULL OR NEW.kind NOT IN ('raw','distilled','superseded','archived')
        BEGIN
          SELECT RAISE(ABORT, 'invalid kind: must be raw|distilled|superseded|archived (not null)');
        END
      `);
            // Defensive: any rows that somehow have NULL kind get fixed (shouldn't exist post-v14
            // backfill, but cheap insurance).
            db.exec(`UPDATE memories SET kind = 'distilled' WHERE kind IS NULL`);
            // raw_archive uniqueness. SQLite cannot ADD CONSTRAINT, but a partial unique index
            // on (memory_id, archived_at) is equivalent for INSERT-time enforcement.
            db.exec(`
        CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_archive_id_at
        ON raw_archive(memory_id, archived_at)
      `);
        },
    },
    {
        version: 16,
        up: (db) => {
            // A5 stub auth: add tenant_id to all data tables. Single-tenant per deployment;
            // multi-tenant enforcement deferred to v2 (full A5). The columns are needed now
            // so future B-track tables don't have to backfill.
            if (!tableHasColumn(db, 'memories', 'tenant_id')) {
                db.exec(`ALTER TABLE memories ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default'`);
            }
            if (!tableHasColumn(db, 'working_memory', 'tenant_id')) {
                db.exec(`ALTER TABLE working_memory ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default'`);
            }
            if (!tableHasColumn(db, 'consolidation_runs', 'tenant_id')) {
                db.exec(`ALTER TABLE consolidation_runs ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default'`);
            }
            if (!tableHasColumn(db, 'task_snapshots', 'tenant_id')) {
                db.exec(`ALTER TABLE task_snapshots ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default'`);
            }
            if (!tableHasColumn(db, 'memory_conflicts', 'tenant_id')) {
                db.exec(`ALTER TABLE memory_conflicts ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default'`);
            }
            // Composite indexes for recall hot paths. Leading column is tenant_id so
            // single-tenant lookups are O(log n).
            db.exec(`CREATE INDEX IF NOT EXISTS idx_memories_tenant_created ON memories(tenant_id, created)`);
            db.exec(`CREATE INDEX IF NOT EXISTS idx_working_memory_tenant ON working_memory(tenant_id, importance DESC, created_at DESC)`);
            db.exec(`CREATE INDEX IF NOT EXISTS idx_consolidation_runs_tenant_ts ON consolidation_runs(tenant_id, timestamp DESC)`);
            db.exec(`CREATE INDEX IF NOT EXISTS idx_task_snapshots_tenant_status ON task_snapshots(tenant_id, status, updated_at DESC)`);
            // A5 stub auth: api_keys (scrypt-hashed; plaintext returned to caller exactly once)
            // and audit_log (append-only mutation trail). Both carry tenant_id from day 1 so
            // future multi-tenant enforcement is a config flip, not a re-migration.
            db.exec(`
        CREATE TABLE IF NOT EXISTS api_keys (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          key_id TEXT UNIQUE NOT NULL,
          key_hash TEXT NOT NULL,
          tenant_id TEXT NOT NULL DEFAULT 'default',
          label TEXT,
          created_at TEXT NOT NULL,
          revoked_at TEXT
        )
      `);
            db.exec(`
        CREATE INDEX IF NOT EXISTS idx_api_keys_tenant_active
        ON api_keys(tenant_id) WHERE revoked_at IS NULL
      `);
            db.exec(`
        CREATE TABLE IF NOT EXISTS audit_log (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          ts TEXT NOT NULL,
          tenant_id TEXT NOT NULL DEFAULT 'default',
          actor TEXT NOT NULL,
          op TEXT NOT NULL,
          target_id TEXT,
          metadata_json TEXT NOT NULL DEFAULT '{}'
        )
      `);
            db.exec(`CREATE INDEX IF NOT EXISTS idx_audit_log_tenant_ts ON audit_log(tenant_id, ts DESC)`);
        },
    },
    {
        version: 17,
        up: (db) => {
            // E1.3 Slack ingestion: idempotency log, per-channel backfill cursors, DLQ.
            // See docs/plans/2026-04-29-e1.3-slack-ingestion.md.
            db.exec(`
        CREATE TABLE IF NOT EXISTS slack_event_log (
          event_id TEXT PRIMARY KEY,
          ingested_at TEXT NOT NULL,
          memory_id TEXT
        )
      `);
            db.exec(`CREATE INDEX IF NOT EXISTS idx_slack_event_log_memory ON slack_event_log(memory_id) WHERE memory_id IS NOT NULL`);
            db.exec(`
        CREATE TABLE IF NOT EXISTS slack_cursors (
          tenant_id TEXT NOT NULL,
          channel_id TEXT NOT NULL,
          latest_ts TEXT NOT NULL,
          updated_at TEXT NOT NULL,
          PRIMARY KEY (tenant_id, channel_id)
        )
      `);
            db.exec(`
        CREATE TABLE IF NOT EXISTS slack_dlq (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          tenant_id TEXT NOT NULL,
          raw_payload TEXT NOT NULL,
          error TEXT NOT NULL,
          received_at TEXT NOT NULL,
          retried_at TEXT
        )
      `);
            db.exec(`CREATE INDEX IF NOT EXISTS idx_slack_dlq_tenant_received ON slack_dlq(tenant_id, received_at)`);
            // Multi-tenant routing seam (review patch #6). Empty by default — single-
            // tenant deployments resolve via HIPPO_TENANT fallback. Multi-workspace
            // deployments populate this table to map team_id → tenant_id.
            db.exec(`
        CREATE TABLE IF NOT EXISTS slack_workspaces (
          team_id TEXT PRIMARY KEY,
          tenant_id TEXT NOT NULL,
          added_at TEXT NOT NULL
        )
      `);
        },
    },
    {
        version: 18,
        up: (db) => {
            db.exec(`
        CREATE TABLE IF NOT EXISTS goal_stack (
          id TEXT PRIMARY KEY,
          session_id TEXT NOT NULL,
          tenant_id TEXT NOT NULL DEFAULT 'default',
          goal_name TEXT NOT NULL,
          level INTEGER NOT NULL DEFAULT 0
            CHECK (level BETWEEN 0 AND 2),
          parent_goal_id TEXT REFERENCES goal_stack(id) ON DELETE SET NULL,
          status TEXT NOT NULL CHECK (status IN ('active','suspended','completed')),
          success_condition TEXT,
          retrieval_policy_id TEXT,
          created_at TEXT NOT NULL,
          completed_at TEXT,
          outcome_score REAL
            CHECK (outcome_score IS NULL OR (outcome_score >= 0 AND outcome_score <= 1))
        );

        CREATE INDEX IF NOT EXISTS idx_goal_stack_tenant_session_status
          ON goal_stack(tenant_id, session_id, status, created_at);

        CREATE TABLE IF NOT EXISTS retrieval_policy (
          id TEXT PRIMARY KEY,
          goal_id TEXT NOT NULL REFERENCES goal_stack(id) ON DELETE CASCADE,
          policy_type TEXT NOT NULL CHECK (policy_type IN
            ('schema-fit-biased','error-prioritized','recency-first','hybrid')),
          weight_schema_fit REAL NOT NULL DEFAULT 1.0
            CHECK (weight_schema_fit >= 0 AND weight_schema_fit <= 100),
          weight_recency REAL NOT NULL DEFAULT 1.0
            CHECK (weight_recency >= 0 AND weight_recency <= 100),
          weight_outcome REAL NOT NULL DEFAULT 1.0
            CHECK (weight_outcome >= 0 AND weight_outcome <= 100),
          error_priority REAL NOT NULL DEFAULT 1.0
            CHECK (error_priority >= 0 AND error_priority <= 100)
        );

        CREATE INDEX IF NOT EXISTS idx_retrieval_policy_goal
          ON retrieval_policy(goal_id);

        CREATE TABLE IF NOT EXISTS goal_recall_log (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          goal_id TEXT NOT NULL REFERENCES goal_stack(id) ON DELETE CASCADE,
          memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
          tenant_id TEXT NOT NULL DEFAULT 'default',
          session_id TEXT NOT NULL,
          recalled_at TEXT NOT NULL,
          score REAL NOT NULL
        );

        CREATE INDEX IF NOT EXISTS idx_goal_recall_log_goal
          ON goal_recall_log(goal_id);
        CREATE UNIQUE INDEX IF NOT EXISTS uniq_goal_recall_log_memory_goal
          ON goal_recall_log(memory_id, goal_id);
      `);
        },
    },
    {
        version: 19,
        up: (db) => {
            // v0.39 commit 3 (Slack hardening): widen slack_dlq with bucketing,
            // retry tracking, and the signature/timestamp pair that lets `hippo
            // slack dlq replay` re-verify before re-running ingest. ALTER ADD
            // COLUMN with DEFAULT is non-destructive — legacy rows take the
            // default values. Idempotent via tableHasColumn().
            if (!tableHasColumn(db, 'slack_dlq', 'team_id')) {
                db.exec(`ALTER TABLE slack_dlq ADD COLUMN team_id TEXT`);
            }
            if (!tableHasColumn(db, 'slack_dlq', 'bucket')) {
                db.exec(`ALTER TABLE slack_dlq ADD COLUMN bucket TEXT NOT NULL DEFAULT 'parse_error'`);
                // SQLite ALTER TABLE cannot add CHECK; bucket value enforcement is app-level.
            }
            if (!tableHasColumn(db, 'slack_dlq', 'retry_count')) {
                db.exec(`ALTER TABLE slack_dlq ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0`);
            }
            if (!tableHasColumn(db, 'slack_dlq', 'signature')) {
                db.exec(`ALTER TABLE slack_dlq ADD COLUMN signature TEXT`);
            }
            if (!tableHasColumn(db, 'slack_dlq', 'slack_timestamp')) {
                db.exec(`ALTER TABLE slack_dlq ADD COLUMN slack_timestamp TEXT`);
            }
        },
    },
    {
        version: 20,
        up: (db) => {
            // v0.39 commit 4 (GDPR Path A backfill): redact every existing
            // raw_archive.payload_json so historical archives match the new
            // metadata-only contract from src/raw-archive.ts. Read each row, parse
            // the existing JSON to extract tenant_id and kind (best effort), then
            // UPDATE with the redacted shape. Rows with unparseable legacy JSON get
            // redacted with tenant_id='unknown', kind='unknown'. The audit_log
            // remains the compliance record.
            const rows = db
                .prepare(`SELECT id, archived_at, reason, payload_json FROM raw_archive`)
                .all();
            const update = db.prepare(`UPDATE raw_archive SET payload_json = ? WHERE id = ?`);
            for (const row of rows) {
                let tenant = 'unknown';
                let kind = 'unknown';
                try {
                    const parsed = JSON.parse(row.payload_json);
                    tenant = parsed.tenant_id ?? 'unknown';
                    kind = parsed.kind ?? 'unknown';
                }
                catch {
                    // Unparseable legacy payload — redact with unknowns.
                }
                const redacted = JSON.stringify({
                    redacted: true,
                    archived_at: row.archived_at,
                    tenant_id: tenant,
                    kind,
                    reason: row.reason,
                    migration: 'v20_redact',
                });
                update.run(redacted, row.id);
            }
        },
    },
    {
        version: 21,
        up: (db) => {
            // v0.39 codex round 3: per-row mirror cleanup tracking. Replaces the
            // global gdpr_v20_mirror_cleanup meta gate (which made the reaper
            // one-shot and silently swallowed failed unlinks). With this column the
            // reaper processes only rows WHERE mirror_cleaned_at IS NULL, sets the
            // timestamp on success, and leaves it NULL on any unlink failure so the
            // next openHippoDb retries automatically.
            if (!tableHasColumn(db, 'raw_archive', 'mirror_cleaned_at')) {
                db.exec(`ALTER TABLE raw_archive ADD COLUMN mirror_cleaned_at TEXT`);
            }
        },
    },
    {
        version: 22,
        up: (db) => {
            // Tenant-isolation gap on continuity tables (codex review 2026-05-02).
            // session_events and session_handoffs predate the v16 tenant migration
            // and were never added to it, so the v0.40.0 provenance gate work
            // exposed a real cross-tenant leak when continuity primitives are used.
            // Adds tenant_id (NOT NULL DEFAULT 'default') with smart backfill from
            // task_snapshots.session_id when unambiguous, plus an optional scope
            // column so a private-channel-derived handoff can default-deny via the
            // same rule recall already enforces.
            //
            // Self-heal partial-init stores: re-run the v4/v5 CREATE TABLE IF NOT
            // EXISTS bodies before ALTERing. A silent skip would otherwise stamp
            // schema_version=22 on a DB missing the underlying tables, leaving
            // them permanently absent.
            db.exec(`
        CREATE TABLE IF NOT EXISTS session_events (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          session_id TEXT NOT NULL,
          task TEXT,
          event_type TEXT NOT NULL,
          content TEXT NOT NULL,
          source TEXT NOT NULL,
          metadata_json TEXT NOT NULL,
          created_at TEXT NOT NULL
        );
        CREATE TABLE IF NOT EXISTS session_handoffs (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          session_id TEXT NOT NULL,
          repo_root TEXT,
          task_id TEXT,
          summary TEXT NOT NULL,
          next_action TEXT,
          artifacts_json TEXT NOT NULL DEFAULT '[]',
          created_at TEXT NOT NULL
        );
      `);
            if (!tableHasColumn(db, 'session_events', 'tenant_id')) {
                db.exec(`ALTER TABLE session_events ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default'`);
            }
            if (!tableHasColumn(db, 'session_events', 'scope')) {
                db.exec(`ALTER TABLE session_events ADD COLUMN scope TEXT`);
            }
            if (!tableHasColumn(db, 'session_handoffs', 'tenant_id')) {
                db.exec(`ALTER TABLE session_handoffs ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default'`);
            }
            if (!tableHasColumn(db, 'session_handoffs', 'scope')) {
                db.exec(`ALTER TABLE session_handoffs ADD COLUMN scope TEXT`);
            }
            // Smart backfill: rows whose session_id maps to exactly one tenant in
            // task_snapshots inherit that tenant. Ambiguous or unmapped rows stay
            // at the column default ('default'). Conservative: never crosses
            // tenant boundaries on guesses. The COUNT(DISTINCT) gate is the load-
            // bearing check; without it, rows with multiple tenants under the same
            // session_id would silently pick whichever group came first.
            db.exec(`
        UPDATE session_events
           SET tenant_id = (
             SELECT MAX(t.tenant_id) FROM task_snapshots t
              WHERE t.session_id = session_events.session_id
           )
         WHERE tenant_id = 'default'
           AND (SELECT COUNT(DISTINCT t.tenant_id) FROM task_snapshots t
                 WHERE t.session_id = session_events.session_id) = 1
      `);
            db.exec(`
        UPDATE session_handoffs
           SET tenant_id = (
             SELECT MAX(t.tenant_id) FROM task_snapshots t
              WHERE t.session_id = session_handoffs.session_id
           )
         WHERE tenant_id = 'default'
           AND (SELECT COUNT(DISTINCT t.tenant_id) FROM task_snapshots t
                 WHERE t.session_id = session_handoffs.session_id) = 1
      `);
            db.exec(`CREATE INDEX IF NOT EXISTS idx_session_events_tenant_session ON session_events(tenant_id, session_id, created_at DESC, id DESC)`);
            db.exec(`CREATE INDEX IF NOT EXISTS idx_session_handoffs_tenant_session ON session_handoffs(tenant_id, session_id, created_at DESC)`);
        },
    },
    {
        version: 23,
        up: (db) => {
            // task_snapshots: add scope so all three continuity tables carry it.
            // Self-heal partial-init stores via CREATE TABLE IF NOT EXISTS (the v22
            // session_events / session_handoffs healing is upstream).
            db.exec(`
        CREATE TABLE IF NOT EXISTS task_snapshots (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          task TEXT NOT NULL,
          summary TEXT NOT NULL,
          next_step TEXT NOT NULL,
          status TEXT NOT NULL,
          source TEXT NOT NULL,
          session_id TEXT,
          tenant_id TEXT NOT NULL DEFAULT 'default',
          created_at TEXT NOT NULL,
          updated_at TEXT NOT NULL
        )
      `);
            if (!tableHasColumn(db, 'task_snapshots', 'scope')) {
                db.exec(`ALTER TABLE task_snapshots ADD COLUMN scope TEXT`);
            }
            // Quarantine policy (codex round 1 P1): pre-existing continuity rows
            // with NULL scope cannot be safely classified as public after the fact.
            // Mark them 'unknown:legacy' so the api.recall + cmdRecall default-deny
            // filter excludes them for no-scope callers. Fresh rows from new
            // writers carry NULL when scope is unspecified (legitimate non-Slack
            // writes); the 'unknown:legacy' marker is a v23-only one-shot for
            // pre-upgrade rows.
            //
            // Run UPDATEs only on tables that exist (some test paths and edge stores
            // skip v22's table healing). The UPDATEs are themselves idempotent via
            // the WHERE scope IS NULL clause, so re-running is a no-op.
            if (tableExists(db, 'task_snapshots')) {
                db.exec(`UPDATE task_snapshots SET scope = 'unknown:legacy' WHERE scope IS NULL`);
            }
            if (tableExists(db, 'session_events')) {
                db.exec(`UPDATE session_events SET scope = 'unknown:legacy' WHERE scope IS NULL`);
            }
            if (tableExists(db, 'session_handoffs')) {
                db.exec(`UPDATE session_handoffs SET scope = 'unknown:legacy' WHERE scope IS NULL`);
            }
            db.exec(`CREATE INDEX IF NOT EXISTS idx_task_snapshots_tenant_scope ON task_snapshots(tenant_id, scope, status)`);
        },
    },
    {
        version: 24,
        up: (db) => {
            // v1.3.0 GitHub connector schema (codex round 1, 2026-05-04).
            // Six tables + a min_compatible_binary meta row for rollback safety.
            db.exec(`
        CREATE TABLE IF NOT EXISTS github_event_log (
          idempotency_key TEXT PRIMARY KEY,
          delivery_id TEXT NOT NULL,
          event_name TEXT NOT NULL,
          ingested_at TEXT NOT NULL,
          memory_id TEXT
        )
      `);
            db.exec(`CREATE INDEX IF NOT EXISTS idx_github_event_log_memory ON github_event_log(memory_id) WHERE memory_id IS NOT NULL`);
            db.exec(`CREATE INDEX IF NOT EXISTS idx_github_event_log_delivery ON github_event_log(delivery_id)`);
            db.exec(`
        CREATE TABLE IF NOT EXISTS github_cursors (
          tenant_id TEXT NOT NULL,
          repo_full_name TEXT NOT NULL,
          issues_hwm TEXT,
          issue_comments_hwm TEXT,
          pr_review_comments_hwm TEXT,
          updated_at TEXT NOT NULL,
          PRIMARY KEY (tenant_id, repo_full_name)
        )
      `);
            db.exec(`
        CREATE TABLE IF NOT EXISTS github_dlq (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          tenant_id TEXT NOT NULL,
          raw_payload TEXT NOT NULL,
          error TEXT NOT NULL,
          event_name TEXT,
          delivery_id TEXT,
          signature TEXT,
          installation_id TEXT,
          repo_full_name TEXT,
          retry_count INTEGER NOT NULL DEFAULT 0,
          received_at TEXT NOT NULL,
          retried_at TEXT,
          bucket TEXT NOT NULL DEFAULT 'parse_error'
        )
      `);
            db.exec(`CREATE INDEX IF NOT EXISTS idx_github_dlq_tenant_received ON github_dlq(tenant_id, received_at)`);
            db.exec(`
        CREATE TABLE IF NOT EXISTS github_installations (
          installation_id TEXT PRIMARY KEY,
          tenant_id TEXT NOT NULL,
          added_at TEXT NOT NULL
        )
      `);
            // PAT-mode multi-tenant routing (codex P0 #4). Maps repo_full_name to
            // tenant when the webhook envelope has no `installation` field. Composite
            // PK so the same repo can intentionally be visible to multiple tenants
            // (e.g., shared tooling accounts) — collision is on (repo, tenant) pair.
            db.exec(`
        CREATE TABLE IF NOT EXISTS github_repositories (
          repo_full_name TEXT NOT NULL,
          tenant_id TEXT NOT NULL,
          added_at TEXT NOT NULL,
          PRIMARY KEY (repo_full_name, tenant_id)
        )
      `);
            // Rollback-safety guard (codex P0 #2). Any binary < 1.2.1 lacks the
            // generic *:private:* default-deny and would leak github:private:* rows
            // if it opened this DB. The startup guard in v1.2.1+ refuses to open a
            // DB whose min_compatible_binary is newer than its own version.
            db.prepare(`INSERT INTO meta(key, value) VALUES('min_compatible_binary', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run('1.2.1');
        },
    },
    {
        version: 25,
        up: (db) => {
            // v1.5.0 DAG-aware recall — cache summary metadata so the assembler can
            // reason about scope without re-walking the DAG. Three additive,
            // optional columns. No min_compatible_binary bump: these columns are
            // pure metadata; older binaries opening this DB will see them as NULL
            // / 0 and behave as before. See docs/plans/2026-05-05-dag-recall.md
            // Task 1.
            if (!tableHasColumn(db, 'memories', 'descendant_count')) {
                db.exec(`ALTER TABLE memories ADD COLUMN descendant_count INTEGER NOT NULL DEFAULT 0`);
            }
            if (!tableHasColumn(db, 'memories', 'earliest_at')) {
                db.exec(`ALTER TABLE memories ADD COLUMN earliest_at TEXT`);
            }
            if (!tableHasColumn(db, 'memories', 'latest_at')) {
                db.exec(`ALTER TABLE memories ADD COLUMN latest_at TEXT`);
            }
            // Backfill descendant_count for existing level-2 summary rows. Use
            // dag_parent_id pointing at the summary id. Level-3 (entity profiles)
            // not built today; their descendant_count stays at default 0.
            db.exec(`
        UPDATE memories
           SET descendant_count = (
             SELECT COUNT(*) FROM memories AS c WHERE c.dag_parent_id = memories.id
           )
         WHERE dag_level >= 2
           AND descendant_count = 0
      `);
            // Backfill earliest_at / latest_at from child created timestamps for
            // existing summaries. Children of a level-2 summary are level-1 facts.
            db.exec(`
        UPDATE memories
           SET earliest_at = (
             SELECT MIN(c.created) FROM memories AS c WHERE c.dag_parent_id = memories.id
           ),
               latest_at = (
             SELECT MAX(c.created) FROM memories AS c WHERE c.dag_parent_id = memories.id
           )
         WHERE dag_level >= 2
           AND earliest_at IS NULL
      `);
        },
    },
    {
        version: 26,
        up: (db) => {
            // v1.12.0 A5 v2 sub-1: add role column to api_keys for the admin/member
            // distinction that gates /v1/sleep. Additive — existing keys backfill to
            // 'admin' via DEFAULT (single-tenant operator = admin by definition).
            // No min_compatible_binary bump: old binaries (v1.11.x) ignore the
            // column on SELECTs that don't name it; new binaries on old data run
            // this migration at openHippoDb time before any createApiKey call.
            //
            // v1.12.7 defensive: also guard on tableExists. If a DB landed in the
            // partial-v16-state (api_keys table missing despite schema_version >= 16),
            // running ALTER TABLE here would crash and block all later migrations.
            // The v27 heal below recreates api_keys with the role column already
            // present, so this ALTER becomes a no-op anyway for that path. Defense
            // in depth: guard so v26 never crashes on the partial-apply state.
            if (tableExists(db, 'api_keys') && !tableHasColumn(db, 'api_keys', 'role')) {
                db.exec(`ALTER TABLE api_keys ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`);
            }
        },
    },
    {
        version: 27,
        up: (db) => {
            // v1.12.7 self-heal — re-assert the v16 schema (api_keys + audit_log).
            //
            // Surfaced 2026-05-24 on Keith's ~/.hippo/hippo.db: schema_version
            // recorded as 25 but api_keys and audit_log tables were missing from
            // migration v16. Root cause unknown — the migration runner has wrapped
            // each migration in BEGIN/COMMIT since the first SQLite commit, so
            // atomicity isn't the bug. Possible causes: DROP TABLE post-migration,
            // SQL import / restore from a pre-v16 backup over a v16+ schema_version,
            // or some edge case the wrapping doesn't catch. Cause may be operator
            // action; either way the practical fix is the same.
            //
            // All CREATE IF NOT EXISTS — zero-cost no-op for users without the
            // bug, fixes anyone who has it. Includes the role column from the
            // start so it matches v26's intent without needing v26 to ALTER on
            // this heal path.
            db.exec(`
        CREATE TABLE IF NOT EXISTS api_keys (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          key_id TEXT UNIQUE NOT NULL,
          key_hash TEXT NOT NULL,
          tenant_id TEXT NOT NULL DEFAULT 'default',
          label TEXT,
          created_at TEXT NOT NULL,
          revoked_at TEXT,
          role TEXT NOT NULL DEFAULT 'admin'
        )
      `);
            db.exec(`
        CREATE INDEX IF NOT EXISTS idx_api_keys_tenant_active
        ON api_keys(tenant_id) WHERE revoked_at IS NULL
      `);
            db.exec(`
        CREATE TABLE IF NOT EXISTS audit_log (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          ts TEXT NOT NULL,
          tenant_id TEXT NOT NULL DEFAULT 'default',
          actor TEXT NOT NULL,
          op TEXT NOT NULL,
          target_id TEXT,
          metadata_json TEXT NOT NULL DEFAULT '{}'
        )
      `);
            db.exec(`CREATE INDEX IF NOT EXISTS idx_audit_log_tenant_ts ON audit_log(tenant_id, ts DESC)`);
            // Belt-and-braces: if api_keys existed before this migration WITHOUT
            // the role column (i.e. v16-shape table that v26's ALTER skipped due
            // to tableExists=false on an earlier broken run, then someone manually
            // CREATEd it without role), backfill role.
            if (tableExists(db, 'api_keys') && !tableHasColumn(db, 'api_keys', 'role')) {
                db.exec(`ALTER TABLE api_keys ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`);
            }
        },
    },
    {
        version: 28,
        up: (db) => {
            // E1 of 5-episode DAG live-coupling arc (docs/plans/2026-05-25-dag-e1-schema-v28.md).
            // Adds dirty-flag persistence for the existing DAG layer's level-2
            // summaries so E2 (child-write propagation) and E3 (sleep-cycle
            // rebuild) have somewhere to write + read the staleness signal. Adds
            // dag_level_3_built_at as a column (Keith Q5 pick: lets E5 entity-
            // profile build path land without a second migration).
            //
            // All columns are additive + DEFAULTed/nullable, so backfill is
            // automatic for existing rows. No min_compatible_binary bump: old
            // binaries (v1.12.x) ignore the columns on SELECTs that don't name
            // them; new binaries on old data hit this migration at openHippoDb
            // time before any DAG path touches summary_dirty.
            //
            // Precedent for column-only guards on memories: v25 (db.ts:827),
            // which added the DAG cache columns the same way. memories table
            // itself comes from v1 and is always present, so the tableExists
            // half of the v26/v27 guard pattern isn't load-bearing here.
            if (!tableHasColumn(db, 'memories', 'summary_dirty')) {
                db.exec(`ALTER TABLE memories ADD COLUMN summary_dirty INTEGER NOT NULL DEFAULT 0`);
            }
            if (!tableHasColumn(db, 'memories', 'last_rebuilt_at')) {
                db.exec(`ALTER TABLE memories ADD COLUMN last_rebuilt_at TEXT`);
            }
            if (!tableHasColumn(db, 'memories', 'rebuild_count')) {
                db.exec(`ALTER TABLE memories ADD COLUMN rebuild_count INTEGER NOT NULL DEFAULT 0`);
            }
            if (!tableHasColumn(db, 'memories', 'dag_level_3_built_at')) {
                // Reserved for E5: buildEntityProfiles will set this on level-3
                // rows when they're created. Always NULL for level 0/1/2 rows.
                db.exec(`ALTER TABLE memories ADD COLUMN dag_level_3_built_at TEXT`);
            }
        },
    },
    {
        version: 29,
        up: (db) => {
            // E2 prediction first-class object (docs/plans/2026-05-26-e2-prediction-object.md).
            // Adds a canonical predictions table for J3 reference-class /
            // planning-fallacy detector (a follow-up episode). Predictions
            // duplicate claim_text in the table itself so memory deletion
            // (forget/consolidate/archive) does not lose prediction data; FK
            // memory_id is NULLABLE with ON DELETE SET NULL.
            //
            // Cross-tenant safety: BEFORE INSERT + BEFORE UPDATE triggers
            // enforce tenant_id match against the referenced memory. SQLite's
            // ON DELETE SET NULL is incompatible with composite FK where one
            // side is NOT NULL, so the trigger pattern replaces a composite
            // FK target. Precedent: v14 memories.kind trigger pair at db.ts:298-322.
            //
            // CHECK constraint pins closure_state to (open|closed|closed-unknown).
            // J3 computes accuracy (clean vs regressed) from (estimate_value,
            // actual_value) at query time.
            if (!tableExists(db, 'predictions')) {
                db.exec(`
          CREATE TABLE predictions (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            memory_id TEXT,
            tenant_id TEXT NOT NULL,
            class_tag TEXT NOT NULL,
            claim_text TEXT NOT NULL,
            estimate_value REAL,
            estimate_unit TEXT,
            target_date TEXT,
            actual_value REAL,
            closure_state TEXT NOT NULL DEFAULT 'open'
              CHECK (closure_state IN ('open', 'closed', 'closed-unknown')),
            closed_at TEXT,
            closure_note TEXT,
            created_at TEXT NOT NULL,
            FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE SET NULL
          )
        `);
                db.exec(`
          CREATE INDEX IF NOT EXISTS idx_predictions_tenant_class
          ON predictions(tenant_id, class_tag, closure_state)
        `);
                db.exec(`
          CREATE INDEX IF NOT EXISTS idx_predictions_memory
          ON predictions(memory_id) WHERE memory_id IS NOT NULL
        `);
                // Cross-tenant safety: tenant_id must match the referenced memory's
                // tenant_id when memory_id IS NOT NULL. INSERT + UPDATE pair, mirroring
                // v14 memories.kind enforcement (db.ts:298-322).
                db.exec(`
          CREATE TRIGGER IF NOT EXISTS trg_predictions_tenant_match_insert
          BEFORE INSERT ON predictions
          WHEN NEW.memory_id IS NOT NULL
          BEGIN
            SELECT CASE
              WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
              THEN RAISE(ABORT, 'predictions.tenant_id must match memories.tenant_id for the referenced memory')
            END;
          END
        `);
                db.exec(`
          CREATE TRIGGER IF NOT EXISTS trg_predictions_tenant_match_update
          BEFORE UPDATE ON predictions
          WHEN NEW.memory_id IS NOT NULL
            AND (NEW.memory_id IS NOT OLD.memory_id OR NEW.tenant_id IS NOT OLD.tenant_id)
          BEGIN
            SELECT CASE
              WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
              THEN RAISE(ABORT, 'predictions.tenant_id must match memories.tenant_id for the referenced memory')
            END;
          END
        `);
            }
        },
    },
    {
        version: 30,
        up: (db) => {
            // E2 decision first-class object (docs/plans/2026-05-28-e2-decision-object.md).
            // Promotes `hippo decide` from a tagged memory (which decayed on a 90-day
            // half-life even while the decision was still in force) to a canonical
            // decisions table that is the source of truth. The memory mirror is kept
            // for recall but is no longer authoritative; memory_id is NULLABLE with
            // ON DELETE SET NULL so forget/consolidate/archive does not lose a
            // decision. Mirrors the v29 predictions tenant-match trigger pattern.
            //
            // status (active|superseded|closed): superseded carries a self-FK
            // superseded_by to the successor decision; closed is a terminal
            // retire-without-successor. A superseded_by same-tenant trigger makes
            // cross-tenant supersession unrepresentable at the schema level.
            if (!tableExists(db, 'decisions')) {
                db.exec(`
          CREATE TABLE decisions (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            memory_id TEXT,
            tenant_id TEXT NOT NULL,
            decision_text TEXT NOT NULL,
            context TEXT,
            status TEXT NOT NULL DEFAULT 'active'
              CHECK (status IN ('active', 'superseded', 'closed')),
            superseded_by INTEGER,
            superseded_at TEXT,
            closed_at TEXT,
            created_at TEXT NOT NULL,
            FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE SET NULL,
            FOREIGN KEY (superseded_by) REFERENCES decisions(id) ON DELETE SET NULL
          )
        `);
                db.exec(`
          CREATE INDEX IF NOT EXISTS idx_decisions_tenant_status
          ON decisions(tenant_id, status)
        `);
                db.exec(`
          CREATE INDEX IF NOT EXISTS idx_decisions_memory
          ON decisions(memory_id) WHERE memory_id IS NOT NULL
        `);
                // Cross-tenant safety vs the referenced memory (mirrors predictions v29).
                db.exec(`
          CREATE TRIGGER IF NOT EXISTS trg_decisions_tenant_match_insert
          BEFORE INSERT ON decisions
          WHEN NEW.memory_id IS NOT NULL
          BEGIN
            SELECT CASE
              WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
              THEN RAISE(ABORT, 'decisions.tenant_id must match memories.tenant_id for the referenced memory')
            END;
          END
        `);
                db.exec(`
          CREATE TRIGGER IF NOT EXISTS trg_decisions_tenant_match_update
          BEFORE UPDATE ON decisions
          WHEN NEW.memory_id IS NOT NULL
            AND (NEW.memory_id IS NOT OLD.memory_id OR NEW.tenant_id IS NOT OLD.tenant_id)
          BEGIN
            SELECT CASE
              WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
              THEN RAISE(ABORT, 'decisions.tenant_id must match memories.tenant_id for the referenced memory')
            END;
          END
        `);
                // Cross-tenant safety vs the successor decision (self-FK). superseded_by
                // is set only via UPDATE (the supersede path); the successor must share
                // the tenant. The successor row already exists in the same transaction
                // when this fires, so the subquery resolves.
                db.exec(`
          CREATE TRIGGER IF NOT EXISTS trg_decisions_supersede_tenant_match_update
          BEFORE UPDATE ON decisions
          WHEN NEW.superseded_by IS NOT NULL
            AND NEW.superseded_by IS NOT OLD.superseded_by
          BEGIN
            SELECT CASE
              WHEN NEW.tenant_id != (SELECT tenant_id FROM decisions WHERE id = NEW.superseded_by)
              THEN RAISE(ABORT, 'decisions.superseded_by must reference a decision in the same tenant')
            END;
          END
        `);
            }
        },
    },
];
function tableHasColumn(db, tableName, columnName) {
    if (!/^[a-z_]+$/i.test(tableName))
        throw new Error(`Invalid table name: ${tableName}`);
    const rows = db.prepare(`PRAGMA table_info(${tableName})`).all();
    return rows.some((row) => row.name === columnName);
}
function tableExists(db, tableName) {
    if (!/^[a-z_]+$/i.test(tableName))
        throw new Error(`Invalid table name: ${tableName}`);
    const row = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name = ?`).get(tableName);
    return !!row?.name;
}
export function getHippoDbPath(hippoRoot) {
    return path.join(hippoRoot, 'hippo.db');
}
export function getCurrentSchemaVersion() {
    return CURRENT_SCHEMA_VERSION;
}
function readMinCompatibleBinary(db) {
    // Tolerate the meta table not yet existing on a fresh DB.
    try {
        const row = db.prepare(`SELECT value FROM meta WHERE key = 'min_compatible_binary'`).get();
        return row?.value ?? null;
    }
    catch {
        return null;
    }
}
export function openHippoDb(hippoRoot) {
    fs.mkdirSync(hippoRoot, { recursive: true });
    const db = new DatabaseSync(getHippoDbPath(hippoRoot));
    try {
        db.exec('PRAGMA journal_mode = WAL');
        db.exec('PRAGMA busy_timeout = 5000');
        db.exec('PRAGMA synchronous = NORMAL');
        db.exec('PRAGMA wal_autocheckpoint = 100');
        db.exec('PRAGMA foreign_keys = ON');
        runMigrations(db);
        // Path A backfill: delete any orphan markdown mirrors for already-archived
        // raw_archive rows. Idempotent via per-row raw_archive.mirror_cleaned_at
        // (v21). Wrapped in try/catch — a filesystem failure must not prevent DB open.
        try {
            cleanupArchivedMirrors(hippoRoot, db);
        }
        catch (cleanupErr) {
            console.error('openHippoDb: cleanupArchivedMirrors failed (non-fatal):', cleanupErr);
        }
        return db;
    }
    catch (error) {
        try {
            db.close();
        }
        catch {
            // Best effort only.
        }
        throw error;
    }
}
function runMigrations(db) {
    ensureMetaTable(db);
    // v1.3.1 rollback-safety guard. Schema v24 stamped meta.min_compatible_binary;
    // older binaries that lack the generic *:private:* default-deny filter would
    // leak github:private:* rows on no-scope recall. Refuse to open a DB stamped
    // with a min newer than this binary's version. Read BEFORE migrations so a
    // stale v1.2.0 binary cannot apply unknown future migrations either.
    const minRequired = readMinCompatibleBinary(db);
    if (minRequired && compareSemver(minRequired, PACKAGE_VERSION) > 0) {
        throw new Error(`hippo-memory: this database requires hippo-memory >= ${minRequired}, but the running binary is ${PACKAGE_VERSION}. ` +
            `Upgrade hippo-memory to open this database. Running an older binary against this DB would leak private rows that the ` +
            `older filter does not recognize.`);
    }
    let currentVersion = getSchemaVersion(db);
    for (const migration of MIGRATIONS) {
        if (migration.version <= currentVersion)
            continue;
        db.exec('BEGIN');
        try {
            migration.up(db);
            setSchemaVersion(db, migration.version);
            db.exec('COMMIT');
            currentVersion = migration.version;
        }
        catch (error) {
            db.exec('ROLLBACK');
            throw error;
        }
    }
    ensureMetaDefaults(db);
    ensureOptionalFts(db);
}
function ensureMetaTable(db) {
    db.exec(`
    CREATE TABLE IF NOT EXISTS meta (
      key TEXT PRIMARY KEY,
      value TEXT NOT NULL
    );
  `);
}
export function getSchemaVersion(db) {
    const row = db.prepare(`SELECT value FROM meta WHERE key = 'schema_version'`).get();
    const version = Number(row?.value ?? 0);
    return Number.isFinite(version) ? version : 0;
}
function setSchemaVersion(db, version) {
    db.prepare(`INSERT INTO meta(key, value) VALUES('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(String(version));
    db.exec(`PRAGMA user_version = ${Math.max(0, Math.trunc(version))}`);
}
function ensureMetaDefaults(db) {
    const defaults = [
        ['schema_version', String(CURRENT_SCHEMA_VERSION)],
        ['last_retrieval_ids', '[]'],
        ['total_remembered', '0'],
        ['total_recalled', '0'],
        ['total_forgotten', '0'],
        ['fts5_available', '0'],
    ];
    const stmt = db.prepare(`INSERT OR IGNORE INTO meta(key, value) VALUES (?, ?)`);
    for (const [key, value] of defaults) {
        stmt.run(key, value);
    }
}
function ensureOptionalFts(db) {
    let available = false;
    try {
        db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(id UNINDEXED, content, tags)`);
        backfillFtsIndex(db);
        available = true;
    }
    catch {
        available = false;
    }
    db.prepare(`INSERT INTO meta(key, value) VALUES('fts5_available', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(available ? '1' : '0');
}
function backfillFtsIndex(db) {
    const memCount = db.prepare(`SELECT COUNT(*) AS c FROM memories`).get()?.c ?? 0;
    const ftsCount = db.prepare(`SELECT COUNT(*) AS c FROM memories_fts`).get()?.c ?? 0;
    if (memCount === ftsCount)
        return;
    db.exec(`
    INSERT INTO memories_fts(id, content, tags)
    SELECT m.id, m.content, m.tags_json
    FROM memories m
    WHERE NOT EXISTS (
      SELECT 1 FROM memories_fts f WHERE f.id = m.id
    )
  `);
    db.exec(`
    DELETE FROM memories_fts
    WHERE id NOT IN (SELECT id FROM memories)
  `);
}
export function closeHippoDb(db) {
    db.close();
}
export function getMeta(db, key, fallback = '') {
    const row = db.prepare(`SELECT value FROM meta WHERE key = ?`).get(key);
    return row?.value ?? fallback;
}
export function setMeta(db, key, value) {
    db.prepare(`INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(key, value);
}
export function isFtsAvailable(db) {
    return getMeta(db, 'fts5_available', '0') === '1';
}
export function pruneConsolidationRuns(db, keep = 50) {
    db.prepare(`
    DELETE FROM consolidation_runs
    WHERE id NOT IN (
      SELECT id FROM consolidation_runs
      ORDER BY timestamp DESC, id DESC
      LIMIT ?
    )
  `).run(keep);
}
//# sourceMappingURL=db.js.map