Files
openclaw/src/proxy-capture/store.sqlite.ts
T
Peter Steinberger 0b8aabe864 docs: document auth profile failure policy contract (#89613)
* docs: document markdown marker renderer

* docs: document rendered markdown chunking

* docs: document markdown text chunking

* docs: document shared text chunking

* docs: document plugin text chunking exports

* docs: document avatar policy constants

* docs: document node match candidates

* docs: document scoped expiring id cache

* docs: document runtime import normalization

* docs: document string sample summaries

* docs: document session usage timeseries types

* docs: document session usage response types

* docs: document manifest frontmatter shapes

* docs: document channel route input metadata

* docs: document pair loop guard settings

* docs: document migration config patch helpers

* docs: document api provider registry

* docs: document tool call repair payloads

* docs: document plugin tool payload helpers

* docs: document lazy promise loader

* docs: document store writer queue state

* docs: document thread binding lifecycle

* docs: document concurrency helper contract

* docs: document gateway client info contract

* docs: document delivery context contracts

* docs: document secret ref defaults contract

* docs: document command gating contract

* docs: document avatar policy contract

* docs: document node match policy

* docs: document message channel normalization

* docs: document boolean parsing contract

* docs: document zod parse helpers

* docs: document direct dm guard policy

* docs: document fixed window limiter contract

* docs: document node presence event contract

* docs: document secret normalization contract

* docs: document progress draft line removal

* docs: document usage formatting contracts

* docs: document agent run status contract

* docs: document runtime import helpers

* docs: document provider utility ownership

* docs: document invalid config helpers

* docs: document json compat parser

* docs: document channel config metadata ownership

* docs: document channel logging helpers

* docs: document sender identity validation ownership

* docs: document string sampling helper

* docs: document global singleton helpers

* docs: document transcript tool helpers

* docs: document exec safe-bin normalization

* docs: document reaction level resolver

* docs: document account snapshot redaction boundary

* docs: document messaging target helpers

* docs: document thread binding messages

* docs: document conversation binding context

* docs: document conversation resolution helper

* docs: document owner display secret retention

* docs: document provider request config types

* docs: document skills config types

* docs: document memory config types

* docs: document imessage config types

* docs: document crestodian config types

* docs: document tools config policies

* docs: document shared config base types

* docs: document channel config contracts

* docs: document openclaw config state types

* docs: document model config contracts

* docs: document shared agent config types

* docs: document agent defaults config types

* docs: document secret input contracts

* docs: document auth config contracts

* docs: document gateway config contracts

* docs: document tool call stream repair contracts

* docs: document memory host facades

* docs: document llm core contracts

* docs: document markdown core contracts

* docs: document gateway connect error contracts

* docs: document gateway protocol primitives

* docs: document gateway frame schemas

* docs: document gateway device schemas

* docs: document gateway environment schemas

* docs: document gateway push schemas

* docs: document gateway plugin schemas

* docs: document gateway artifact schemas

* docs: document gateway command schemas

* docs: document gateway task schemas

* docs: document gateway exec approval schemas

* docs: document gateway secret schemas

* docs: document gateway config schemas

* docs: document gateway snapshot schemas

* docs: document gateway chat schemas

* docs: document gateway wizard schemas

* docs: document gateway node schemas

* docs: document gateway plugin approval schemas

* docs: document gateway talk schemas

* docs: document gateway agent schemas

* docs: document gateway session schemas

* docs: document gateway cron schemas

* docs: document gateway agent model skill schemas

* docs: document gateway skill proposal tool schemas

* docs: document gateway protocol registry

* docs: document gateway channel status schemas

* docs: document gateway schema regression tests

* docs: document gateway schema barrel

* docs: document gateway validator tests

* docs: document gateway primitive push tests

* docs: document gateway contract tests

* docs: document native protocol guard

* docs: document channel schema tests

* docs: document gateway protocol smoke tests

* docs: document gateway protocol entrypoint

* docs: document gateway protocol type exports

* docs: document gateway error codes

* docs: document protocol schema registry

* docs: document talk audio codec

* docs: document talk activation names

* docs: document talk consult questions

* docs: document talk consult tool

* docs: document talk run control contracts

* docs: document talk run control adapter

* docs: document talkback consult queue

* docs: document talk consult transcript guard

* docs: document talk fast context runtime

* docs: document forced talk consult coordinator

* docs: document talk output activity tracker

* docs: document talk event metrics

* docs: document talk diagnostics

* docs: document talk observability hook

* docs: document talk provider resolver

* docs: document talk provider registry

* docs: document talk runtime primitives

* docs: document talk consult controller logs

* docs: document channel identity helpers

* docs: document channel account allowlist helpers

* docs: document channel metadata draft controls

* docs: document channel ingress policy

* docs: document channel sender access gates

* docs: document channel catalog message contracts

* docs: document channel account plugin helpers

* docs: document configured binding helpers

* docs: document channel acp approval config helpers

* docs: document channel bundled config write helpers

* docs: document channel plugin utility contracts

* docs: document channel config access helpers

* docs: document channel message action helpers

* docs: document channel outbound runtime helpers

* docs: document channel pairing promotion helpers

* docs: document channel registry helpers

* docs: document channel setup wizard helpers

* docs: document channel lifecycle status helpers

* docs: document channel target thread helpers

* docs: document channel session binding helpers

* docs: document channel package module probes

* docs: document channel setup wizard contracts

* docs: document channel plugin API barrels

* docs: document channel contract test helpers

* docs: document channel core helpers

* docs: document small core facades

* docs: document provider runtime helpers

* docs: document persistence and realtime helpers

* docs: document mcp and state helpers

* docs: document tool planner contracts

* docs: document music generation runtime

* docs: document crestodian command flow

* docs: document utility helpers

* docs: document node host helpers

* docs: document transcript contracts

* docs: document trajectory export contracts

* docs: document image generation contracts

* docs: document routing helper contracts

* docs: document session helper contracts

* docs: document video generation contracts

* docs: document model catalog contracts

* docs: document proxy capture contracts

* docs: document status rendering contracts

* docs: document test helper contracts

* docs: document wizard setup contracts

* docs: document process contracts

* docs: document memory host sdk contracts

* docs: document tts contracts

* docs: document secrets runtime contracts

* docs: document shared helper contracts

* docs: document hook runtime contracts

* docs: document security audit contracts

* docs: document flow contracts

* docs: document media understanding contracts

* docs: document tui contracts

* docs: document logging contracts

* docs: document llm contracts

* docs: document cron contracts

* docs: document daemon contracts

* docs: document task contracts

* docs: document acp contracts

* docs: document test utility contracts

* docs: document skill contracts

* docs: document config contracts

* docs: document outbound infra contracts

* docs: document command analysis contracts

* docs: document provider usage infra contracts

* docs: document file safety infra contracts

* docs: document exec approval infra contracts

* docs: document gateway runtime infra contracts

* docs: document infra utility contracts

* docs: document infra queue storage contracts

* docs: document heartbeat infra contracts

* docs: document remaining infra contracts

* docs: document gateway auth contracts

* docs: document gateway display helpers

* docs: document gateway http helpers

* docs: document gateway node helpers

* docs: document gateway mcp helpers

* docs: document gateway support helpers

* docs: document gateway server runtime helpers

* docs: document gateway runtime bootstrap helpers

* docs: document gateway session events

* docs: document gateway utility helpers

* docs: document gateway talk helpers

* docs: document gateway helper contracts

* docs: document gateway server method helpers

* docs: document gateway server auth helpers

* docs: document gateway server tests

* docs: document gateway test helpers

* docs: document gateway node tests

* docs: document gateway channel tests

* docs: document gateway session tests

* docs: document gateway server startup tests

* docs: document gateway tool test helpers

* docs: document gateway server test helpers

* docs: document gateway server method tests

* docs: document remaining gateway tests

* docs: document plugin sdk public subpaths

* docs: document plugin sdk runtime helpers

* docs: document plugin sdk memory provider helpers

* docs: document plugin sdk runtime facades

* docs: document plugin sdk command approval helpers

* docs: document plugin sdk runtime types

* docs: document plugin sdk browser account helpers

* docs: document plugin sdk media memory helpers

* docs: document plugin sdk core tests

* docs: document plugin sdk contract helpers

* docs: document plugin sdk test helpers

* docs: document remaining plugin sdk tests

* docs: document cli utility helpers

* docs: document cli runtime helpers

* docs: document cli command registration helpers

* docs: document node cli helpers

* docs: document cli program registration

* docs: document message cli registration

* docs: document daemon cli helpers

* docs: document cli route parsers
2026-06-03 15:20:39 -07:00

547 lines
18 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { normalizeNullableString as normalizeObservedValue } from "@openclaw/normalization-core/string-coerce";
import { normalizeUniqueStringEntries } from "@openclaw/normalization-core/string-normalization";
import { requireNodeSqlite } from "../infra/node-sqlite.js";
import { configureSqliteWalMaintenance, type SqliteWalMaintenance } from "../infra/sqlite-wal.js";
import { readCaptureBlobText, writeCaptureBlob } from "./blob-store.js";
import type {
CaptureBlobRecord,
CaptureEventRecord,
CaptureObservedDimension,
CaptureQueryPreset,
CaptureQueryRow,
CaptureSessionCoverageSummary,
CaptureSessionRecord,
CaptureSessionSummary,
} from "./types.js";
// SQLite-backed debug proxy store. Metadata stays in SQLite; large payloads are
// compressed into the blob directory and referenced by hash.
function ensureParentDir(filePath: string) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
}
type OpenedDatabase = {
db: DatabaseSync;
walMaintenance: SqliteWalMaintenance;
};
function openDatabase(dbPath: string): OpenedDatabase {
ensureParentDir(dbPath);
const { DatabaseSync } = requireNodeSqlite();
const db = new DatabaseSync(dbPath);
const walMaintenance = configureSqliteWalMaintenance(db);
db.exec("PRAGMA busy_timeout = 5000");
db.exec(`
CREATE TABLE IF NOT EXISTS capture_sessions (
id TEXT PRIMARY KEY,
started_at INTEGER NOT NULL,
ended_at INTEGER,
mode TEXT NOT NULL,
source_scope TEXT NOT NULL,
source_process TEXT NOT NULL,
proxy_url TEXT,
db_path TEXT NOT NULL,
blob_dir TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS capture_events (
id INTEGER PRIMARY KEY,
session_id TEXT NOT NULL,
ts INTEGER NOT NULL,
source_scope TEXT NOT NULL,
source_process TEXT NOT NULL,
protocol TEXT NOT NULL,
direction TEXT NOT NULL,
kind TEXT NOT NULL,
flow_id TEXT NOT NULL,
method TEXT,
host TEXT,
path TEXT,
status INTEGER,
close_code INTEGER,
content_type TEXT,
headers_json TEXT,
data_text TEXT,
data_blob_id TEXT,
data_sha256 TEXT,
error_text TEXT,
meta_json TEXT
);
CREATE INDEX IF NOT EXISTS capture_events_session_ts_idx ON capture_events(session_id, ts);
CREATE INDEX IF NOT EXISTS capture_events_flow_idx ON capture_events(flow_id, ts);
`);
return { db, walMaintenance };
}
function serializeJson(value: unknown): string | null {
return value == null ? null : JSON.stringify(value);
}
// Metadata is optional and user/tool supplied, so parse defensively for coverage
// summaries instead of assuming every event has valid JSON.
function parseMetaJson(metaJson: unknown): Record<string, unknown> | null {
if (typeof metaJson !== "string" || metaJson.trim().length === 0) {
return null;
}
try {
const parsed = JSON.parse(metaJson) as unknown;
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
} catch {
return null;
}
}
function sortObservedCounts(counts: Map<string, number>): CaptureObservedDimension[] {
return [...counts.entries()]
.map(([value, count]) => ({ value, count }))
.toSorted((left, right) => right.count - left.count || left.value.localeCompare(right.value));
}
export class DebugProxyCaptureStore {
readonly db: DatabaseSync;
private readonly walMaintenance: SqliteWalMaintenance;
private closed = false;
constructor(
readonly dbPath: string,
readonly blobDir: string,
) {
const opened = openDatabase(dbPath);
this.db = opened.db;
this.walMaintenance = opened.walMaintenance;
}
close(): void {
if (this.closed) {
return;
}
this.walMaintenance.close();
this.db.close();
this.closed = true;
}
get isClosed(): boolean {
return this.closed;
}
upsertSession(session: CaptureSessionRecord): void {
this.db
.prepare(
`INSERT INTO capture_sessions (
id, started_at, ended_at, mode, source_scope, source_process, proxy_url, db_path, blob_dir
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
ended_at=excluded.ended_at,
proxy_url=excluded.proxy_url,
source_process=excluded.source_process`,
)
.run(
session.id,
session.startedAt,
session.endedAt ?? null,
session.mode,
session.sourceScope,
session.sourceProcess,
session.proxyUrl ?? null,
session.dbPath,
session.blobDir,
);
}
endSession(sessionId: string, endedAt = Date.now()): void {
this.db
.prepare(`UPDATE capture_sessions SET ended_at = ? WHERE id = ?`)
.run(endedAt, sessionId);
}
persistPayload(data: Buffer, contentType?: string): CaptureBlobRecord {
return writeCaptureBlob({ blobDir: this.blobDir, data, contentType });
}
recordEvent(event: CaptureEventRecord): void {
this.db
.prepare(
`INSERT INTO capture_events (
session_id, ts, source_scope, source_process, protocol, direction, kind, flow_id,
method, host, path, status, close_code, content_type, headers_json,
data_text, data_blob_id, data_sha256, error_text, meta_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
event.sessionId,
event.ts,
event.sourceScope,
event.sourceProcess,
event.protocol,
event.direction,
event.kind,
event.flowId,
event.method ?? null,
event.host ?? null,
event.path ?? null,
event.status ?? null,
event.closeCode ?? null,
event.contentType ?? null,
event.headersJson ?? null,
event.dataText ?? null,
event.dataBlobId ?? null,
event.dataSha256 ?? null,
event.errorText ?? null,
event.metaJson ?? null,
);
}
listSessions(limit = 50): CaptureSessionSummary[] {
return this.db
.prepare(
`SELECT
s.id,
s.started_at AS startedAt,
s.ended_at AS endedAt,
s.mode,
s.source_process AS sourceProcess,
s.proxy_url AS proxyUrl,
COUNT(e.id) AS eventCount
FROM capture_sessions s
LEFT JOIN capture_events e ON e.session_id = s.id
GROUP BY s.id
ORDER BY s.started_at DESC
LIMIT ?`,
)
.all(limit) as CaptureSessionSummary[];
}
getSessionEvents(sessionId: string, limit = 500): Array<Record<string, unknown>> {
return this.db
.prepare(
`SELECT
id, session_id AS sessionId, ts, source_scope AS sourceScope, source_process AS sourceProcess,
protocol, direction, kind, flow_id AS flowId, method, host, path, status, close_code AS closeCode,
content_type AS contentType, headers_json AS headersJson, data_text AS dataText,
data_blob_id AS dataBlobId, data_sha256 AS dataSha256, error_text AS errorText, meta_json AS metaJson
FROM capture_events
WHERE session_id = ?
ORDER BY ts DESC, id DESC
LIMIT ?`,
)
.all(sessionId, limit) as Array<Record<string, unknown>>;
}
summarizeSessionCoverage(sessionId: string): CaptureSessionCoverageSummary {
const rows = this.db
.prepare(
`SELECT host, meta_json AS metaJson
FROM capture_events
WHERE session_id = ?`,
)
.all(sessionId) as Array<{ host?: string | null; metaJson?: string | null }>;
const providers = new Map<string, number>();
const apis = new Map<string, number>();
const models = new Map<string, number>();
const hosts = new Map<string, number>();
const localPeers = new Map<string, number>();
let unlabeledEventCount = 0;
for (const row of rows) {
const meta = parseMetaJson(row.metaJson);
const provider = normalizeObservedValue(meta?.provider);
const api = normalizeObservedValue(meta?.api);
const model = normalizeObservedValue(meta?.model);
const host = normalizeObservedValue(row.host);
if (!provider && !api && !model) {
unlabeledEventCount += 1;
}
if (provider) {
providers.set(provider, (providers.get(provider) ?? 0) + 1);
}
if (api) {
apis.set(api, (apis.get(api) ?? 0) + 1);
}
if (model) {
models.set(model, (models.get(model) ?? 0) + 1);
}
if (host) {
hosts.set(host, (hosts.get(host) ?? 0) + 1);
// Local model/provider endpoints are useful to surface separately when
// debugging why cloud-provider labels are absent.
if (
host === "127.0.0.1:11434" ||
host.startsWith("127.0.0.1:") ||
host.startsWith("localhost:")
) {
localPeers.set(host, (localPeers.get(host) ?? 0) + 1);
}
}
}
return {
sessionId,
totalEvents: rows.length,
unlabeledEventCount,
providers: sortObservedCounts(providers),
apis: sortObservedCounts(apis),
models: sortObservedCounts(models),
hosts: sortObservedCounts(hosts),
localPeers: sortObservedCounts(localPeers),
};
}
readBlob(blobId: string): string | null {
const row = this.db
.prepare(`SELECT data_blob_id AS blobId FROM capture_events WHERE data_blob_id = ? LIMIT 1`)
.get(blobId) as { blobId?: string } | undefined;
if (!row?.blobId) {
return null;
}
const blobPath = path.join(this.blobDir, `${row.blobId}.bin.gz`);
return fs.existsSync(blobPath) ? readCaptureBlobText(blobPath) : null;
}
queryPreset(preset: CaptureQueryPreset, sessionId?: string): CaptureQueryRow[] {
const sessionWhere = sessionId ? "AND session_id = ?" : "";
const args = sessionId ? [sessionId] : [];
switch (preset) {
// Presets are intentionally SQL-only summaries so the CLI can query large
// capture sessions without loading every event into memory.
case "double-sends":
return this.db
.prepare(
`SELECT host, path, method, COUNT(*) AS duplicateCount
FROM capture_events
WHERE kind = 'request' ${sessionWhere}
GROUP BY host, path, method, data_sha256
HAVING COUNT(*) > 1
ORDER BY duplicateCount DESC, host ASC`,
)
.all(...args) as CaptureQueryRow[];
case "retry-storms":
return this.db
.prepare(
`SELECT host, path, COUNT(*) AS errorCount
FROM capture_events
WHERE kind = 'response' AND status >= 429 ${sessionWhere}
GROUP BY host, path
HAVING COUNT(*) > 1
ORDER BY errorCount DESC, host ASC`,
)
.all(...args) as CaptureQueryRow[];
case "cache-busting":
return this.db
.prepare(
`SELECT host, path, COUNT(*) AS variantCount
FROM capture_events
WHERE kind = 'request'
AND (path LIKE '%?%' OR headers_json LIKE '%cache-control%' OR headers_json LIKE '%pragma%')
${sessionWhere}
GROUP BY host, path
ORDER BY variantCount DESC, host ASC`,
)
.all(...args) as CaptureQueryRow[];
case "ws-duplicate-frames":
return this.db
.prepare(
`SELECT host, path, COUNT(*) AS duplicateFrames
FROM capture_events
WHERE kind = 'ws-frame' AND direction = 'outbound' ${sessionWhere}
GROUP BY host, path, data_sha256
HAVING COUNT(*) > 1
ORDER BY duplicateFrames DESC, host ASC`,
)
.all(...args) as CaptureQueryRow[];
case "missing-ack":
return this.db
.prepare(
`SELECT flow_id AS flowId, host, path, COUNT(*) AS outboundFrames
FROM capture_events
WHERE kind = 'ws-frame' AND direction = 'outbound' ${sessionWhere}
AND flow_id NOT IN (
SELECT flow_id FROM capture_events
WHERE kind = 'ws-frame' AND direction = 'inbound' ${sessionId ? "AND session_id = ?" : ""}
)
GROUP BY flow_id, host, path
ORDER BY outboundFrames DESC`,
)
.all(...(sessionId ? [sessionId, sessionId] : [])) as CaptureQueryRow[];
case "error-bursts":
return this.db
.prepare(
`SELECT host, path, COUNT(*) AS errorCount
FROM capture_events
WHERE kind = 'error' ${sessionWhere}
GROUP BY host, path
ORDER BY errorCount DESC, host ASC`,
)
.all(...args) as CaptureQueryRow[];
default:
return [];
}
}
purgeAll(): { sessions: number; events: number; blobs: number } {
const sessionCount =
(this.db.prepare(`SELECT COUNT(*) AS count FROM capture_sessions`).get() as { count: number })
.count ?? 0;
const eventCount =
(this.db.prepare(`SELECT COUNT(*) AS count FROM capture_events`).get() as { count: number })
.count ?? 0;
this.db.exec(`DELETE FROM capture_events; DELETE FROM capture_sessions;`);
let blobs = 0;
if (fs.existsSync(this.blobDir)) {
for (const entry of fs.readdirSync(this.blobDir)) {
fs.rmSync(path.join(this.blobDir, entry), { force: true });
blobs += 1;
}
}
return { sessions: sessionCount, events: eventCount, blobs };
}
deleteSessions(sessionIds: string[]): { sessions: number; events: number; blobs: number } {
const uniqueSessionIds = normalizeUniqueStringEntries(sessionIds);
if (uniqueSessionIds.length === 0) {
return { sessions: 0, events: 0, blobs: 0 };
}
const placeholders = uniqueSessionIds.map(() => "?").join(", ");
const blobRows = this.db
.prepare(
`SELECT DISTINCT data_blob_id AS blobId
FROM capture_events
WHERE session_id IN (${placeholders})
AND data_blob_id IS NOT NULL`,
)
.all(...uniqueSessionIds) as Array<{ blobId?: string | null }>;
const eventCount =
(
this.db
.prepare(
`SELECT COUNT(*) AS count
FROM capture_events
WHERE session_id IN (${placeholders})`,
)
.get(...uniqueSessionIds) as { count: number }
).count ?? 0;
const sessionCount =
(
this.db
.prepare(
`SELECT COUNT(*) AS count
FROM capture_sessions
WHERE id IN (${placeholders})`,
)
.get(...uniqueSessionIds) as { count: number }
).count ?? 0;
this.db
.prepare(`DELETE FROM capture_events WHERE session_id IN (${placeholders})`)
.run(...uniqueSessionIds);
this.db
.prepare(`DELETE FROM capture_sessions WHERE id IN (${placeholders})`)
.run(...uniqueSessionIds);
const candidateBlobIds = blobRows
.map((row) => row.blobId?.trim())
.filter((blobId): blobId is string => Boolean(blobId));
const remainingBlobRefs =
// Shared blobs are deleted only when no surviving event references them.
candidateBlobIds.length > 0
? new Set(
(
this.db
.prepare(
`SELECT DISTINCT data_blob_id AS blobId
FROM capture_events
WHERE data_blob_id IN (${candidateBlobIds.map(() => "?").join(", ")})
AND data_blob_id IS NOT NULL`,
)
.all(...candidateBlobIds) as Array<{ blobId?: string | null }>
)
.map((row) => row.blobId?.trim())
.filter((blobId): blobId is string => Boolean(blobId)),
)
: new Set<string>();
let blobs = 0;
for (const row of blobRows) {
const blobId = row.blobId?.trim();
if (!blobId || remainingBlobRefs.has(blobId)) {
continue;
}
const blobPath = path.join(this.blobDir, `${blobId}.bin.gz`);
if (fs.existsSync(blobPath)) {
fs.rmSync(blobPath, { force: true });
blobs += 1;
}
}
return { sessions: sessionCount, events: eventCount, blobs };
}
}
let cachedStore: DebugProxyCaptureStore | null = null;
let cachedKey = "";
let cachedStoreLeases = 0;
export function getDebugProxyCaptureStore(dbPath: string, blobDir: string): DebugProxyCaptureStore {
const key = `${dbPath}:${blobDir}`;
if (!cachedStore || cachedStore.isClosed || cachedKey !== key) {
cachedStore = new DebugProxyCaptureStore(dbPath, blobDir);
cachedKey = key;
cachedStoreLeases = 0;
}
return cachedStore;
}
export function closeDebugProxyCaptureStore(): void {
if (!cachedStore) {
return;
}
cachedStore.close();
cachedStore = null;
cachedKey = "";
cachedStoreLeases = 0;
}
// Lease API keeps one cached synchronous SQLite connection alive across related
// capture operations, then closes it when the last owner releases.
export function acquireDebugProxyCaptureStore(
dbPath: string,
blobDir: string,
): { store: DebugProxyCaptureStore; release: () => void } {
const store = getDebugProxyCaptureStore(dbPath, blobDir);
const key = cachedKey;
cachedStoreLeases += 1;
let released = false;
return {
store,
release: () => {
if (released) {
return;
}
released = true;
cachedStoreLeases = Math.max(0, cachedStoreLeases - 1);
if (cachedStoreLeases === 0 && cachedStore === store && cachedKey === key) {
closeDebugProxyCaptureStore();
}
},
};
}
export function persistEventPayload(
store: DebugProxyCaptureStore,
params: { data?: Buffer | string | null; contentType?: string; previewLimit?: number },
): { dataText?: string; dataBlobId?: string; dataSha256?: string } {
if (params.data == null) {
return {};
}
const buffer = Buffer.isBuffer(params.data) ? params.data : Buffer.from(params.data);
const previewLimit = params.previewLimit ?? 8192;
// Store the whole payload as a blob but keep a small UTF-8 preview inline for
// fast CLI listings and query output.
const blob = store.persistPayload(buffer, params.contentType);
return {
dataText: buffer.subarray(0, previewLimit).toString("utf8"),
dataBlobId: blob.blobId,
dataSha256: blob.sha256,
};
}
export function safeJsonString(value: unknown): string | undefined {
const raw = serializeJson(value);
return raw ?? undefined;
}