blob: 42c678be016e983bd7af6e23375fa9aee1294a90 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// opencode sessions as Maka Sessions.
//
// State lives in one SQLite database, `~/.local/share/opencode/opencode.db`,
// which the CLI itself will name (`opencode db path`). Earlier releases wrote
// a `storage/session/{info,message,part}` JSON tree; that layout is gone, so
// this reads the database and nothing else. Verified against 1.18.21.
//
// A conversation is three tables. `session` holds identity and `directory`,
// which is the cwd a project-scoped query reads. `message` and `part` each
// keep their payload in an opaque `data` JSON column — the schema names the
// container, the column names the shape.
//
// Turn state is derivable here, unlike a Claude Code transcript: every
// assistant message records `time.completed`, and `finish` is `stop` on a
// closing step, `tool-calls` on an intermediate one, and absent on a message
// that was aborted, which also carries `error.name`.
import { realpath, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, join, sep } from 'node:path';
import {
ExternalSessionLimitError,
ExternalSessionNotFoundError,
externalSessionMatchesQuery,
sanitizeExternalSessionTitle,
} from '@maka/core/external-session';
import type {
ExternalMakaSession,
ExternalSessionAdapter,
ExternalSessionCatalogPage,
ExternalSessionCatalogPageQuery,
ExternalSessionQuery,
ExternalSessionSummary,
} from '@maka/core/external-session';
import type { StoredMessage } from '@maka/core/session';
import { listOffsetExternalSessionCatalogPage } from './offset-external-session-catalog.js';
export const OPENCODE_SESSION_ADAPTER_ID = 'opencode';
/**
* What one OpenCode transcript may cost before the import is refused.
*
* Rows are `message` rows plus `part` rows, counted the way the preflight
* counts them, and bytes are the id, foreign-key and `data` columns it sums.
* These bounds protect the source side of the import: bytes cap encoded SQLite
* payload and rows cap the number of decoded source objects. They are not a
* process-RSS promise; JSON parsing depends on payload shape. The converter has
* its own `OPENCODE_TRANSCRIPT_MAX_CONVERTED_BYTES` limit for retained Maka
* messages, so source and output memory are never conflated.
*/
export const OPENCODE_TRANSCRIPT_MAX_RAW_BYTES = 64 * 1024 * 1024;
export const OPENCODE_TRANSCRIPT_MAX_ROWS = 250_000;
export const OPENCODE_TRANSCRIPT_MAX_CONVERTED_BYTES = 256 * 1024 * 1024;
const EXTERNAL_SNAPSHOT_ABORT_SOURCE = 'external_session_snapshot';
/**
* Guards a source field before it reaches JavaScript.
*
* These are memory bounds, not display limits. Display length is enforced by
* `sanitizeExternalSessionTitle` and the wire protocol; these larger bounds
* only prevent malformed source fields from being loaded whole.
*/
const OPENCODE_CATALOG_ID_MAX_BYTES = 512;
const OPENCODE_CATALOG_TITLE_MAX_BYTES = 64 * 1024;
const OPENCODE_CATALOG_CWD_MAX_BYTES = 4 * 1024;
/** Guards the value interpolated into no SQL, but read back out of one. */
const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/u;
export interface OpenCodeSessionAdapterOptions {
/** Overrides `~/.local/share/opencode`. */
opencodeHome?: string;
/** Maximum aggregate bytes across source rows loaded for one import. */
maxRawBytes?: number;
/** Maximum message + part rows loaded for one import. */
maxRows?: number;
/** Maximum serialized bytes retained across converted Maka messages. */
maxConvertedBytes?: number;
}
interface SessionRow {
readonly id: string;
readonly title: string;
readonly directory: string;
readonly timeCreated?: number;
readonly timeUpdated?: number;
readonly archived: boolean;
readonly parentId?: string;
}
interface MessageRow {
readonly id: string;
readonly timeCreated: number;
readonly data: Record<string, unknown>;
}
interface PartRow {
readonly messageId: string;
readonly data: Record<string, unknown>;
}
export class OpenCodeSessionAdapter implements ExternalSessionAdapter {
readonly id = OPENCODE_SESSION_ADAPTER_ID;
readonly #home: string;
readonly #maxRawBytes: number;
readonly #maxRows: number;
readonly #maxConvertedBytes: number;
constructor(options: OpenCodeSessionAdapterOptions = {}) {
this.#home = options.opencodeHome ?? join(homedir(), '.local', 'share', 'opencode');
this.#maxRawBytes = options.maxRawBytes ?? OPENCODE_TRANSCRIPT_MAX_RAW_BYTES;
this.#maxRows = options.maxRows ?? OPENCODE_TRANSCRIPT_MAX_ROWS;
this.#maxConvertedBytes = options.maxConvertedBytes ?? OPENCODE_TRANSCRIPT_MAX_CONVERTED_BYTES;
assertPositiveSafeInteger(this.#maxRawBytes, 'OpenCode transcript byte limit');
assertPositiveSafeInteger(this.#maxRows, 'OpenCode transcript row limit');
assertPositiveSafeInteger(this.#maxConvertedBytes, 'OpenCode converted message byte limit');
}
async detect(): Promise<boolean> {
return (await this.#resolvedDatabasePath()) !== undefined;
}
async listSessions(query: ExternalSessionQuery = {}): Promise<readonly ExternalSessionSummary[]> {
return this.#readSessionPage(query);
}
async listSessionPage(
query: ExternalSessionCatalogPageQuery,
): Promise<ExternalSessionCatalogPage> {
return listOffsetExternalSessionCatalogPage(query, (pageQuery) => this.listSessions(pageQuery));
}
async readSession(sessionId: string): Promise<ExternalMakaSession> {
if (!SESSION_ID_PATTERN.test(sessionId)) {
throw new Error(`opencode session id is not usable: ${sessionId}`);
}
return this.#withDatabase((db) =>
withReadSnapshot(db, () => {
requireTranscriptSchema(db);
preflightSessionMetadata(db, sessionId);
const rawSession = db
.prepare(
'SELECT id, title, directory, time_created, time_updated, time_archived, parent_id FROM session WHERE id = ?',
)
.get(sessionId);
const row = toSessionRow(rawSession);
if (!row) throw new ExternalSessionNotFoundError();
if (row.parentId !== undefined) {
throw new Error(`opencode session is a child of another session: ${sessionId}`);
}
preflightTranscript(db, sessionId, this.#maxRawBytes, this.#maxRows);
const { messages, parts } = readTranscript(db, sessionId);
return {
sourceSessionId: sessionId,
metadata: {
name: sanitizeExternalSessionTitle(row.title) || sessionId,
cwd: row.directory,
},
messages: convertTranscript(sessionId, messages, parts, this.#maxConvertedBytes),
};
}),
);
}
#databasePath(): string {
return join(this.#home, 'opencode.db');
}
/**
* Opens the database and runs one read.
*
* Failure is reported, not flattened into an empty result. An unreadable
* database and an opencode install with no sessions are different facts, and
* a caller that cannot tell them apart reports the wrong one: "no sessions
* here" for a database that is locked, corrupt, or written by a version
* whose tables this does not recognise.
*/
async #resolvedDatabasePath(): Promise<string | undefined> {
try {
const root = await realpath(this.#home);
const path = await realpath(this.#databasePath());
if (path !== root && !path.startsWith(root + sep)) return undefined;
if (basename(path) !== 'opencode.db' || !(await stat(path)).isFile()) return undefined;
return path;
} catch {
return undefined;
}
}
async #withDatabase<T>(read: (db: OpenCodeDatabase) => T): Promise<T> {
const path = await this.#resolvedDatabasePath();
if (!path) throw new Error('opencode database is unavailable');
let sqlite: typeof import('node:sqlite');
try {
sqlite = await import('node:sqlite');
} catch (cause) {
throw new Error('opencode sessions need node:sqlite, which is unavailable', { cause });
}
let db: OpenCodeDatabase;
try {
db = new sqlite.DatabaseSync(path, { readOnly: true }) as OpenCodeDatabase;
} catch (cause) {
throw new Error(`opencode database could not be opened: ${path}`, { cause });
}
try {
return read(db);
} catch (cause) {
if (
cause instanceof ExternalSessionLimitError ||
(cause instanceof Error && cause.message.startsWith('opencode session '))
) {
throw cause;
}
throw new Error(`opencode database could not be read: ${path}`, { cause });
} finally {
try {
db.close();
} catch {
// A close that fails leaves nothing for a reader to do; the process
// releases the handle either way, and throwing here would replace a
// usable result with an error about cleanup.
}
}
}
async #readSessionPage(query: ExternalSessionQuery): Promise<readonly ExternalSessionSummary[]> {
// Discovery is allowed to come up empty — the catalog lists whatever
// sources are present, and an opencode that was installed but never used
// is a normal state rather than a failure to report.
if (!(await this.#resolvedDatabasePath())) return [];
return await this.#withDatabase((db) => {
const columns = tableColumns(db, 'session');
if (!columns.has('id') || !columns.has('directory') || !columns.has('parent_id')) {
throw new Error('opencode `session` table does not carry required columns');
}
const selected = [
'id',
'title',
'directory',
'time_created',
'time_updated',
'time_archived',
'parent_id',
].filter((column) => columns.has(column));
const bounds = [
"(parent_id IS NULL OR parent_id = '')",
...(!query.includeArchived && columns.has('time_archived')
? ['time_archived IS NULL']
: []),
`length(CAST(id AS BLOB)) <= ${OPENCODE_CATALOG_ID_MAX_BYTES}`,
`length(CAST(coalesce(directory, '') AS BLOB)) <= ${OPENCODE_CATALOG_CWD_MAX_BYTES}`,
...(columns.has('title')
? [`length(CAST(coalesce(title, '') AS BLOB)) <= ${OPENCODE_CATALOG_TITLE_MAX_BYTES}`]
: []),
];
const order = columns.has('time_updated')
? columns.has('time_created')
? 'coalesce(time_updated, time_created, 0) DESC, id DESC'
: 'coalesce(time_updated, 0) DESC, id DESC'
: columns.has('time_created')
? 'coalesce(time_created, 0) DESC, id DESC'
: 'id DESC';
const requestedOffset = query.offset ?? 0;
const requestedLimit = query.limit ?? Number.MAX_SAFE_INTEGER;
if (
!Number.isSafeInteger(requestedOffset) ||
requestedOffset < 0 ||
!Number.isSafeInteger(requestedLimit) ||
requestedLimit < 0
) {
throw new Error('Invalid OpenCode catalog page');
}
if (requestedLimit === 0) return [];
const batchSize = Math.max(32, Math.min(256, requestedLimit * 2));
const statement = db.prepare(
`SELECT ${selected.join(', ')} FROM session WHERE ${bounds.join(' AND ')} ORDER BY ${order} LIMIT ? OFFSET ?`,
);
const summaries: ExternalSessionSummary[] = [];
let matched = 0;
let rawOffset = 0;
while (summaries.length < requestedLimit) {
const raw = statement.all(batchSize, rawOffset);
for (const value of raw) {
const row = toSessionRow(value);
if (!row) continue;
const summary = toSummary(row);
if (!externalSessionMatchesQuery(summary, query)) continue;
if (matched++ < requestedOffset) continue;
summaries.push(summary);
if (summaries.length === requestedLimit) break;
}
rawOffset += raw.length;
if (raw.length < batchSize) break;
}
return summaries;
});
}
}
interface OpenCodeDatabase {
prepare(sql: string): {
all(...params: unknown[]): unknown[];
get(...params: unknown[]): unknown;
};
exec(sql: string): void;
close(): void;
}
function withReadSnapshot<T>(db: OpenCodeDatabase, read: () => T): T {
db.exec('BEGIN DEFERRED');
try {
const result = read();
db.exec('COMMIT');
return result;
} catch (error) {
try {
db.exec('ROLLBACK');
} catch {
// Preserve the read failure. Closing the read-only connection releases
// any transaction SQLite could not roll back explicitly.
}
throw error;
}
}
function requireTranscriptSchema(db: OpenCodeDatabase): void {
const required = {
session: ['id', 'title', 'directory', 'parent_id'],
message: ['id', 'session_id', 'time_created', 'data'],
part: ['id', 'message_id', 'session_id', 'time_created', 'data'],
} as const;
for (const [table, columns] of Object.entries(required)) {
const actual = tableColumns(db, table);
if (columns.some((column) => !actual.has(column))) {
throw new Error(`opencode \`${table}\` table does not carry required columns`);
}
}
}
function preflightSessionMetadata(db: OpenCodeDatabase, sessionId: string): void {
const sizes = asRecord(
db
.prepare(
`SELECT length(CAST(id AS BLOB)) AS id_bytes,
length(CAST(coalesce(title, '') AS BLOB)) AS title_bytes,
length(CAST(directory AS BLOB)) AS cwd_bytes
FROM session WHERE id = ?`,
)
.get(sessionId),
);
if (!sizes) throw new ExternalSessionNotFoundError();
for (const [field, max] of [
['id_bytes', OPENCODE_CATALOG_ID_MAX_BYTES],
['title_bytes', OPENCODE_CATALOG_TITLE_MAX_BYTES],
['cwd_bytes', OPENCODE_CATALOG_CWD_MAX_BYTES],
] as const) {
if ((numberOf(sizes[field]) ?? 0) > max) {
throw new ExternalSessionLimitError(
'record_bytes',
max,
`OpenCode Session metadata exceeds ${max} bytes`,
);
}
}
}
function preflightTranscript(
db: OpenCodeDatabase,
sessionId: string,
maxRawBytes: number,
maxRows: number,
): void {
const stats = asRecord(
db
.prepare(
`SELECT count(*) AS rows, coalesce(sum(raw_bytes), 0) AS raw_bytes, coalesce(max(data_bytes), 0) AS max_data_bytes
FROM (
SELECT length(CAST(id AS BLOB)) + length(CAST(data AS BLOB)) AS raw_bytes,
length(CAST(data AS BLOB)) AS data_bytes
FROM message WHERE session_id = ?
UNION ALL
SELECT length(CAST(id AS BLOB)) + length(CAST(message_id AS BLOB)) + length(CAST(data AS BLOB)) AS raw_bytes,
length(CAST(data AS BLOB)) AS data_bytes
FROM part WHERE session_id = ?
LIMIT ?
)`,
)
.get(sessionId, sessionId, maxRows + 1),
);
const rows = numberOf(stats?.rows) ?? 0;
const rawBytes = numberOf(stats?.raw_bytes) ?? 0;
const maxDataBytes = numberOf(stats?.max_data_bytes) ?? 0;
if (rows > maxRows) {
throw new ExternalSessionLimitError(
'records',
maxRows,
`OpenCode transcript exceeds ${maxRows} source rows`,
);
}
if (maxDataBytes > maxRawBytes) {
throw new ExternalSessionLimitError(
'record_bytes',
maxRawBytes,
`OpenCode transcript contains a source record larger than ${maxRawBytes} bytes`,
);
}
if (rawBytes > maxRawBytes) {
throw new ExternalSessionLimitError(
'transcript_bytes',
maxRawBytes,
`OpenCode transcript exceeds ${maxRawBytes} source bytes`,
);
}
}
function readTranscript(
db: OpenCodeDatabase,
sessionId: string,
): { messages: readonly MessageRow[]; parts: readonly PartRow[] } {
const messages = db
.prepare(
'SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created, id',
)
.all(sessionId)
.map((row, index) => requireRow(toMessageRow(row), 'message', index));
const parts = db
.prepare('SELECT message_id, data FROM part WHERE session_id = ? ORDER BY time_created, id')
.all(sessionId)
.map((row, index) => requireRow(toPartRow(row), 'part', index));
return { messages, parts };
}
function tableColumns(db: OpenCodeDatabase, table: string): Set<string> {
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as { name?: unknown }[];
return new Set(
rows.map((column) => (typeof column.name === 'string' ? column.name : '')).filter(Boolean),
);
}
function toSummary(row: SessionRow): ExternalSessionSummary {
const updatedAt = row.timeUpdated ?? row.timeCreated;
return {
id: row.id,
name: sanitizeExternalSessionTitle(row.title) || row.id,
cwd: row.directory,
...(row.timeCreated !== undefined ? { createdAt: row.timeCreated } : {}),
...(updatedAt !== undefined ? { updatedAt } : {}),
...(row.archived ? { archived: true } : {}),
};
}
/**
* Turns one opencode conversation into Maka messages.
*
* Exported for the fixture tests, which exercise the mapping without a
* database: the conversion is where the source format is interpreted, and it
* is the part worth pinning.
*/
export function convertTranscript(
sessionId: string,
messages: readonly MessageRow[],
parts: readonly PartRow[],
maxConvertedBytes = OPENCODE_TRANSCRIPT_MAX_CONVERTED_BYTES,
): readonly StoredMessage[] {
const partsByMessage = new Map<string, Record<string, unknown>[]>();
for (const part of parts) {
const existing = partsByMessage.get(part.messageId);
if (existing) existing.push(part.data);
else partsByMessage.set(part.messageId, [part.data]);
}
const ordered = [...messages].sort((left, right) =>
left.timeCreated === right.timeCreated
? left.id.localeCompare(right.id)
: left.timeCreated - right.timeCreated,
);
const out: StoredMessage[] = [];
let convertedBytes = 0;
const append = (message: StoredMessage): void => {
const encodedBytes = Buffer.byteLength(JSON.stringify(message), 'utf8');
if (encodedBytes > maxConvertedBytes - convertedBytes) {
throw new ExternalSessionLimitError(
'converted_bytes',
maxConvertedBytes,
`OpenCode transcript converts to more than ${maxConvertedBytes} bytes`,
);
}
convertedBytes += encodedBytes;
out.push(message);
};
let sequence = 0;
const id = (kind: string): string => `opencode:${sessionId}:${kind}:${sequence++}`;
let turnSequence = 0;
interface Turn {
turnId: string;
lastTs: number;
aborted: boolean;
closed: boolean;
errorName?: string;
}
let turn: Turn | undefined;
const closeTurn = (): void => {
if (!turn) return;
if (turn.errorName !== undefined && !turn.aborted) {
append({
type: 'turn_state',
id: id('turn-state'),
turnId: turn.turnId,
ts: turn.lastTs,
status: 'failed',
errorClass: 'opencode_error',
});
} else if (turn.aborted) {
append({
type: 'turn_state',
id: id('turn-state'),
turnId: turn.turnId,
ts: turn.lastTs,
status: 'aborted',
abortedAt: turn.lastTs,
abortSource: EXTERNAL_SNAPSHOT_ABORT_SOURCE,
});
} else if (turn.closed) {
append({
type: 'turn_state',
id: id('turn-state'),
turnId: turn.turnId,
ts: turn.lastTs,
status: 'completed',
});
} else {
// A turn whose last assistant step asked for tools and never came back:
// the run stopped between a call and its answer. Recording it as
// completed would assert a reply the session never produced.
append({
type: 'turn_state',
id: id('turn-state'),
turnId: turn.turnId,
ts: turn.lastTs,
status: 'aborted',
abortedAt: turn.lastTs,
abortSource: EXTERNAL_SNAPSHOT_ABORT_SOURCE,
});
}
turn = undefined;
};
for (const message of ordered) {
const data = message.data;
const role = stringOf(data.role);
const ts =
numberOf((data.time as Record<string, unknown> | undefined)?.created) ?? message.timeCreated;
const messageParts = partsByMessage.get(message.id) ?? [];
if (role === 'user') {
// A user message closes whatever came before it either way: it is the
// boundary, whether or not it carries a prompt this import can use.
closeTurn();
const text = messageParts
.filter((part) => stringOf(part.type) === 'text' && part.synthetic !== true)
.map((part) => stringOf(part.text) ?? '')
.filter((part) => part.length > 0)
.join('\n\n');
// No text part means no prompt to import. Opening a turn for it would
// produce a `turn_state` describing a turn that holds no messages —
// a terminal verdict on a conversation that is not there. An assistant
// message that follows opens its own turn.
if (text.length === 0) continue;
turn = {
turnId: `opencode:${sessionId}:turn:${turnSequence++}`,
lastTs: ts,
aborted: false,
closed: false,
};
append({ type: 'user', id: id('user'), turnId: turn.turnId, ts, text });
continue;
}
if (role !== 'assistant') continue;
if (!turn) {
// An assistant message with no preceding user message: a resumed
// session whose opening prompt is not in this transcript. Give it a turn
// rather than dropping the content.
turn = {
turnId: `opencode:${sessionId}:turn:${turnSequence++}`,
lastTs: ts,
aborted: false,
closed: false,
};
}
turn.lastTs = Math.max(turn.lastTs, ts);
const errorName = stringOf((data.error as Record<string, unknown> | undefined)?.name);
if (errorName !== undefined) {
if (errorName === 'MessageAbortedError') turn.aborted = true;
else turn.errorName = errorName;
}
const finish = stringOf(data.finish);
// `stop` is the only finish that closes a turn. `tool-calls` means the
// step handed off to a tool and another assistant message follows.
if (finish === 'stop') turn.closed = true;
else if (finish !== undefined) turn.closed = false;
const modelId = stringOf(data.modelID) ?? 'opencode';
// Parts are walked in the order the session recorded them rather than
// bucketed by type. opencode accepts `text` before `reasoning`, and its
// own replay keeps that order; emitting all reasoning first would move a
// model's thinking across text it actually wrote after.
for (const part of messageParts) {
const kind = stringOf(part.type);
if (kind === 'reasoning') {
const thinking = stringOf(part.text);
if (thinking === undefined) continue;
append({
type: 'assistant',
id: id('thinking'),
turnId: turn.turnId,
ts,
text: '',
thinking: { text: thinking },
contentOrder: ['thinking'],
modelId,
});
continue;
}
if (kind === 'text') {
const text = stringOf(part.text);
if (text === undefined) continue;
append({
type: 'assistant',
id: id('assistant'),
turnId: turn.turnId,
ts,
text,
contentOrder: ['text'],
modelId,
});
continue;
}
if (kind !== 'tool') continue;
const callId = stringOf(part.callID);
// A call with no id cannot be paired with its result. Minting one
// produces a row guaranteed not to match anything, which reads as a
// detached call rather than an absent one.
if (callId === undefined) continue;
const state = asRecord(part.state);
const status = stringOf(state?.status);
append({
type: 'tool_call',
id: callId,
turnId: turn.turnId,
ts,
toolName: stringOf(part.tool) ?? 'unknown',
args: asRecord(state?.input) ?? {},
});
// `completed` and `error` are both terminal: opencode records a failed
// call as `{ status: 'error', error: <message> }` and replays it as an
// errored output. Dropping the failure would leave a call with no
// answer inside a turn a later `finish: "stop"` marks completed — a
// transcript asserting the tool never replied when it replied with a
// failure.
//
// `pending` and `running` are the calls that genuinely had no answer
// when the session was written, and they get no result.
if (status === 'completed') {
append({
type: 'tool_result',
id: id('tool-result'),
turnId: turn.turnId,
ts,
toolUseId: callId,
isError: false,
content: { kind: 'text', text: stringOf(state?.output) ?? '' },
});
continue;
}
if (status === 'error') {
append({
type: 'tool_result',
id: id('tool-result'),
turnId: turn.turnId,
ts,
toolUseId: callId,
isError: true,
content: { kind: 'text', text: stringOf(state?.error) ?? 'opencode tool call failed' },
});
}
}
}
closeTurn();
return out;
}
function requireRow<T>(row: T | undefined, table: string, index: number): T {
if (row === undefined) {
throw new Error(`opencode \`${table}\` row ${index} could not be decoded`);
}
return row;
}
function toSessionRow(value: unknown): SessionRow | undefined {
const row = asRecord(value);
const id = stringOf(row?.id);
if (id === undefined) return undefined;
const directory = stringOf(row?.directory) ?? '';
const parentId = readParentId(row?.parent_id);
return {
id,
title: stringOf(row?.title) ?? '',
directory,
...(numberOf(row?.time_created) !== undefined
? { timeCreated: numberOf(row?.time_created) }
: {}),
...(numberOf(row?.time_updated) !== undefined
? { timeUpdated: numberOf(row?.time_updated) }
: {}),
archived: numberOf(row?.time_archived) !== undefined,
...(parentId !== undefined ? { parentId } : {}),
};
}
function toMessageRow(value: unknown): MessageRow | undefined {
const row = asRecord(value);
const id = stringOf(row?.id);
const data = parseJsonRecord(row?.data);
if (id === undefined || data === undefined) return undefined;
return { id, timeCreated: numberOf(row?.time_created) ?? 0, data };
}
function toPartRow(value: unknown): PartRow | undefined {
const row = asRecord(value);
const messageId = stringOf(row?.message_id);
const data = parseJsonRecord(row?.data);
if (messageId === undefined || data === undefined) return undefined;
return { messageId, data };
}
function parseJsonRecord(value: unknown): Record<string, unknown> | undefined {
if (typeof value !== 'string') return undefined;
try {
return asRecord(JSON.parse(value));
} catch {
return undefined;
}
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function stringOf(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
/**
* The source marks a root Session's `parent_id` as NULL or the empty string.
*
* Anything else is a child. A value this build cannot read as text is a child
* it cannot name — not the absence of a parent — so it is refused rather than
* decoded to `undefined`, which would let a child session be imported as a
* root of its own. The catalog's SQL already restricts its rows to roots, so a
* row that fails here is one the pagination bounds did not anticipate: skipped
* there, refused here.
*/
function readParentId(value: unknown): string | undefined {
if (value === null || value === undefined) return undefined;
if (typeof value !== 'string') {
throw new Error(
'opencode session `parent_id` is neither text nor null, so the session cannot be proven a root',
);
}
return value.length > 0 ? value : undefined;
}
function numberOf(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function assertPositiveSafeInteger(value: number, label: string): void {
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${label} must be positive`);
}