From af2dacbc00418e8a8c04477342513a13113dbc96 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 4 Jun 2026 22:19:32 +1000 Subject: [PATCH] feat(migrate): Void 1 SQLite importer Co-Authored-By: Claude Opus 4.8 --- migrate/sources/void1.js | 69 +++++++++++++++++++++++++++++++++++++ tests/migrate/void1.test.js | 45 ++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 migrate/sources/void1.js create mode 100644 tests/migrate/void1.test.js diff --git a/migrate/sources/void1.js b/migrate/sources/void1.js new file mode 100644 index 0000000..b443eb8 --- /dev/null +++ b/migrate/sources/void1.js @@ -0,0 +1,69 @@ +import { DatabaseSync } from 'node:sqlite'; +import { pool } from '../../lib/db/pool.js'; +import * as pages from '../../lib/db/repos/pages.js'; +import * as projects from '../../lib/db/repos/projects.js'; +import * as tasks from '../../lib/db/repos/tasks.js'; +import * as messages from '../../lib/db/repos/messages.js'; +import * as map from '../../lib/db/repos/migration_map.js'; + +const SYS = { kind: 'system', id: null }; +const slugify = (s) => (s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '').slice(0, 60) || 'item'; + +// conversations.create() has no space_id param; insert directly so migrated chats +// live in the void1 Space. +async function createConversation(title, spaceId) { + const { rows: [r] } = await pool.query( + `INSERT INTO conversations(title, space_id, metadata) VALUES($1,$2,'{}') RETURNING id`, [title || 'Conversation', spaceId]); + return r.id; +} + +export async function importVoid1({ sqlitePath, spaceId, dryRun = false }) { + const db = new DatabaseSync(sqlitePath, { readOnly: true }); + const n = { pages: 0, projects: 0, tasks: 0, conversations: 0, messages: 0 }; + const projIds = new Map(); // void1 project id -> void2 project id + + for (const w of db.prepare(`SELECT * FROM wiki_pages`).all()) { + if (await map.seen('void1', `wiki_pages:${w.id}`, 'page')) continue; + n.pages++; if (dryRun) continue; + const p = await pages.create({ space_id: spaceId, slug: slugify(w.slug || w.title), title: w.title || 'Untitled', body_md: w.content || '' }, SYS); + await map.record('void1', `wiki_pages:${w.id}`, 'page', p.id); + } + for (const pr of db.prepare(`SELECT * FROM projects`).all()) { + const existing = await map.seen('void1', `projects:${pr.id}`, 'project'); + if (existing) { projIds.set(pr.id, existing); continue; } + n.projects++; if (dryRun) continue; + const created = await projects.create({ space_id: spaceId, slug: slugify(pr.name), name: pr.name || 'Project', description: pr.description || null, status: pr.status === 'archived' ? 'archived' : 'active' }, SYS); + projIds.set(pr.id, created.id); + await map.record('void1', `projects:${pr.id}`, 'project', created.id); + } + for (const t of db.prepare(`SELECT * FROM project_tasks`).all()) { + if (await map.seen('void1', `project_tasks:${t.id}`, 'task')) continue; + n.tasks++; if (dryRun) continue; + const created = await tasks.create({ space_id: spaceId, project_id: projIds.get(t.project_id) || null, title: t.text || 'Task', position: t.position ?? 0 }, SYS); + await map.record('void1', `project_tasks:${t.id}`, 'task', created.id); + } + for (const j of db.prepare(`SELECT * FROM project_journal`).all()) { + if (await map.seen('void1', `project_journal:${j.id}`, 'page')) continue; + n.pages++; if (dryRun) continue; + const p = await pages.create({ space_id: spaceId, slug: slugify(`journal-${j.id}`), title: `Journal ${j.id}`, body_md: j.content || '' }, SYS); + await map.record('void1', `project_journal:${j.id}`, 'page', p.id); + } + for (const c of db.prepare(`SELECT * FROM conversations`).all()) { + let convId = await map.seen('void1', `conversations:${c.id}`, 'conversation'); + if (!convId) { + n.conversations++; + if (!dryRun) { + convId = await createConversation(c.title, spaceId); + await map.record('void1', `conversations:${c.id}`, 'conversation', convId); + } + } + for (const m of db.prepare(`SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at`).all(c.id)) { + if (await map.seen('void1', `messages:${m.id}`, 'message')) continue; + n.messages++; if (dryRun) continue; + const appended = await messages.append(convId, { role: m.role === 'assistant' ? 'assistant' : 'user', body: m.content || '' }); + await map.record('void1', `messages:${m.id}`, 'message', appended.id); + } + } + db.close(); + return n; +} diff --git a/tests/migrate/void1.test.js b/tests/migrate/void1.test.js new file mode 100644 index 0000000..39a5ecf --- /dev/null +++ b/tests/migrate/void1.test.js @@ -0,0 +1,45 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { DatabaseSync } from 'node:sqlite'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { pool } from '../../lib/db/pool.js'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import { ensureSpace } from '../../migrate/spaces.js'; +import { importVoid1 } from '../../migrate/sources/void1.js'; + +let dbPath, spaceId; +beforeAll(async () => { + await resetDb(); await migrateUp(); spaceId = await ensureSpace('void1', 'Void 1'); + dbPath = join(tmpdir(), `void1-${randomUUID()}.db`); + const db = new DatabaseSync(dbPath); + db.exec(`CREATE TABLE wiki_pages(id TEXT,slug TEXT,title TEXT,content TEXT,tags TEXT,parent_id TEXT,created_at INT,updated_at INT); + CREATE TABLE projects(id TEXT,name TEXT,description TEXT,status TEXT); + CREATE TABLE project_tasks(id TEXT,project_id TEXT,text TEXT,done INT,position INT); + CREATE TABLE project_journal(id TEXT,project_id TEXT,content TEXT,created_at INT); + CREATE TABLE conversations(id TEXT,project_id TEXT,title TEXT,created_at INT); + CREATE TABLE messages(id TEXT,conversation_id TEXT,role TEXT,content TEXT,created_at INT);`); + db.prepare(`INSERT INTO wiki_pages VALUES('w1','home','Home','welcome','',NULL,0,0)`).run(); + db.prepare(`INSERT INTO projects VALUES('p1','Homelab','my lab','active')`).run(); + db.prepare(`INSERT INTO project_tasks VALUES('t1','p1','do a thing',0,1)`).run(); + db.prepare(`INSERT INTO conversations VALUES('c1','p1','Chat 1',0)`).run(); + db.prepare(`INSERT INTO messages VALUES('m1','c1','user','hi',0)`).run(); + db.prepare(`INSERT INTO messages VALUES('m2','c1','assistant','hello',1)`).run(); + db.close(); +}); + +describe('void1 importer', () => { + it('maps wiki/projects/tasks/conversations/messages, idempotently', async () => { + const r1 = await importVoid1({ sqlitePath: dbPath, spaceId }); + expect(r1.pages).toBeGreaterThanOrEqual(1); + expect(r1.projects).toBe(1); + expect(r1.tasks).toBe(1); + expect(r1.conversations).toBe(1); + expect(r1.messages).toBe(2); + const page = await pool.query(`SELECT title FROM pages WHERE space_id=$1 AND title='Home'`, [spaceId]); + expect(page.rows.length).toBe(1); + const r2 = await importVoid1({ sqlitePath: dbPath, spaceId }); // re-run + expect(r2.pages + r2.projects + r2.tasks + r2.conversations + r2.messages).toBe(0); + }); +});