/** * Chat V2 SQLite bootstrap. * * Opens (or creates) the chat database at the configured path, applies * the Phase 1 migration idempotently, and returns a ready-to-use * `better-sqlite3` Database instance. * * Schema matches tech-spec §3.2. All tables use `INTEGER ms-since-epoch (UTC)` * for timestamps and raw UUID strings for IDs. * * @module services/chat-v2/sqlite/chat-db */ import { type ComponentLogger } from '../../core/logger.service.js'; /** * F-CYCLE7-1: install a test-only stub for the better-sqlite3 loader. * * Pass a function to override; pass `null` to restore the real loader * AND clear the cached `better-sqlite3` reference so the next call * exercises the stub. Tests MUST call this with `null` in `afterEach` * to avoid leaking state across cases. * * @param fn - Stub `require`-like function or `null` to restore real loader */ export declare function setBetterSqlite3LoaderForTesting(fn: ((name: string) => unknown) | null): void; /** Type alias for a `better-sqlite3` Database instance. */ export type ChatDatabase = import('better-sqlite3').Database; /** * Idempotent base DDL — Phase 1 schema with Phase A column additions baked in. * * Safe to run every boot. * * Differences vs. the literal spec §3.2: * - Added `IF NOT EXISTS` on every CREATE so reboots don't fail. * - PRAGMAs set outside the transaction (SQLite disallows PRAGMA in txn). * * Phase A additions (SEALED design 2026-04-25 §3.1 + §3.2) are reflected * directly in the CREATE TABLE bodies so a fresh-install database lands on * the new schema in one shot. Pre-existing databases (created under the * Phase 1 schema) get the same columns added by `applyPhaseAColumnUpgrades` * below, which uses `ALTER TABLE ADD COLUMN` guarded by `PRAGMA table_info`. */ export declare const CHAT_V2_MIGRATION_SQL = "\nCREATE TABLE IF NOT EXISTS chat_channels (\n id TEXT PRIMARY KEY,\n agent_session TEXT NOT NULL,\n owner_user_id TEXT NOT NULL,\n name TEXT NOT NULL,\n purpose TEXT,\n created_at INTEGER NOT NULL,\n archived_at INTEGER,\n last_message_at INTEGER,\n -- Phase A (SEALED \u00A73.1): channel-type discriminator. 'dm' preserves the\n -- Phase 1 1:1 user\u2194agent contract; 'channel' is the Slack-like team\n -- surface. 'huddle' (Phase B-2, 2026-05-17) is ad-hoc multi-agent\n -- groups with explicit membership in chat_channel_members.\n -- Existing rows backfill to 'dm' via applyPhaseAColumnUpgrades.\n type TEXT NOT NULL DEFAULT 'dm' CHECK(type IN ('dm','channel','huddle')),\n -- Phase A (SEALED \u00A73.1): team workspace link. Required at the service\n -- layer when type='channel'; null for type='dm'.\n team_id TEXT,\n -- Phase A (SEALED \u00A73.1): optional project link for project-scoped channels.\n project_id TEXT,\n -- Phase A (SEALED \u00A73.1): for type='dm', the resolved member-ID being DM'd\n -- (distinct from agent_session which is the wire-level binding key).\n target_member_id TEXT\n);\n\nCREATE INDEX IF NOT EXISTS ix_channels_owner\n ON chat_channels(owner_user_id, archived_at);\n\n-- Phase A indexes that reference the new columns (type, team_id, thread_id)\n-- live in CHAT_V2_PHASE_A_INDEX_SQL below \u2014 they must be created AFTER\n-- applyPhaseAColumnUpgrades runs so the columns exist on legacy DBs.\n\nCREATE TABLE IF NOT EXISTS chat_messages (\n id TEXT PRIMARY KEY,\n channel_id TEXT NOT NULL REFERENCES chat_channels(id) ON DELETE CASCADE,\n seq INTEGER NOT NULL,\n sender_type TEXT NOT NULL CHECK(sender_type IN ('user','agent','system')),\n sender_id TEXT NOT NULL,\n content TEXT NOT NULL,\n content_type TEXT NOT NULL CHECK(content_type IN ('text','markdown','image_ref','system_note'))\n DEFAULT 'markdown',\n created_at INTEGER NOT NULL,\n metadata TEXT,\n -- Phase A (SEALED \u00A73.2): JSON-encoded array of mention IDs (member or\n -- team) referenced inline in the content field. Stored as a JSON string;\n -- service layer treats null as []. Bounded at insert time; see types.ts.\n mentions TEXT,\n -- Phase A (SEALED \u00A73.2): optional Slack-style thread root. When set,\n -- this message is a reply within the thread rooted at thread_id.\n thread_id TEXT\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS uq_messages_channel_seq\n ON chat_messages(channel_id, seq);\n\nCREATE INDEX IF NOT EXISTS ix_messages_channel_created\n ON chat_messages(channel_id, created_at DESC);\n\n-- Owner messages across channels by time (owner active minutes, #1387)\nCREATE INDEX IF NOT EXISTS ix_messages_sender_created\n ON chat_messages(sender_type, created_at);\n\n-- Phase 1: partial unique index for clientMessageId-based idempotency (spec \u00A74.4)\nCREATE UNIQUE INDEX IF NOT EXISTS uq_messages_client_id\n ON chat_messages(channel_id, json_extract(metadata, '$.clientMessageId'))\n WHERE json_extract(metadata, '$.clientMessageId') IS NOT NULL;\n\nCREATE TABLE IF NOT EXISTS chat_attachments (\n id TEXT PRIMARY KEY,\n message_id TEXT NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,\n kind TEXT NOT NULL CHECK(kind IN ('image')),\n mime_type TEXT NOT NULL,\n size_bytes INTEGER NOT NULL,\n local_path TEXT NOT NULL,\n original_name TEXT,\n created_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS ix_attachments_message\n ON chat_attachments(message_id);\n\nCREATE TABLE IF NOT EXISTS chat_offline_queue (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_id TEXT NOT NULL,\n agent_session TEXT NOT NULL,\n message_id TEXT NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,\n queued_at INTEGER NOT NULL,\n delivered_at INTEGER,\n attempts INTEGER NOT NULL DEFAULT 0\n);\n\nCREATE INDEX IF NOT EXISTS ix_queue_pending\n ON chat_offline_queue(agent_session, delivered_at)\n WHERE delivered_at IS NULL;\n\n-- Phase B-2 (2026-05-17): huddle membership roster. One row per\n-- (huddle_channel, agent_session_name). For type='huddle' channels\n-- only; 'dm' and 'channel' rows have no entries here.\n--\n-- Dispatcher uses this list to fan a user message out to every\n-- huddle member; the @-mention subset gets a \"must respond\" prompt\n-- variant while the rest get an \"optional\" one.\nCREATE TABLE IF NOT EXISTS chat_channel_members (\n channel_id TEXT NOT NULL REFERENCES chat_channels(id) ON DELETE CASCADE,\n member_session TEXT NOT NULL,\n joined_at INTEGER NOT NULL,\n PRIMARY KEY (channel_id, member_session)\n);\n\nCREATE INDEX IF NOT EXISTS ix_channel_members_session\n ON chat_channel_members(member_session);\n"; /** * Phase A indexes that reference Phase A columns. Must run AFTER * `applyPhaseAColumnUpgrades` adds those columns on pre-Phase-A databases — * otherwise the partial-index `WHERE` clauses fail with "no such column". * * On a fresh database this runs after `CHAT_V2_MIGRATION_SQL` has already * baked the columns into the CREATE TABLE bodies, so each step is a * harmless no-op (`CREATE INDEX IF NOT EXISTS`). */ export declare const CHAT_V2_PHASE_A_INDEX_SQL = "\n-- Phase A (SEALED \u00A73.1) used to define uq_channel_agent_dm_active\n-- enforcing 1:1 agent<->DM-channel binding. That constraint was dropped\n-- per the unified-chat-message-store spec (2026-05-14 \u00A7\"Phase 2 design\n-- discovery\" \u2192 Option B): legacy ChatService models \"one conversation\n-- per Slack thread or web chat session\", so an agent participates in\n-- N concurrent DM-style channels. Dropping the index removes the\n-- agent_already_bound (409) failure that would otherwise block\n-- migration of the legacy multi-thread model. Existing databases get\n-- the drop applied in applyPhaseAColumnUpgrades.\n\n-- Phase A (SEALED \u00A73.1): scoped lookup for \"all channels in team T\" and\n-- \"project P channels\". Indexes by team first because team-scoped reads\n-- are the dominant Phase B+ access pattern.\nCREATE INDEX IF NOT EXISTS ix_channels_team\n ON chat_channels(team_id, archived_at)\n WHERE team_id IS NOT NULL;\n\n-- Phase A (SEALED \u00A73.2): thread-pane lookup. Filtered to non-null so the\n-- (smaller) index only covers actual threaded replies.\nCREATE INDEX IF NOT EXISTS ix_messages_thread\n ON chat_messages(thread_id, seq)\n WHERE thread_id IS NOT NULL;\n"; /** * Bring a pre-existing chat database up to Phase A's schema by adding * any missing columns and the new indexes / dropping the superseded ones. * * Safe to run on a fresh database too — every step is no-op idempotent. * Specifically: * - `ensureColumn` is a no-op when the column is already present (the * fresh-install case, since the columns are baked into the CREATE * TABLE in `CHAT_V2_MIGRATION_SQL`). * - `DROP INDEX IF EXISTS uq_channel_agent_active` removes the legacy * unconditional partial index so the new dm-scoped one isn't shadowed. * The DROP is metadata-only — no row data is affected. * * Exported (separately from `openChatDatabase`) so tests can simulate the * pre-Phase-A → Phase A migration path explicitly. * * @param db - The chat database handle * @returns A small report describing what changed (for logging) */ export declare function applyPhaseAColumnUpgrades(db: ChatDatabase): { channelsAdded: string[]; messagesAdded: string[]; legacyIndexDropped: boolean; phaseADmIndexDropped: boolean; /** * Phase B-2: whether `chat_channels` was rebuilt to drop the legacy * `CHECK(type IN ('dm','channel'))` constraint and allow `'huddle'`. * `false` on fresh installs (the new CHECK is already in the * baseline DDL) and on subsequent boots after the first rebuild. */ channelsCheckRebuilt: boolean; }; /** * Options accepted by `openChatDatabase`. */ export interface OpenChatDatabaseOptions { /** Absolute path to the SQLite file. Parent dirs will be created. */ dbPath: string; /** * When true, uses SQLite `:memory:` regardless of `dbPath`. * Intended for unit tests. Defaults to false. */ inMemory?: boolean; /** * When true, suppress the integrity-check warning on open. Tests use this * to avoid noisy logs. Defaults to false. */ skipIntegrityCheck?: boolean; /** Optional logger override — defaults to LoggerService. */ logger?: ComponentLogger; } /** * Create `chat_read_state` (the owner's per-channel read marker, #1400) and, * the first time only, seed one row per existing channel at its current max * `seq` so an upgrade does not flood every old conversation as unread. * * "First time" is detected by the table not existing yet: re-seeding on every * boot would silently mark genuinely unread channels as read after a restart. * A channel without a row has `last_read_seq` 0. * * @param db - The chat database handle * @returns Whether the table was created and how many rows were seeded */ export declare function applyReadStateUpgrade(db: ChatDatabase): { created: boolean; seeded: number; }; /** * Open (or create) the chat database, apply PRAGMAs, run the Phase 1 * migration idempotently, and run a boot-time integrity check. * * @param options - Opener options * @returns A ready-to-use better-sqlite3 Database handle * * @example * ```ts * const db = openChatDatabase({ dbPath: '/tmp/chat.db' }); * // ... use db ... * db.close(); * ``` */ export declare function openChatDatabase(options: OpenChatDatabaseOptions): ChatDatabase; //# sourceMappingURL=chat-db.d.ts.map