| /* |
| * 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. |
| */ |
| |
| import assert from 'node:assert/strict'; |
| import { DatabaseSync } from 'node:sqlite'; |
| import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; |
| import { tmpdir } from 'node:os'; |
| import { join } from 'node:path'; |
| import { describe, test } from 'node:test'; |
| import { fileURLToPath } from 'node:url'; |
| import { decodeCanonicalMessage } from '@maka/core/session'; |
| import { |
| ExternalSessionCatalogCursorError, |
| ExternalSessionLimitError, |
| } from '@maka/core/external-session'; |
| import { createExternalSessionAdapterRegistry } from '../external-session-adapters.js'; |
| import { |
| OPENCODE_SESSION_ADAPTER_ID, |
| OpenCodeSessionAdapter, |
| } from '../opencode-session-adapter.js'; |
| |
| // Captured from opencode 1.18.21 with `opencode db`: one session that reads |
| // files, runs commands, writes one, and ends on an aborted message. Paths are |
| // rewritten and long tool output truncated; the record shapes are verbatim. |
| // Resolved against `src` rather than the compiled location: the fixture is |
| // data, so it is not emitted into `dist` beside the test that reads it. |
| const FIXTURE = fileURLToPath( |
| new URL('../../src/__tests__/fixtures/opencode-session-1.18.21.json', import.meta.url), |
| ); |
| |
| interface Fixture { |
| session: { |
| id: string; |
| title: string; |
| directory: string; |
| time_created: number; |
| time_updated: number; |
| time_archived: number | null; |
| parent_id: string | null; |
| }; |
| messages: { id: string; time_created: number; data: unknown }[]; |
| parts: { id: string; message_id: string; time_created: number; data: unknown }[]; |
| } |
| |
| describe('OpenCodeSessionAdapter', () => { |
| test('reports absent when no database exists', async () => { |
| await withOpenCodeHome(async (home) => { |
| const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); |
| assert.equal(await adapter.detect(), false); |
| assert.deepEqual(await adapter.listSessions(), []); |
| }); |
| }); |
| |
| test('an unreadable database is reported, not answered as an empty catalog', async () => { |
| await withOpenCodeHome(async (home) => { |
| await writeFile(join(home, 'opencode.db'), 'not a database'); |
| const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); |
| // The file is there, so the source is present; what fails is reading it. |
| assert.equal(await adapter.detect(), true); |
| await assert.rejects(adapter.listSessions(), /could not be (opened|read)/u); |
| // Reporting "not found" here would send a user looking for a session |
| // that exists in a database this could not open. |
| await assert.rejects(adapter.readSession('ses_anything'), /could not be (opened|read)/u); |
| }); |
| }); |
| |
| test('a session whose transcript tables are missing fails instead of importing empty', async () => { |
| await withOpenCodeHome(async (home) => { |
| const fixture = await seed(home, undefined, { omitPartTable: true }); |
| const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); |
| // The session row still reads, so the catalog offers it... |
| assert.equal((await adapter.listSessions()).length, 1); |
| // ...and the import must not answer with a conversation of nothing. A |
| // future opencode that renames these tables would otherwise silently |
| // import every session as empty and report success. |
| await assert.rejects(adapter.readSession(fixture.session.id), /could not be read/u); |
| }); |
| }); |
| |
| test('a user message with no prompt does not produce a turn holding no messages', async () => { |
| await withOpenCodeHome(async (home) => { |
| const fixture = await seed(home, (f) => { |
| // One user message, no parts at all. |
| f.messages = [{ id: 'msg_empty', time_created: 1, data: { role: 'user' } }]; |
| f.parts = []; |
| return f; |
| }); |
| const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); |
| const session = await adapter.readSession(fixture.session.id); |
| assert.deepEqual( |
| session.messages, |
| [], |
| 'no turn_state is emitted for a turn that holds nothing', |
| ); |
| }); |
| }); |
| |
| test('lists a captured session with its directory and title', async () => { |
| await withOpenCodeHome(async (home) => { |
| const fixture = await seed(home); |
| const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); |
| assert.equal(await adapter.detect(), true); |
| const sessions = await adapter.listSessions(); |
| assert.equal(sessions.length, 1); |
| assert.equal(sessions[0]?.id, fixture.session.id); |
| assert.equal(sessions[0]?.cwd, fixture.session.directory); |
| assert.equal(sessions[0]?.name, fixture.session.title); |
| assert.equal(sessions[0]?.createdAt, fixture.session.time_created); |
| }); |
| }); |
| |
| test('a cwd query selects by the session directory', async () => { |
| await withOpenCodeHome(async (home) => { |
| const fixture = await seed(home); |
| const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); |
| assert.equal((await adapter.listSessions({ cwd: fixture.session.directory })).length, 1); |
| assert.equal((await adapter.listSessions({ cwd: '/somewhere/else' })).length, 0); |
| }); |
| }); |
| |
| test('pages the filtered catalog before returning rows to the Host', async () => { |
| await withOpenCodeHome(async (home) => { |
| await seed(home); |
| const db = new DatabaseSync(join(home, 'opencode.db')); |
| try { |
| const insert = db.prepare( |
| 'INSERT INTO session (id, parent_id, directory, title, time_created, time_updated, time_archived) VALUES (?, NULL, ?, ?, ?, ?, ?)', |
| ); |
| for (let index = 1; index <= 40; index += 1) { |
| insert.run( |
| `ses_page_${index}`, |
| index % 2 === 0 ? '/repo' : '/other', |
| `Page ${index}`, |
| index, |
| index, |
| null, |
| ); |
| } |
| insert.run('ses_created_fallback', '/repo', 'Newest fallback', 10_000, null, null); |
| insert.run('ses_archived', '/repo', 'Archived', 20_000, 20_000, 1); |
| } finally { |
| db.close(); |
| } |
| const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); |
| const first = await adapter.listSessionPage({ cwd: '/repo', limit: 3 }); |
| const cursor = first.items.at(-1)?.nextCursor; |
| assert.ok(cursor); |
| const second = await adapter.listSessionPage({ cwd: '/repo', cursor, limit: 3 }); |
| const summaries = [...first.items, ...second.items].map(({ summary }) => summary); |
| assert.equal(first.items.length, 3); |
| assert.equal(second.items.length, 3); |
| assert.equal(first.items[0]?.summary.id, 'ses_created_fallback'); |
| assert.equal(first.items[0]?.summary.updatedAt, 10_000); |
| assert.equal(new Set(summaries.map(({ id }) => id)).size, 6); |
| assert.ok(!summaries.some(({ id }) => id === 'ses_archived')); |
| await assert.rejects( |
| adapter.listSessionPage({ cwd: '/other', cursor, limit: 3 }), |
| (error: unknown) => error instanceof ExternalSessionCatalogCursorError, |
| ); |
| }); |
| }); |
| |
| test('child sessions are neither listed nor readable as conversations', async () => { |
| await withOpenCodeHome(async (home) => { |
| const fixture = await seed(home, (f) => { |
| f.session.parent_id = 'ses_parent'; |
| return f; |
| }); |
| const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); |
| assert.deepEqual(await adapter.listSessions(), []); |
| await assert.rejects(adapter.readSession(fixture.session.id), /child of another session/u); |
| }); |
| }); |
| |
| test('a root Session with no directory is both listed and importable', async () => { |
| await withOpenCodeHome(async (home) => { |
| const fixture = await seed(home); |
| const db = new DatabaseSync(join(home, 'opencode.db')); |
| try { |
| db.prepare('UPDATE session SET directory = NULL WHERE id = ?').run(fixture.session.id); |
| } finally { |
| db.close(); |
| } |
| const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); |
| |
| assert.equal((await adapter.listSessions())[0]?.cwd, ''); |
| assert.equal((await adapter.readSession(fixture.session.id)).metadata.cwd, ''); |
| }); |
| }); |
| |
| test('converts the captured session into canonical Maka messages', async () => { |
| await withOpenCodeHome(async (home) => { |
| const fixture = await seed(home); |
| const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); |
| const session = await adapter.readSession(fixture.session.id); |
| |
| assert.equal(session.sourceSessionId, fixture.session.id); |
| assert.equal(session.metadata.cwd, fixture.session.directory); |
| // Every emitted message has to survive Maka's own decoder, or the |
| // import would be rejected at the persistence boundary rather than here. |
| for (const message of session.messages) { |
| assert.ok(decodeCanonicalMessage(message), `undecodable: ${message.type}`); |
| } |
| |
| const kinds = new Set(session.messages.map((message) => message.type)); |
| assert.ok(kinds.has('user')); |
| assert.ok(kinds.has('assistant')); |
| assert.ok(kinds.has('tool_call')); |
| assert.ok(kinds.has('tool_result')); |
| assert.ok(kinds.has('turn_state')); |
| }); |
| }); |
| |
| test('excludes synthetic OpenCode user text before provenance is erased', async () => { |
| await withOpenCodeHome(async (home) => { |
| const fixture = await seed(home, (f) => { |
| f.messages = [{ id: 'm_user', time_created: 1, data: { role: 'user' } }]; |
| f.parts = [ |
| { |
| id: 'p_synthetic', |
| message_id: 'm_user', |
| time_created: 1, |
| data: { type: 'text', text: 'automatic summary instruction', synthetic: true }, |
| }, |
| { |
| id: 'p_human', |
| message_id: 'm_user', |
| time_created: 2, |
| data: { type: 'text', text: 'human prompt' }, |
| }, |
| ]; |
| return f; |
| }); |
| const session = await new OpenCodeSessionAdapter({ opencodeHome: home }).readSession( |
| fixture.session.id, |
| ); |
| const user = session.messages.find((message) => message.type === 'user'); |
| assert.equal(user?.text, 'human prompt'); |
| assert.ok(!JSON.stringify(session.messages).includes('automatic summary instruction')); |
| }); |
| }); |
| |
| test('rejects row and UTF-8 byte overflow without publishing a partial transcript', async () => { |
| await withOpenCodeHome(async (home) => { |
| const fixture = await seed(home, (f) => { |
| f.messages = [{ id: 'm_user', time_created: 1, data: { role: 'user' } }]; |
| f.parts = [ |
| { |
| id: 'p_1', |
| message_id: 'm_user', |
| time_created: 1, |
| data: { type: 'text', text: 'ä¸'.repeat(100) }, |
| }, |
| ]; |
| return f; |
| }); |
| const rowBound = new OpenCodeSessionAdapter({ opencodeHome: home, maxRows: 1 }); |
| await assert.rejects( |
| rowBound.readSession(fixture.session.id), |
| (error) => error instanceof ExternalSessionLimitError && error.limit.kind === 'records', |
| ); |
| const byteBound = new OpenCodeSessionAdapter({ opencodeHome: home, maxRawBytes: 128 }); |
| await assert.rejects( |
| byteBound.readSession(fixture.session.id), |
| (error) => |
| error instanceof ExternalSessionLimitError && error.limit.kind === 'record_bytes', |
| ); |
| }); |
| }); |
| |
| test('rejects aggregate source bytes even when every individual row fits', async () => { |
| await withOpenCodeHome(async (home) => { |
| const fixture = await seed(home, (f) => { |
| f.messages = [{ id: 'm_user', time_created: 1, data: { role: 'user' } }]; |
| f.parts = Array.from({ length: 3 }, (_, index) => ({ |
| id: `p_${index}`, |
| message_id: 'm_user', |
| time_created: index + 1, |
| data: { type: 'text', text: 'x'.repeat(80) }, |
| })); |
| return f; |
| }); |
| await assert.rejects( |
| new OpenCodeSessionAdapter({ opencodeHome: home, maxRawBytes: 180 }).readSession( |
| fixture.session.id, |
| ), |
| (error) => |
| error instanceof ExternalSessionLimitError && error.limit.kind === 'transcript_bytes', |
| ); |
| }); |
| }); |
| |
| test('rejects canonical output that exceeds the conversion budget', async () => { |
| await withOpenCodeHome(async (home) => { |
| const fixture = await seed(home); |
| await assert.rejects( |
| new OpenCodeSessionAdapter({ opencodeHome: home, maxConvertedBytes: 10 }).readSession( |
| fixture.session.id, |
| ), |
| (error) => |
| error instanceof ExternalSessionLimitError && error.limit.kind === 'converted_bytes', |
| ); |
| }); |
| }); |
| |
| test('does not follow an opencode database symlink outside the configured home', async () => { |
| const outside = await mkdtemp(join(tmpdir(), 'maka-opencode-outside-')); |
| try { |
| const fixture = await seed(outside); |
| await withOpenCodeHome(async (home) => { |
| await symlink(join(outside, 'opencode.db'), join(home, 'opencode.db')); |
| const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); |
| assert.equal(await adapter.detect(), false); |
| assert.deepEqual(await adapter.listSessions(), []); |
| await assert.rejects(adapter.readSession(fixture.session.id), /database is unavailable/u); |
| }); |
| } finally { |
| await rm(outside, { recursive: true, force: true }); |
| } |
| }); |
| |
| test('pairs every tool result with the call it answers', async () => { |
| await withOpenCodeHome(async (home) => { |
| const fixture = await seed(home); |
| const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); |
| const session = await adapter.readSession(fixture.session.id); |
| |
| const callIds = new Set( |
| session.messages.filter((m) => m.type === 'tool_call').map((m) => m.id), |
| ); |
| const results = session.messages.filter((m) => m.type === 'tool_result'); |
| assert.ok(results.length > 0, 'the capture contains completed tool calls'); |
| for (const result of results) { |
| assert.ok( |
| callIds.has((result as { toolUseId: string }).toolUseId), |
| 'every result names a call that was emitted', |
| ); |
| } |
| }); |
| }); |
| |
| test('reasoning is carried as thinking rather than as reply text', async () => { |
| await withOpenCodeHome(async (home) => { |
| const fixture = await seed(home); |
| const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); |
| const session = await adapter.readSession(fixture.session.id); |
| |
| const thinking = session.messages.filter( |
| (m) => m.type === 'assistant' && (m as { thinking?: unknown }).thinking !== undefined, |
| ); |
| assert.ok(thinking.length > 0, 'the capture contains reasoning parts'); |
| for (const message of thinking) { |
| assert.equal((message as { text: string }).text, ''); |
| } |
| }); |
| }); |
| |
| test('an aborted final message closes its turn as aborted, not completed', async () => { |
| await withOpenCodeHome(async (home) => { |
| const fixture = await seed(home); |
| const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); |
| const session = await adapter.readSession(fixture.session.id); |
| |
| const states = session.messages.filter((m) => m.type === 'turn_state') as { |
| status: string; |
| }[]; |
| assert.ok(states.length > 0); |
| // The capture ends on a message carrying MessageAbortedError. |
| assert.equal(states.at(-1)?.status, 'aborted'); |
| // Earlier turns finished on `finish: "stop"` and must not be dragged |
| // into the last one's verdict. |
| assert.ok( |
| states.slice(0, -1).some((state) => state.status === 'completed'), |
| 'completed turns are recorded as completed', |
| ); |
| }); |
| }); |
| |
| test('a turn left waiting on a tool call is aborted rather than completed', async () => { |
| await withOpenCodeHome(async (home) => { |
| // Drop everything after the first tool-calls message, which is what a |
| // run killed between a call and its answer leaves behind. |
| const fixture = await seed(home, (f) => { |
| const cut = f.messages.findIndex( |
| (m) => (m.data as { finish?: string }).finish === 'tool-calls', |
| ); |
| assert.ok(cut > 0, 'the capture has a tool-calls message'); |
| const kept = f.messages.slice(0, cut + 1); |
| const keptIds = new Set(kept.map((m) => m.id)); |
| f.messages = kept; |
| f.parts = f.parts.filter((p) => keptIds.has(p.message_id)); |
| return f; |
| }); |
| const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); |
| const session = await adapter.readSession(fixture.session.id); |
| const states = session.messages.filter((m) => m.type === 'turn_state') as { |
| status: string; |
| }[]; |
| assert.equal(states.at(-1)?.status, 'aborted'); |
| }); |
| }); |
| |
| test('an in-flight tool call is imported without a synthesised result', async () => { |
| await withOpenCodeHome(async (home) => { |
| const fixture = await seed(home, (f) => { |
| for (const part of f.parts) { |
| const data = part.data as { type?: string; state?: { status?: string } }; |
| if (data.type === 'tool' && data.state) data.state.status = 'running'; |
| } |
| return f; |
| }); |
| const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); |
| const session = await adapter.readSession(fixture.session.id); |
| assert.ok(session.messages.some((m) => m.type === 'tool_call')); |
| assert.equal( |
| session.messages.filter((m) => m.type === 'tool_result').length, |
| 0, |
| 'no result is invented for a call that never answered', |
| ); |
| }); |
| }); |
| |
| test('a terminal tool failure is imported as an errored result, not a dangling call', async () => { |
| await withOpenCodeHome(async (home) => { |
| // The reviewer's reproduction: a failed call inside a step that a later |
| // `finish: "stop"` closes. Without a result the transcript asserts the |
| // tool never replied, inside a turn recorded as completed. |
| const fixture = await seed(home, (f) => { |
| f.messages = [ |
| { id: 'm_user', time_created: 1, data: { role: 'user' } }, |
| { |
| id: 'm_call', |
| time_created: 2, |
| data: { role: 'assistant', finish: 'tool-calls', modelID: 'm' }, |
| }, |
| { |
| id: 'm_stop', |
| time_created: 3, |
| data: { role: 'assistant', finish: 'stop', modelID: 'm' }, |
| }, |
| ]; |
| f.parts = [ |
| { |
| id: 'p_prompt', |
| message_id: 'm_user', |
| time_created: 1, |
| data: { type: 'text', text: 'go' }, |
| }, |
| { |
| id: 'p_tool', |
| message_id: 'm_call', |
| time_created: 2, |
| data: { |
| type: 'tool', |
| tool: 'bash', |
| callID: 'call_failed', |
| state: { status: 'error', input: { command: 'false' }, error: 'exit 1' }, |
| }, |
| }, |
| { |
| id: 'p_text', |
| message_id: 'm_stop', |
| time_created: 3, |
| data: { type: 'text', text: 'done' }, |
| }, |
| ]; |
| return f; |
| }); |
| const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); |
| const session = await adapter.readSession(fixture.session.id); |
| |
| const result = session.messages.find((m) => m.type === 'tool_result') as |
| | { toolUseId: string; isError: boolean; content: { text: string } } |
| | undefined; |
| assert.ok(result, 'a failed call still answered, and the answer is a failure'); |
| assert.equal(result?.toolUseId, 'call_failed'); |
| assert.equal(result?.isError, true); |
| assert.equal(result?.content.text, 'exit 1'); |
| }); |
| }); |
| |
| test('a call still running when the session was written gets no result', async () => { |
| await withOpenCodeHome(async (home) => { |
| const fixture = await seed(home, (f) => { |
| f.messages = [ |
| { id: 'm_user', time_created: 1, data: { role: 'user' } }, |
| { |
| id: 'm_call', |
| time_created: 2, |
| data: { role: 'assistant', finish: 'tool-calls', modelID: 'm' }, |
| }, |
| ]; |
| f.parts = [ |
| { |
| id: 'p_prompt', |
| message_id: 'm_user', |
| time_created: 1, |
| data: { type: 'text', text: 'go' }, |
| }, |
| { |
| id: 'p_tool', |
| message_id: 'm_call', |
| time_created: 2, |
| data: { |
| type: 'tool', |
| tool: 'bash', |
| callID: 'call_running', |
| state: { status: 'running', input: {} }, |
| }, |
| }, |
| ]; |
| return f; |
| }); |
| const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); |
| const session = await adapter.readSession(fixture.session.id); |
| assert.ok(session.messages.some((m) => m.type === 'tool_call')); |
| assert.equal(session.messages.filter((m) => m.type === 'tool_result').length, 0); |
| }); |
| }); |
| |
| test('parts keep the order the session recorded them in', async () => { |
| await withOpenCodeHome(async (home) => { |
| // opencode accepts text -> reasoning -> tool and its own replay keeps |
| // that order. Bucketing by type would emit reasoning first. |
| const fixture = await seed(home, (f) => { |
| f.messages = [ |
| { id: 'm_user', time_created: 1, data: { role: 'user' } }, |
| { |
| id: 'm_reply', |
| time_created: 2, |
| data: { role: 'assistant', finish: 'stop', modelID: 'm' }, |
| }, |
| ]; |
| f.parts = [ |
| { |
| id: 'p_prompt', |
| message_id: 'm_user', |
| time_created: 1, |
| data: { type: 'text', text: 'go' }, |
| }, |
| { |
| id: 'p1', |
| message_id: 'm_reply', |
| time_created: 2, |
| data: { type: 'text', text: 'first' }, |
| }, |
| { |
| id: 'p2', |
| message_id: 'm_reply', |
| time_created: 3, |
| data: { type: 'reasoning', text: 'second' }, |
| }, |
| { |
| id: 'p3', |
| message_id: 'm_reply', |
| time_created: 4, |
| data: { |
| type: 'tool', |
| tool: 'bash', |
| callID: 'call_third', |
| state: { status: 'completed', input: {}, output: 'ok' }, |
| }, |
| }, |
| ]; |
| return f; |
| }); |
| const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); |
| const session = await adapter.readSession(fixture.session.id); |
| const reply = session.messages.filter((m) => m.type !== 'user'); |
| assert.deepEqual( |
| reply.slice(0, 4).map((m) => { |
| if (m.type !== 'assistant') return m.type; |
| return (m as { thinking?: unknown }).thinking !== undefined ? 'thinking' : 'text'; |
| }), |
| ['text', 'thinking', 'tool_call', 'tool_result'], |
| ); |
| }); |
| }); |
| |
| test('an undecodable transcript row fails the import rather than truncating it', async () => { |
| await withOpenCodeHome(async (home) => { |
| const fixture = await seed(home, (f) => { |
| f.messages = [{ id: 'm_user', time_created: 1, data: { role: 'user' } }]; |
| f.parts = []; |
| return f; |
| }); |
| // Replace the message payload with something that is not JSON. |
| const db = new DatabaseSync(join(home, 'opencode.db')); |
| try { |
| db.prepare('UPDATE message SET data = ? WHERE id = ?').run('{not json', 'm_user'); |
| } finally { |
| db.close(); |
| } |
| const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); |
| await assert.rejects(adapter.readSession(fixture.session.id), /could not be read/u); |
| }); |
| }); |
| |
| test('the registry exposes the adapter under its own id', async () => { |
| const registry = createExternalSessionAdapterRegistry(); |
| const adapter = registry.get(OPENCODE_SESSION_ADAPTER_ID); |
| assert.ok(adapter); |
| assert.equal(adapter?.id, OPENCODE_SESSION_ADAPTER_ID); |
| }); |
| }); |
| |
| async function withOpenCodeHome(run: (home: string) => Promise<void>): Promise<void> { |
| const home = await mkdtemp(join(tmpdir(), 'maka-opencode-adapter-')); |
| try { |
| await run(home); |
| } finally { |
| await rm(home, { recursive: true, force: true }); |
| } |
| } |
| |
| async function seed( |
| home: string, |
| mutate?: (fixture: Fixture) => Fixture, |
| options: { omitPartTable?: boolean } = {}, |
| ): Promise<Fixture> { |
| const raw = JSON.parse(await readFile(FIXTURE, 'utf8')) as Fixture; |
| const fixture = mutate ? mutate(raw) : raw; |
| const db = new DatabaseSync(join(home, 'opencode.db')); |
| try { |
| db.exec(` |
| CREATE TABLE session ( |
| id text PRIMARY KEY, project_id text, workspace_id text, parent_id text, |
| slug text, directory text, path text, title text, |
| version text, time_created integer, time_updated integer, |
| time_compacting integer, time_archived integer |
| ); |
| CREATE TABLE message ( |
| id text PRIMARY KEY, session_id text NOT NULL, |
| time_created integer NOT NULL, time_updated integer, data text NOT NULL |
| ); |
| `); |
| if (!options.omitPartTable) { |
| db.exec(` |
| CREATE TABLE part ( |
| id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, |
| time_created integer NOT NULL, time_updated integer, data text NOT NULL |
| ); |
| `); |
| } |
| db.prepare( |
| 'INSERT INTO session (id, parent_id, directory, title, time_created, time_updated, time_archived) VALUES (?, ?, ?, ?, ?, ?, ?)', |
| ).run( |
| fixture.session.id, |
| fixture.session.parent_id, |
| fixture.session.directory, |
| fixture.session.title, |
| fixture.session.time_created, |
| fixture.session.time_updated, |
| fixture.session.time_archived, |
| ); |
| const message = db.prepare( |
| 'INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)', |
| ); |
| for (const row of fixture.messages) { |
| message.run(row.id, fixture.session.id, row.time_created, JSON.stringify(row.data)); |
| } |
| if (options.omitPartTable) return fixture; |
| const part = db.prepare( |
| 'INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)', |
| ); |
| for (const row of fixture.parts) { |
| part.run( |
| row.id, |
| row.message_id, |
| fixture.session.id, |
| row.time_created, |
| JSON.stringify(row.data), |
| ); |
| } |
| } finally { |
| db.close(); |
| } |
| return fixture; |
| } |