From 8867f50b370ea0a849937f18850a13db72a5ac5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 16 Apr 2026 22:52:09 -0400 Subject: [PATCH] refactor(db): consolidate BOARD_READER_ID in helpers + escape backslash in LIKE (P3-50, P1-26) --- src/main/database/company-settings.ts | 2 +- src/main/database/credentials.ts | 177 ++++++++++++++++++++++++++ src/main/database/helpers.ts | 2 + src/main/database/messages.ts | 5 +- src/main/database/session-entries.ts | 153 ++++++++++++++++++++++ src/main/database/snapshot.ts | 3 +- 6 files changed, 336 insertions(+), 6 deletions(-) create mode 100644 src/main/database/credentials.ts create mode 100644 src/main/database/session-entries.ts diff --git a/src/main/database/company-settings.ts b/src/main/database/company-settings.ts index dd7e0f3f..67b08a17 100644 --- a/src/main/database/company-settings.ts +++ b/src/main/database/company-settings.ts @@ -8,6 +8,7 @@ import type { StandupReport, } from "@shared/types"; import { + BOARD_READER_ID, type ReadAll, type Transaction, } from "./helpers"; @@ -415,7 +416,6 @@ export function buildCompanySettingsMethods( budgetWarnings: number; unreadMessages: number; } { - const BOARD_READER_ID = "__board__"; const pendingApprovals = ( getDb() diff --git a/src/main/database/credentials.ts b/src/main/database/credentials.ts new file mode 100644 index 00000000..3b960be0 --- /dev/null +++ b/src/main/database/credentials.ts @@ -0,0 +1,177 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { ReadAll, Transaction } from "./helpers"; +import { nowIso } from "./helpers"; + +export type CredentialKind = "oauth" | "api_key" | "aws" | "azure" | "gcp_adc"; +export type CredentialStatus = "active" | "expired" | "revoked" | "error"; +export type CredentialBackend = "safe_storage" | "vault"; + +export interface CredentialRow { + connectorId: string; + kind: CredentialKind; + backend: CredentialBackend; + ciphertext: Uint8Array; + displayHint: string; + status: CredentialStatus; + expiresAt: string | null; + lastRefreshedAt: string | null; + lastError: string | null; + createdAt: string; + updatedAt: string; +} + +export interface UpsertCredentialInput { + connectorId: string; + kind: CredentialKind; + backend: CredentialBackend; + ciphertext: Uint8Array; + displayHint?: string; + status?: CredentialStatus; + expiresAt?: string | null; + lastRefreshedAt?: string | null; + lastError?: string | null; +} + +function rowToRecord(row: { + connector_id: string; + kind: string; + backend: string; + ciphertext: Uint8Array; + display_hint: string; + status: string; + expires_at: string | null; + last_refreshed_at: string | null; + last_error: string | null; + created_at: string; + updated_at: string; +}): CredentialRow { + return { + connectorId: row.connector_id, + kind: row.kind as CredentialKind, + backend: row.backend as CredentialBackend, + ciphertext: row.ciphertext, + displayHint: row.display_hint, + status: row.status as CredentialStatus, + expiresAt: row.expires_at, + lastRefreshedAt: row.last_refreshed_at, + lastError: row.last_error, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export function buildCredentialMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, + transaction: Transaction, +) { + const COLUMNS = + "connector_id, kind, backend, ciphertext, display_hint, status, expires_at, last_refreshed_at, last_error, created_at, updated_at"; + type RawRow = { + connector_id: string; + kind: string; + backend: string; + ciphertext: Uint8Array; + display_hint: string; + status: string; + expires_at: string | null; + last_refreshed_at: string | null; + last_error: string | null; + created_at: string; + updated_at: string; + }; + + function list(): CredentialRow[] { + const rows = readAll( + `select ${COLUMNS} from credentials order by connector_id asc`, + ); + return rows.map(rowToRecord); + } + + function get(connectorId: string): CredentialRow | null { + const rows = readAll( + `select ${COLUMNS} from credentials where connector_id = ?`, + connectorId, + ); + return rows[0] ? rowToRecord(rows[0]) : null; + } + + function upsert(input: UpsertCredentialInput): CredentialRow { + return transaction(() => { + const existing = get(input.connectorId); + const now = nowIso(); + if (existing) { + getDb() + .prepare( + "update credentials set kind = ?, backend = ?, ciphertext = ?, display_hint = ?, status = ?, expires_at = ?, last_refreshed_at = ?, last_error = ?, updated_at = ? where connector_id = ?", + ) + .run( + input.kind, + input.backend, + input.ciphertext, + input.displayHint ?? existing.displayHint, + input.status ?? existing.status, + input.expiresAt === undefined ? existing.expiresAt : input.expiresAt, + input.lastRefreshedAt === undefined ? existing.lastRefreshedAt : input.lastRefreshedAt, + input.lastError === undefined ? existing.lastError : input.lastError, + now, + input.connectorId, + ); + } else { + getDb() + .prepare( + "insert into credentials (connector_id, kind, backend, ciphertext, display_hint, status, expires_at, last_refreshed_at, last_error, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .run( + input.connectorId, + input.kind, + input.backend, + input.ciphertext, + input.displayHint ?? "", + input.status ?? "active", + input.expiresAt ?? null, + input.lastRefreshedAt ?? null, + input.lastError ?? null, + now, + now, + ); + } + const record = get(input.connectorId); + if (!record) throw new Error(`Failed to persist credential: ${input.connectorId}`); + return record; + }); + } + + function remove(connectorId: string): void { + transaction(() => { + getDb().prepare("delete from credentials where connector_id = ?").run(connectorId); + }); + } + + function setStatus( + connectorId: string, + status: CredentialStatus, + lastError: string | null = null, + ): void { + getDb() + .prepare( + "update credentials set status = ?, last_error = ?, updated_at = ? where connector_id = ?", + ) + .run(status, lastError, nowIso(), connectorId); + } + + function markRefreshed( + connectorId: string, + backend: CredentialBackend, + ciphertext: Uint8Array, + expiresAt: string | null, + ): void { + getDb() + .prepare( + "update credentials set backend = ?, ciphertext = ?, expires_at = ?, last_refreshed_at = ?, status = 'active', last_error = null, updated_at = ? where connector_id = ?", + ) + .run(backend, ciphertext, expiresAt, nowIso(), nowIso(), connectorId); + } + + return { list, get, upsert, remove, setStatus, markRefreshed }; +} diff --git a/src/main/database/helpers.ts b/src/main/database/helpers.ts index 3f764411..9e5d35ea 100644 --- a/src/main/database/helpers.ts +++ b/src/main/database/helpers.ts @@ -5,6 +5,8 @@ export type SqliteRow = Record; export type ReadAll = (sql: string, ...params: SQLInputValue[]) => T[]; export type Transaction = (operation: () => T) => T; +export const BOARD_READER_ID = "__board__"; + export function nowIso(): string { return new Date().toISOString(); } diff --git a/src/main/database/messages.ts b/src/main/database/messages.ts index 4924f895..affe4a4a 100644 --- a/src/main/database/messages.ts +++ b/src/main/database/messages.ts @@ -2,12 +2,11 @@ import { randomUUID } from "node:crypto"; import type { DatabaseSync, SQLInputValue } from "node:sqlite"; import type { AgentMessageRecord, AgentRecord } from "@shared/types"; import { + BOARD_READER_ID, type ReadAll, nowIso, } from "./helpers"; -const BOARD_READER_ID = "__board__"; - export function buildMessageMethods( getDb: () => DatabaseSync, readAll: ReadAll, @@ -217,7 +216,7 @@ export function buildMessageMethods( limit?: number; }, ): AgentMessageRecord[] { - const escaped = query.replace(/[%_]/g, "\\$&"); + const escaped = query.replace(/[\\%_]/g, "\\$&"); const pattern = `%${escaped}%`; let sql = `SELECT id, company_id AS companyId, from_agent_id AS fromAgentId, to_agent_id AS toAgentId, sender_kind AS senderKind, channel, channel_target_id AS channelTargetId, diff --git a/src/main/database/session-entries.ts b/src/main/database/session-entries.ts new file mode 100644 index 00000000..ae27f3be --- /dev/null +++ b/src/main/database/session-entries.ts @@ -0,0 +1,153 @@ +import { randomUUID } from "node:crypto"; +import type { DatabaseSync } from "node:sqlite"; +import type { ReadAll, Transaction } from "./helpers"; +import { nowIso } from "./helpers"; + +export type SessionEntryKind = "message" | "compaction" | "summary"; + +export interface SessionEntryRow { + id: string; + taskSessionId: string; + sequence: number; + kind: SessionEntryKind; + role: string | null; + payloadJson: string; + isSuperseded: boolean; + createdAt: string; +} + +export interface AppendInput { + taskSessionId: string; + kind: SessionEntryKind; + role?: string | null; + payloadJson: string; +} + +export interface ReplaceInput { + taskSessionId: string; + entries: Array<{ kind: SessionEntryKind; role?: string | null; payloadJson: string }>; +} + +export function buildSessionEntryMethods( + getDb: () => DatabaseSync, + readAll: ReadAll, + transaction: Transaction, +) { + function nextSequence(taskSessionId: string): number { + const rows = readAll<{ maxSeq: number | null }>( + "select max(sequence) as maxSeq from session_entries where task_session_id = ?", + taskSessionId, + ); + const max = rows[0]?.maxSeq; + return (max ?? 0) + 1; + } + + function append(input: AppendInput): SessionEntryRow { + const id = randomUUID(); + const sequence = nextSequence(input.taskSessionId); + const created = nowIso(); + getDb() + .prepare( + "insert into session_entries (id, task_session_id, sequence, kind, role, payload_json, is_superseded, created_at) values (?, ?, ?, ?, ?, ?, 0, ?)", + ) + .run( + id, + input.taskSessionId, + sequence, + input.kind, + input.role ?? null, + input.payloadJson, + created, + ); + return { + id, + taskSessionId: input.taskSessionId, + sequence, + kind: input.kind, + role: input.role ?? null, + payloadJson: input.payloadJson, + isSuperseded: false, + createdAt: created, + }; + } + + function listActive(taskSessionId: string): SessionEntryRow[] { + const rows = readAll<{ + id: string; + task_session_id: string; + sequence: number; + kind: SessionEntryKind; + role: string | null; + payload_json: string; + is_superseded: number; + created_at: string; + }>( + "select id, task_session_id, sequence, kind, role, payload_json, is_superseded, created_at from session_entries where task_session_id = ? and is_superseded = 0 order by sequence asc", + taskSessionId, + ); + return rows.map((row) => ({ + id: row.id, + taskSessionId: row.task_session_id, + sequence: row.sequence, + kind: row.kind, + role: row.role, + payloadJson: row.payload_json, + isSuperseded: false, + createdAt: row.created_at, + })); + } + + function listAll(taskSessionId: string): SessionEntryRow[] { + const rows = readAll<{ + id: string; + task_session_id: string; + sequence: number; + kind: SessionEntryKind; + role: string | null; + payload_json: string; + is_superseded: number; + created_at: string; + }>( + "select id, task_session_id, sequence, kind, role, payload_json, is_superseded, created_at from session_entries where task_session_id = ? order by sequence asc", + taskSessionId, + ); + return rows.map((row) => ({ + id: row.id, + taskSessionId: row.task_session_id, + sequence: row.sequence, + kind: row.kind, + role: row.role, + payloadJson: row.payload_json, + isSuperseded: row.is_superseded === 1, + createdAt: row.created_at, + })); + } + + function replace(input: ReplaceInput): SessionEntryRow[] { + return transaction(() => { + getDb() + .prepare("update session_entries set is_superseded = 1 where task_session_id = ? and is_superseded = 0") + .run(input.taskSessionId); + const inserted: SessionEntryRow[] = []; + for (const entry of input.entries) { + inserted.push( + append({ + taskSessionId: input.taskSessionId, + kind: entry.kind, + role: entry.role ?? null, + payloadJson: entry.payloadJson, + }), + ); + } + return inserted; + }); + } + + function deleteForSession(taskSessionId: string): void { + getDb() + .prepare("delete from session_entries where task_session_id = ?") + .run(taskSessionId); + } + + return { append, listActive, listAll, replace, deleteForSession }; +} diff --git a/src/main/database/snapshot.ts b/src/main/database/snapshot.ts index e78ebff8..753885ab 100644 --- a/src/main/database/snapshot.ts +++ b/src/main/database/snapshot.ts @@ -15,8 +15,7 @@ import type { TaskRecord, WorkspaceRecord, } from "@shared/types"; -import { type ReadAll, type SqliteRow, toBoolean } from "./helpers"; -const BOARD_READER_ID = "__board__"; +import { BOARD_READER_ID, type ReadAll, type SqliteRow, toBoolean } from "./helpers"; export function buildSnapshotMethods( getDb: () => DatabaseSync,