diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index 8c3aa1618..da443c1c5 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -2,7 +2,11 @@ import { Session } from "@/session/session" import { SessionV1 } from "@opencode-ai/core/v1/session" import { MessageV2 } from "../../session/message-v2" import { SessionID } from "../../session/schema" -import { effectCmd, fail } from "../effect-cmd" +import { SessionBundle } from "../../session/bundle" +import { Database } from "@opencode-ai/core/database/database" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { InstanceRef } from "@/effect/instance-ref" +import { effectCmd, fail, CliError } from "../effect-cmd" import { UI } from "../ui" import * as prompts from "@clack/prompts" import { EOL } from "os" @@ -231,7 +235,8 @@ export const ExportCommand = effectCmd({ .option("sanitize", { describe: "redact sensitive transcript and file data", type: "boolean", - }), + }) + .command(ExportSessionBundleCommand), handler: Effect.fn("Cli.export")(function* (args) { return yield* run(args) }), @@ -290,3 +295,68 @@ const run = Effect.fn("Cli.export.body")(function* (args: { sessionID?: string; process.stdout.write(EOL) }).pipe(Effect.catchCause(() => fail(`Session not found: ${sessionID!}`))) }) + +/** + * `opencode export session ` — portable JSONL bundle + * (first line per session = session row, then message rows and part rows in + * storage order; IDs and timestamps preserved verbatim). Byte-faithful to the + * storage layer: import into another store reproduces the rows exactly. + */ +export const ExportSessionBundleCommand = effectCmd({ + command: "session [sessions..]", + describe: + "export sessions as a portable JSONL bundle (session metadata first, then messages and parts in order, IDs and timestamps preserved)", + builder: (yargs) => + yargs + .positional("sessions", { + describe: "session ids to export (comma-separated lists allowed)", + type: "string", + array: true, + }) + .option("all", { + describe: "export every session in the current project scope", + type: "boolean", + default: false, + }) + .option("out", { + alias: "o", + describe: "write the bundle to a file instead of stdout", + type: "string", + }), + handler: Effect.fn("Cli.export.session")(function* (args: { sessions?: string[]; all?: boolean; out?: string }) { + const ctx = yield* InstanceRef + if (!ctx) return yield* Effect.die("InstanceRef not provided") + const db = (yield* Database.Service).db + + let ids: string[] + if (args.all) { + ids = yield* SessionBundle.allSessionIDs(db, ctx.project.id) + if (ids.length === 0) { + process.stderr.write("No sessions found in project scope" + EOL) + } + } else { + ids = (args.sessions ?? []) + .flatMap((value) => value.split(",")) + .map((value) => value.trim()) + .filter(Boolean) + if (ids.length === 0) { + return yield* fail("Specify a session id (or comma-separated ids), or pass --all") + } + } + + const blocks = yield* SessionBundle.exportBlocks(db, ids).pipe( + Effect.mapError((error) => new CliError({ message: error.message })), + ) + const text = SessionBundle.serialize(blocks) + EOL + + if (args.out) { + const fs = yield* FSUtil.Service + yield* fs.writeWithDirs(args.out, text).pipe( + Effect.mapError(() => new CliError({ message: `Failed to write bundle to ${args.out}` })), + ) + process.stdout.write(`Exported ${blocks.length} session(s) to ${args.out}` + EOL) + return + } + process.stdout.write(text) + }), +}) diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 1b7350f74..8d89fa767 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -2,7 +2,8 @@ import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Session } from "@/session/session" import { MessageV2 } from "../../session/message-v2" -import { CliError, effectCmd } from "../effect-cmd" +import { SessionBundle } from "../../session/bundle" +import { CliError, effectCmd, fail } from "../effect-cmd" import { Database } from "@opencode-ai/core/database/database" import { SessionTable, MessageTable, PartTable } from "@opencode-ai/core/session/sql" import { InstanceRef } from "@/effect/instance-ref" @@ -92,21 +93,98 @@ export function transformShareData(shareData: ShareData[]): { type ExportData = { info: SDKSession; messages: Array<{ info: Message; parts: Part[] }> } export const ImportCommand = effectCmd({ - command: "import ", + command: "import [file]", describe: "import session data from JSON file or URL", builder: (yargs) => - yargs.positional("file", { - describe: "path to JSON file or share URL", - type: "string", - demandOption: true, - }), + yargs + .positional("file", { + describe: "path to JSON file or share URL", + type: "string", + }) + .command(ImportSessionBundleCommand), handler: Effect.fn("Cli.import")(function* (args) { + if (!args.file) { + return yield* fail("Specify a file or share URL, or run `opencode import session `") + } const ctx = yield* InstanceRef if (!ctx) return yield* Effect.die("InstanceRef not provided") return yield* runImport(args.file, ctx) }), }) +/** + * `opencode import session [--remap-dir = ...]` — imports a + * portable JSONL bundle produced by `opencode export session`. Idempotent by + * session ID: an existing ID is skipped (exit 0). Each session is written in a + * single transaction. Single-writer: do not run concurrent imports against the + * same store — SQLite WAL protects concurrent readers, but writers are the + * caller's responsibility to serialize. + */ +export const ImportSessionBundleCommand = effectCmd({ + command: "session ", + describe: + "import sessions from a portable JSONL bundle (idempotent by session id; single-writer — do not run concurrent imports against one store)", + builder: (yargs) => + yargs + .positional("bundle", { + describe: "path to the JSONL bundle file", + type: "string", + demandOption: true, + }) + .option("remap-dir", { + describe: "rewrite matching session directories at import: --remap-dir = (repeatable)", + type: "array", + default: [] as string[], + }), + handler: Effect.fn("Cli.import.session")(function* (args: { bundle: string; remapDir?: string[] }) { + const ctx = yield* InstanceRef + if (!ctx) return yield* Effect.die("InstanceRef not provided") + return yield* runBundleImport(args.bundle, (args.remapDir ?? []).map(String), ctx) + }), +}) + +const runBundleImport = Effect.fn("Cli.import.session.body")(function* ( + file: string, + remapSpecs: string[], + ctx: InstanceContext, +) { + const fs = yield* FSUtil.Service + const db = (yield* Database.Service).db + + let remaps: [string, string][] + try { + remaps = remapSpecs.map(SessionBundle.parseRemap) + } catch (error) { + return yield* fail(error instanceof SessionBundle.BundleError ? error.message : `Invalid --remap-dir value`) + } + + const text = yield* fs.readFileString(file).pipe( + Effect.mapError((error) => new CliError({ message: formatImportFileError(file, error) })), + ) + const blocks = yield* SessionBundle.parse(text).pipe( + Effect.mapError((error) => new CliError({ message: error.message })), + ) + if (blocks.length === 0) { + return yield* fail(`No sessions found in bundle: ${file}`) + } + + const result = yield* SessionBundle.importBlocks(db, blocks, { + fallbackProjectID: ctx.project.id, + remaps, + }).pipe( + Effect.mapError((error) => new CliError({ message: `Failed to import bundle: ${error.message}` })), + ) + + for (const id of result.imported) { + process.stdout.write(`Imported session: ${id}`) + process.stdout.write(EOL) + } + for (const id of result.skipped) { + process.stdout.write(`Session ${id} already exists, skipped`) + process.stdout.write(EOL) + } +}) + const runImport = Effect.fn("Cli.import.body")(function* (file: string, ctx: InstanceContext) { const share = yield* ShareNext.Service const fs = yield* FSUtil.Service diff --git a/packages/opencode/src/session/bundle.ts b/packages/opencode/src/session/bundle.ts new file mode 100644 index 000000000..0816879cb --- /dev/null +++ b/packages/opencode/src/session/bundle.ts @@ -0,0 +1,251 @@ +export * as SessionBundle from "./bundle" + +import { Effect, Schema } from "effect" +import type { SqlError } from "effect/unstable/sql/SqlError" +import { asc, eq, inArray } from "drizzle-orm" +import { Database } from "@opencode-ai/core/database/database" +import { MessageTable, PartTable, SessionTable } from "@opencode-ai/core/session/sql" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { ProjectV2 } from "@opencode-ai/core/project" +import type { SessionSchema } from "@opencode-ai/core/session/schema" + +type Drizzle = Database.Interface["db"] + +export type SessionRow = typeof SessionTable.$inferSelect +export type MessageRow = typeof MessageTable.$inferSelect +export type PartRow = typeof PartTable.$inferSelect + +export type MessageBlock = { message: MessageRow; parts: PartRow[] } +export type SessionBlock = { session: SessionRow; messages: MessageBlock[] } + +export class BundleError extends Schema.TaggedErrorClass()("BundleError", { + message: Schema.String, +}) {} + +/** + * Portable session bundle: JSONL, one JSON object per line. + * + * {"type":"session","data":} + * {"type":"message","data":} (chronological, per message) + * {"type":"part","data":} (parts of that message, in order) + * + * Repeated per session for multi-session bundles. Rows are the raw storage + * records (IDs and timestamps preserved verbatim), so export → import is + * row-identical. + */ + +export const exportBlocks = (db: Drizzle, ids: string[]): Effect.Effect => + Effect.gen(function* () { + if (ids.length === 0) return [] + const unique = [...new Set(ids)] + const sessionRows = yield* db.select().from(SessionTable).where(inArray(SessionTable.id, unique as SessionSchema.ID[])).all().pipe(Effect.orDie) + const missing = unique.filter((id) => !sessionRows.some((row) => row.id === id)) + if (missing.length > 0) { + return yield* Effect.fail(new BundleError({ message: `Session not found: ${missing.join(", ")}` })) + } + const byId = new Map(sessionRows.map((row) => [row.id, row])) + + const messageRows = yield* db + .select() + .from(MessageTable) + .where(inArray(MessageTable.session_id, unique as SessionSchema.ID[])) + .orderBy(asc(MessageTable.time_created), asc(MessageTable.id)) + .all() + .pipe(Effect.orDie) + const partRows = yield* db + .select() + .from(PartTable) + .where(inArray(PartTable.session_id, unique as SessionSchema.ID[])) + .orderBy(asc(PartTable.message_id), asc(PartTable.id)) + .all() + .pipe(Effect.orDie) + + const partsByMessage = new Map() + for (const part of partRows) { + const list = partsByMessage.get(part.message_id) + if (list) list.push(part) + else partsByMessage.set(part.message_id, [part]) + } + const messagesBySession = new Map() + for (const message of messageRows) { + const list = messagesBySession.get(message.session_id) + if (list) list.push(message) + else messagesBySession.set(message.session_id, [message]) + } + + return ids.map((id) => ({ + session: byId.get(id as SessionSchema.ID)!, + messages: (messagesBySession.get(id) ?? []).map((message) => ({ + message, + parts: partsByMessage.get(message.id) ?? [], + })), + })) + }) + +/** All session IDs in a project scope, oldest first. Used by `export session --all`. */ +export const allSessionIDs = (db: Drizzle, projectID: ProjectV2.ID): Effect.Effect => + db + .select({ id: SessionTable.id }) + .from(SessionTable) + .where(eq(SessionTable.project_id, projectID)) + .orderBy(asc(SessionTable.time_created), asc(SessionTable.id)) + .all() + .pipe(Effect.orDie, Effect.map((rows) => rows.map((row) => row.id))) + +export const serialize = (blocks: SessionBlock[]): string => + blocks + .flatMap((block) => [ + JSON.stringify({ type: "session", data: block.session }), + ...block.messages.flatMap(({ message, parts }) => [ + JSON.stringify({ type: "message", data: message }), + ...parts.map((part) => JSON.stringify({ type: "part", data: part })), + ]), + ]) + .join("\n") + +type RawLine = { type?: unknown; data?: unknown } + +export const parse = (text: string): Effect.Effect => + Effect.gen(function* () { + const lines = text + .split("\n") + .map((line, index) => ({ line: line.trim(), number: index + 1 })) + .filter((entry) => entry.line.length > 0) + const blocks: SessionBlock[] = [] + let current: SessionBlock | undefined + for (const { line, number } of lines) { + let raw: RawLine + try { + raw = JSON.parse(line) as RawLine + } catch { + return yield* Effect.fail(new BundleError({ message: `Bundle line ${number}: invalid JSON` })) + } + if (!raw || typeof raw !== "object" || typeof raw.type !== "string" || !raw.data || typeof raw.data !== "object") { + return yield* Effect.fail(new BundleError({ message: `Bundle line ${number}: expected {"type","data"} object` })) + } + const data = raw.data as Record + if (raw.type === "session") { + if (typeof data.id !== "string") { + return yield* Effect.fail(new BundleError({ message: `Bundle line ${number}: session missing id` })) + } + current = { session: data as unknown as SessionRow, messages: [] } + blocks.push(current) + continue + } + if (!current) { + return yield* Effect.fail( + new BundleError({ message: `Bundle line ${number}: ${raw.type} line before any session line` }), + ) + } + if (raw.type === "message") { + if (typeof data.id !== "string" || data.session_id !== current.session.id) { + return yield* Effect.fail( + new BundleError({ message: `Bundle line ${number}: message does not belong to session ${current.session.id}` }), + ) + } + current.messages.push({ message: data as unknown as MessageRow, parts: [] }) + continue + } + if (raw.type === "part") { + const owner = current.messages[current.messages.length - 1] + if (!owner || typeof data.id !== "string" || data.message_id !== owner.message.id) { + return yield* Effect.fail( + new BundleError({ message: `Bundle line ${number}: part does not belong to the preceding message` }), + ) + } + owner.parts.push(data as unknown as PartRow) + continue + } + return yield* Effect.fail(new BundleError({ message: `Bundle line ${number}: unknown type ${raw.type}` })) + } + return blocks + }) + +export type ImportOptions = { + /** Project used when the session's original project does not exist in the target store. */ + fallbackProjectID: ProjectV2.ID + /** [from, to] directory rewrites, applied in order; matches `from` exactly or as a path prefix. */ + remaps?: [string, string][] +} + +/** + * Rewrites `directory` if it matches a remap rule (exact match, or a path under + * `from` — the remainder is re-rooted under `to`). Returns undefined when no + * rule matches. + */ +export function remapDirectory(directory: string, remaps: [string, string][]): string | undefined { + for (const [from, to] of remaps) { + if (!from) continue + if (directory === from) return to + const prefix = from.endsWith("/") ? from : from + "/" + if (directory.startsWith(prefix)) { + const target = to.endsWith("/") ? to.slice(0, -1) : to + return target + directory.slice(from.length) + } + } + return undefined +} + +/** Parses a `--remap-dir` spec: `from=to` (first `=` separates). */ +export function parseRemap(spec: string): [string, string] { + const index = spec.indexOf("=") + if (index <= 0 || index === spec.length - 1) { + throw new BundleError({ message: `invalid --remap-dir value "${spec}", expected from=to` }) + } + return [spec.slice(0, index).trim(), spec.slice(index + 1).trim()] +} + +export type ImportResult = { imported: string[]; skipped: string[] } + +export const importBlocks = ( + db: Drizzle, + blocks: SessionBlock[], + options: ImportOptions, +): Effect.Effect => + Effect.forEach(blocks, (block) => importBlock(db, block, options), { concurrency: 1 }).pipe( + Effect.map((results) => ({ + imported: results.filter((r) => r.imported).map((r) => r.id), + skipped: results.filter((r) => !r.imported).map((r) => r.id), + })), + ) + +const importBlock = (db: Drizzle, block: SessionBlock, options: ImportOptions) => + db.transaction((tx) => + Effect.gen(function* () { + const existing = yield* tx + .select({ id: SessionTable.id }) + .from(SessionTable) + .where(eq(SessionTable.id, block.session.id)) + .get() + .pipe(Effect.orDie) + if (existing) return { imported: false, id: block.session.id } + + const remaps = options.remaps ?? [] + const remapped = remapDirectory(block.session.directory, remaps) + let projectID = block.session.project_id + if (remapped !== undefined) { + projectID = options.fallbackProjectID + } else { + const project = yield* tx + .select({ id: ProjectTable.id }) + .from(ProjectTable) + .where(eq(ProjectTable.id, projectID)) + .get() + .pipe(Effect.orDie) + if (!project) projectID = options.fallbackProjectID + } + + yield* tx + .insert(SessionTable) + .values({ ...block.session, project_id: projectID, ...(remapped !== undefined ? { directory: remapped } : {}) }) + .run() + .pipe(Effect.orDie) + for (const { message, parts } of block.messages) { + yield* tx.insert(MessageTable).values(message).run().pipe(Effect.orDie) + for (const part of parts) { + yield* tx.insert(PartTable).values(part).run().pipe(Effect.orDie) + } + } + return { imported: true, id: block.session.id } + }), + ) diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index e9d3ad233..f96bbfa66 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -286,6 +286,11 @@ exports[`opencode CLI help-text snapshots every documented command emits stable export session data as JSON +Commands: + opencode export session [sessions..] export sessions as a portable JSONL bundle (session metadata + first, then messages and parts in order, IDs and timestamps + preserved) + Positionals: sessionID session id to export [string] @@ -299,12 +304,17 @@ Options: `; exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode import --help 1`] = ` -"opencode import +"opencode import [file] import session data from JSON file or URL +Commands: + opencode import session import sessions from a portable JSONL bundle (idempotent by + session id; single-writer — do not run concurrent imports + against one store) + Positionals: - file path to JSON file or share URL [string] [required] + file path to JSON file or share URL [string] Options: -h, --help show help [boolean] diff --git a/packages/opencode/test/session/bundle.test.ts b/packages/opencode/test/session/bundle.test.ts new file mode 100644 index 000000000..a58f0d329 --- /dev/null +++ b/packages/opencode/test/session/bundle.test.ts @@ -0,0 +1,378 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { asc } from "drizzle-orm" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { Database } from "@opencode-ai/core/database/database" +import { MessageTable, PartTable, SessionTable } from "@opencode-ai/core/session/sql" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import type { ProjectV2 } from "@opencode-ai/core/project" +import type { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionBundle } from "../../src/session/bundle" + +type DB = Database.Interface["db"] + +async function withStore(fn: (db: DB) => Promise | A): Promise { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-bundle-")) + try { + return await Effect.runPromise( + Effect.gen(function* () { + const { db } = yield* Database.Service + return yield* Effect.promise(() => Promise.resolve(fn(db))) + }).pipe(Effect.provide(Database.layerFromPath(path.join(tmp, "store.db")))), + ) + } finally { + await fs.rm(tmp, { recursive: true, force: true }).catch(() => undefined) + } +} + +const prj = (id: string) => id as ProjectV2.ID + +async function seedProject(db: DB, id: string, worktree: string) { + await Effect.runPromise( + db + .insert(ProjectTable) + .values({ id: prj(id), worktree: worktree as AbsolutePath, sandboxes: [] }) + .run() + .pipe(Effect.orDie), + ) +} + +async function seedSession(db: DB, row: { id: string; project_id: string } & Record) { + await Effect.runPromise( + db + .insert(SessionTable) + .values({ + slug: "test-slug", + directory: "/tmp/default", + title: "Test session", + version: "0.0.0-test", + time_created: 1000, + time_updated: 1000, + ...row, + } as typeof SessionTable.$inferInsert) + .run() + .pipe(Effect.orDie), + ) +} + +async function seedMessage(db: DB, row: { id: string; session_id: string; time_created: number; data: object }) { + await Effect.runPromise( + db + .insert(MessageTable) + .values({ ...row, time_updated: row.time_created } as typeof MessageTable.$inferInsert) + .run() + .pipe(Effect.orDie), + ) +} + +async function seedPart(db: DB, row: { id: string; message_id: string; session_id: string; time_created: number; data: object }) { + await Effect.runPromise( + db + .insert(PartTable) + .values({ ...row, time_updated: row.time_created } as typeof PartTable.$inferInsert) + .run() + .pipe(Effect.orDie), + ) +} + +const fetchAll = async (db: DB) => { + const sessions = await Effect.runPromise( + db.select().from(SessionTable).orderBy(asc(SessionTable.id)).all().pipe(Effect.orDie), + ) + const messages = await Effect.runPromise( + db.select().from(MessageTable).orderBy(asc(MessageTable.time_created), asc(MessageTable.id)).all().pipe(Effect.orDie), + ) + const parts = await Effect.runPromise( + db.select().from(PartTable).orderBy(asc(PartTable.message_id), asc(PartTable.id)).all().pipe(Effect.orDie), + ) + return { sessions, messages, parts } +} + +// A representative store: one session, two messages out of insertion order +// (older one inserted last), parts interleaved, plus every column populated. +async function seedRichStore(db: DB) { + await seedProject(db, "prj_rich", "/tmp/rich") + const sessionRow = { + id: "ses_rich", + project_id: "prj_rich", + slug: "rich-slug", + directory: "/tmp/rich", + path: "sub/dir", + title: "Rich session", + version: "1.2.3", + agent: "build", + model: { id: "test-model", providerID: "test" }, + cost: 0.25, + tokens_input: 11, + tokens_output: 22, + tokens_reasoning: 3, + tokens_cache_read: 4, + tokens_cache_write: 5, + metadata: { foo: "bar" }, + time_created: 1700000000000, + time_updated: 1700000005000, + } + await seedSession(db, sessionRow) + await seedMessage(db, { + id: "msg_new", + session_id: "ses_rich", + time_created: 1700000002000, + data: { role: "assistant", time: { created: 1700000002000 }, parentID: "msg_old", modelID: "test-model", providerID: "test", mode: "build", agent: "build", path: { cwd: "/tmp/rich", root: "/tmp/rich" }, cost: 0.2, tokens: { input: 1, output: 2, reasoning: 0, cache: { read: 0, write: 0 } } }, + }) + await seedMessage(db, { + id: "msg_old", + session_id: "ses_rich", + time_created: 1700000001000, + data: { role: "user", time: { created: 1700000001000 }, agent: "build", model: { providerID: "test", modelID: "test-model" } }, + }) + await seedPart(db, { + id: "prt_b", + message_id: "msg_old", + session_id: "ses_rich", + time_created: 1700000001100, + data: { type: "text", text: "second part" }, + }) + await seedPart(db, { + id: "prt_a", + message_id: "msg_old", + session_id: "ses_rich", + time_created: 1700000001050, + data: { type: "text", text: "first part" }, + }) + await seedPart(db, { + id: "prt_c", + message_id: "msg_new", + session_id: "ses_rich", + time_created: 1700000002100, + data: { type: "step-start" }, + }) + return { sessionRow } +} + +describe("SessionBundle export", () => { + test("emits session metadata first, then messages and parts in storage order, byte-faithful", async () => { + await withStore(async (db) => { + const { sessionRow } = await seedRichStore(db) + const result = await Effect.runPromise(SessionBundle.exportBlocks(db, ["ses_rich"])) + + expect(result).toHaveLength(1) + const block = result[0]! + const sessionJson = JSON.parse(JSON.stringify(block.session)) as Record + expect(sessionJson).toEqual({ ...sessionRow, summary_additions: null, summary_deletions: null, summary_files: null, summary_diffs: null, workspace_id: null, parent_id: null, share_url: null, revert: null, permission: null, time_compacting: null, time_archived: null, directories: null }) + expect(block.messages.map((m) => m.message.id as string)).toEqual(["msg_old", "msg_new"]) + expect(block.messages[0]!.parts.map((p) => p.id as string)).toEqual(["prt_a", "prt_b"]) + expect(block.messages[1]!.parts.map((p) => p.id as string)).toEqual(["prt_c"]) + // timestamps preserved verbatim + expect(block.session.time_created).toBe(1700000000000) + expect(block.messages[1]!.parts[0]!.time_created).toBe(1700000002100) + }) + }) + + test("serializes to JSONL and parses back to identical blocks", async () => { + await withStore(async (db) => { + await seedRichStore(db) + const blocks = await Effect.runPromise(SessionBundle.exportBlocks(db, ["ses_rich"])) + const text = SessionBundle.serialize(blocks) + const lines = text.split("\n") + expect(lines.length).toBe(1 + 2 + 3) + for (const line of lines) expect(() => JSON.parse(line)).not.toThrow() + const parsed = await Effect.runPromise(SessionBundle.parse(text)) + expect(parsed).toEqual(blocks) + // first line is session metadata + expect(JSON.parse(lines[0]!).type).toBe("session") + }) + }) + + test("export of an unknown session fails with a clear error", async () => { + await withStore(async (db) => { + const error = await Effect.runPromise(Effect.flip(SessionBundle.exportBlocks(db, ["ses_missing"]))) + expect(error).toBeInstanceOf(SessionBundle.BundleError) + expect(error.message).toContain("ses_missing") + }) + }) + + test("exports multiple sessions in requested order", async () => { + await withStore(async (db) => { + await seedProject(db, "prj_multi", "/tmp/multi") + await seedSession(db, { id: "ses_two", project_id: "prj_multi", time_created: 1, time_updated: 1 }) + await seedSession(db, { id: "ses_one", project_id: "prj_multi", time_created: 2, time_updated: 2 }) + const blocks = await Effect.runPromise(SessionBundle.exportBlocks(db, ["ses_two", "ses_one"])) + expect(blocks.map((b) => b.session.id as string)).toEqual(["ses_two", "ses_one"]) + }) + }) +}) + +describe("SessionBundle import", () => { + test("round-trip identity: import into a fresh store reproduces the exported rows", async () => { + await withStore(async (dbA) => { + await seedRichStore(dbA) + const blocks = await Effect.runPromise(SessionBundle.exportBlocks(dbA, ["ses_rich"])) + const text = SessionBundle.serialize(blocks) + + await withStore(async (dbB) => { + await seedProject(dbB, "prj_rich", "/tmp/rich") + await seedProject(dbB, "global", "/tmp/global") + const blocks = await parseHelper(text) + const result = await Effect.runPromise( + SessionBundle.importBlocks(dbB, blocks, { fallbackProjectID: prj("global") }), + ) + expect(result.imported).toEqual(["ses_rich"]) + expect(result.skipped).toEqual([]) + + const target = await fetchAll(dbB) + const source = await fetchAll(dbA) + expect(target.sessions).toEqual(source.sessions) + expect(target.messages).toEqual(source.messages) + expect(target.parts).toEqual(source.parts) + }) + }) + }) + + test("import is idempotent: existing session ID is skipped, no duplication", async () => { + await withStore(async (dbA) => { + await seedRichStore(dbA) + const text = SessionBundle.serialize(await Effect.runPromise(SessionBundle.exportBlocks(dbA, ["ses_rich"]))) + + await withStore(async (dbB) => { + await seedProject(dbB, "prj_rich", "/tmp/rich") + const blocks = await Effect.runPromise(SessionBundle.parse(text)) + const first = await Effect.runPromise(SessionBundle.importBlocks(dbB, blocks, { fallbackProjectID: prj("global") })) + expect(first.imported).toEqual(["ses_rich"]) + + const second = await Effect.runPromise(SessionBundle.importBlocks(dbB, blocks, { fallbackProjectID: prj("global") })) + expect(second.imported).toEqual([]) + expect(second.skipped).toEqual(["ses_rich"]) + + const target = await fetchAll(dbB) + expect(target.sessions).toHaveLength(1) + expect(target.messages).toHaveLength(2) + expect(target.parts).toHaveLength(3) + }) + }) + }) + + test("--remap-dir rewrites matching directories under the fallback project; non-matching keep original", async () => { + await withStore(async (dbA) => { + await seedProject(dbA, "prj_src", "/Users/alice/repo") + await seedSession(dbA, { + id: "ses_match", + project_id: "prj_src", + directory: "/Users/alice/repo", + time_created: 1, + time_updated: 1, + }) + await seedSession(dbA, { + id: "ses_match_sub", + project_id: "prj_src", + directory: "/Users/alice/repo/sub/dir", + time_created: 2, + time_updated: 2, + }) + await seedSession(dbA, { + id: "ses_nomatch", + project_id: "prj_src", + directory: "/Users/alice/other", + time_created: 3, + time_updated: 3, + }) + const text = SessionBundle.serialize( + await Effect.runPromise(SessionBundle.exportBlocks(dbA, ["ses_match", "ses_match_sub", "ses_nomatch"])), + ) + + await withStore(async (dbB) => { + await seedProject(dbB, "prj_src", "/Users/alice/repo") + await seedProject(dbB, "prj_target", "/Users/bob/repo") + const blocks = await Effect.runPromise(SessionBundle.parse(text)) + const result = await Effect.runPromise( + SessionBundle.importBlocks(dbB, blocks, { + fallbackProjectID: prj("prj_target"), + remaps: [["/Users/alice/repo", "/Users/bob/repo"]], + }), + ) + expect(result.imported.sort()).toEqual(["ses_match", "ses_match_sub", "ses_nomatch"]) + + const rows = new Map((await fetchAll(dbB)).sessions.map((s) => [s.id as string, s])) + expect(rows.get("ses_match")!.directory).toBe("/Users/bob/repo") + expect(rows.get("ses_match")!.project_id as string).toBe("prj_target") + expect(rows.get("ses_match_sub")!.directory).toBe("/Users/bob/repo/sub/dir") + expect(rows.get("ses_match_sub")!.project_id as string).toBe("prj_target") + // no matching remap: original path, original project (exists in target) + expect(rows.get("ses_nomatch")!.directory).toBe("/Users/alice/other") + expect(rows.get("ses_nomatch")!.project_id as string).toBe("prj_src") + }) + }) + }) + + test("multi-session bundle imports all sessions into a store with unrelated sessions present", async () => { + await withStore(async (dbA) => { + await seedProject(dbA, "prj_pair", "/tmp/pair") + await seedSession(dbA, { id: "ses_alpha", project_id: "prj_pair", title: "alpha", time_created: 1, time_updated: 1 }) + await seedSession(dbA, { id: "ses_beta", project_id: "prj_pair", title: "beta", time_created: 2, time_updated: 2 }) + await seedMessage(dbA, { id: "msg_alpha", session_id: "ses_alpha", time_created: 10, data: { role: "user", time: { created: 10 } } }) + await seedPart(dbA, { id: "prt_alpha", message_id: "msg_alpha", session_id: "ses_alpha", time_created: 11, data: { type: "text", text: "hi" } }) + const text = SessionBundle.serialize(await Effect.runPromise(SessionBundle.exportBlocks(dbA, ["ses_alpha", "ses_beta"]))) + + await withStore(async (dbB) => { + // unrelated pre-existing content + await seedProject(dbB, "prj_unrelated", "/tmp/unrelated") + await seedSession(dbB, { id: "ses_unrelated", project_id: "prj_unrelated", title: "unrelated", time_created: 1, time_updated: 1 }) + // fallback project must exist in the target store + await seedProject(dbB, "global", "/tmp/global") + + const blocks = await Effect.runPromise(SessionBundle.parse(text)) + const result = await Effect.runPromise( + SessionBundle.importBlocks(dbB, blocks, { fallbackProjectID: prj("global") }), + ) + expect(result.imported.sort()).toEqual(["ses_alpha", "ses_beta"]) + + const target = await fetchAll(dbB) + expect(target.sessions.map((s) => s.id as string).sort()).toEqual(["ses_alpha", "ses_beta", "ses_unrelated"]) + expect(target.messages.map((m) => m.id as string)).toEqual(["msg_alpha"]) + expect(target.parts.map((p) => p.id as string)).toEqual(["prt_alpha"]) + // unrelated session untouched + const unrelated = target.sessions.find((s) => s.id === "ses_unrelated")! + expect(unrelated.title).toBe("unrelated") + }) + }) + }) + + test("missing project in target store falls back to fallbackProjectID without remap", async () => { + await withStore(async (dbA) => { + await seedProject(dbA, "prj_orphan", "/tmp/orphan") + await seedSession(dbA, { id: "ses_orphan", project_id: "prj_orphan", directory: "/tmp/orphan", time_created: 1, time_updated: 1 }) + const text = SessionBundle.serialize(await Effect.runPromise(SessionBundle.exportBlocks(dbA, ["ses_orphan"]))) + + await withStore(async (dbB) => { + await seedProject(dbB, "prj_home", "/tmp/home") + const blocks = await Effect.runPromise(SessionBundle.parse(text)) + const result = await Effect.runPromise( + SessionBundle.importBlocks(dbB, blocks, { fallbackProjectID: prj("prj_home") }), + ) + expect(result.imported).toEqual(["ses_orphan"]) + const row = (await fetchAll(dbB)).sessions[0]! + expect(row.project_id as string).toBe("prj_home") + expect(row.directory).toBe("/tmp/orphan") + }) + }) + }) + + test("rejects malformed bundles", async () => { + const bad = await Effect.runPromise(Effect.flip(SessionBundle.parse("not json"))) + expect(bad.message).toContain("invalid JSON") + const orphanPart = await Effect.runPromise( + Effect.flip(SessionBundle.parse('{"type":"part","data":{"id":"prt_x"}}')), + ) + expect(orphanPart.message).toContain("before any session line") + const orphanMessage = await Effect.runPromise( + Effect.flip(SessionBundle.parse('{"type":"message","data":{"id":"msg_x","session_id":"ses_x"}}')), + ) + expect(orphanMessage.message).toContain("before any session line") + }) +}) + +async function parseHelper(text: string) { + return Effect.runPromise(SessionBundle.parse(text)) +} +void parseHelper