diff --git a/docs/superpowers/plans/2026-06-04-void-migrate.md b/docs/superpowers/plans/2026-06-04-void-migrate.md new file mode 100644 index 0000000..c39473a --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-void-migrate.md @@ -0,0 +1,550 @@ +# void-migrate Implementation Plan (Plan 8a) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A re-runnable Node CLI that imports Void 1 (SQLite), BookStack, Karakeep, and plans `.md` into Void 2 — idempotently, one Space per source. + +**Architecture:** A `migrate/` CLI reusing Void 2's repos. Idempotency via a `migration_map` table (refs use `upsertByExternal`). Importers are plain async functions (testable with fixtures); the CLI wires them and supports `--dry-run` + `verify`. + +**Tech Stack:** Node 22 ESM (`node:sqlite` built-in), Postgres, vitest (serial). + +**Spec:** `docs/superpowers/specs/2026-06-04-void-migrate-design.md` + +--- + +### Task 1: `migration_map` table + repo + +**Files:** Create `lib/db/migrations/018_migration_map.sql`, `lib/db/repos/migration_map.js`; Test `tests/db/migration_map.test.js` + +- [ ] **Step 1: Migration** +```sql +-- 018_migration_map.sql — idempotency ledger for void-migrate. Maps a source row +-- to the Void 2 entity it created, so re-running an import never duplicates. +CREATE TABLE migration_map ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + source text NOT NULL, -- 'void1' | 'bookstack' | 'karakeep' | 'plans' + source_id text NOT NULL, -- e.g. 'wiki_pages:42' + entity_type text NOT NULL, -- 'page' | 'project' | 'task' | 'conversation' | 'message' + entity_id uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (source, source_id, entity_type) +); +``` + +- [ ] **Step 2: Failing test** `tests/db/migration_map.test.js` +```js +import { describe, it, expect, beforeAll } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import * as map from '../../lib/db/repos/migration_map.js'; +import { randomUUID } from 'crypto'; + +beforeAll(async () => { await resetDb(); await migrateUp(); }); + +describe('migration_map', () => { + it('seen() is null until record(), then returns the entity id', async () => { + const eid = randomUUID(); + expect(await map.seen('void1', 'wiki_pages:1', 'page')).toBeNull(); + await map.record('void1', 'wiki_pages:1', 'page', eid); + expect(await map.seen('void1', 'wiki_pages:1', 'page')).toBe(eid); + }); + it('record() is idempotent on the unique key', async () => { + const eid = randomUUID(); + await map.record('plans', 'a.md', 'page', eid); + await map.record('plans', 'a.md', 'page', randomUUID()); // ignored + expect(await map.seen('plans', 'a.md', 'page')).toBe(eid); + }); +}); +``` + +- [ ] **Step 3: Run → FAIL.** +- [ ] **Step 4: Implement** `lib/db/repos/migration_map.js` +```js +import { pool } from '../pool.js'; + +export async function seen(source, source_id, entity_type) { + const { rows: [r] } = await pool.query( + `SELECT entity_id FROM migration_map WHERE source=$1 AND source_id=$2 AND entity_type=$3`, + [source, source_id, entity_type]); + return r ? r.entity_id : null; +} +export async function record(source, source_id, entity_type, entity_id) { + await pool.query( + `INSERT INTO migration_map(source, source_id, entity_type, entity_id) VALUES($1,$2,$3,$4) + ON CONFLICT (source, source_id, entity_type) DO NOTHING`, + [source, source_id, entity_type, entity_id]); +} +``` +- [ ] **Step 5: Run → PASS. Commit** `feat(migrate): migration_map idempotency ledger` + +--- + +### Task 2: `ensureSpace` helper + +**Files:** Create `migrate/spaces.js`; Test `tests/migrate/spaces.test.js` + +- [ ] **Step 1: Failing test** +```js +import { describe, it, expect, beforeAll } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import { ensureSpace } from '../../migrate/spaces.js'; + +beforeAll(async () => { await resetDb(); await migrateUp(); }); + +describe('ensureSpace', () => { + it('creates a space once and reuses it by slug', async () => { + const a = await ensureSpace('void1', 'Void 1'); + const b = await ensureSpace('void1', 'Void 1'); + expect(a).toBe(b); + }); +}); +``` + +- [ ] **Step 2: Run → FAIL.** +- [ ] **Step 3: Implement** `migrate/spaces.js` +```js +import { pool } from '../lib/db/pool.js'; +import * as spaces from '../lib/db/repos/spaces.js'; + +const SYS = { kind: 'system', id: null }; + +// Returns the id of the space with `slug`, creating it if absent. Idempotent. +export async function ensureSpace(slug, name) { + const { rows: [r] } = await pool.query(`SELECT id FROM spaces WHERE slug=$1`, [slug]); + if (r) return r.id; + const created = await spaces.create({ slug, name }, SYS); + return created.id; +} +``` +- [ ] **Step 4: Run → PASS. Commit** `feat(migrate): ensureSpace helper` + +--- + +### Task 3: plans importer (simplest — establishes the pattern) + +**Files:** Create `migrate/sources/plans.js`; Test `tests/migrate/plans.test.js` + `tests/fixtures/plans/*` + +- [ ] **Step 1: Fixtures** — create `tests/fixtures/plans/alpha.md` (`# Alpha Plan\nbody one`) and `tests/fixtures/plans/beta.md` (`# Beta\nbody two`). + +- [ ] **Step 2: Failing test** `tests/migrate/plans.test.js` +```js +import { describe, it, expect, beforeAll } from 'vitest'; +import { fileURLToPath } from 'url'; +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 { importPlans } from '../../migrate/sources/plans.js'; + +const DIR = fileURLToPath(new URL('../fixtures/plans', import.meta.url)); +let spaceId; +beforeAll(async () => { await resetDb(); await migrateUp(); spaceId = await ensureSpace('plans', 'Plans'); }); + +describe('plans importer', () => { + it('imports each .md as a page, idempotently', async () => { + const r1 = await importPlans({ dir: DIR, spaceId }); + expect(r1.created).toBe(2); + const { rows } = await pool.query(`SELECT title FROM pages WHERE space_id=$1 ORDER BY title`, [spaceId]); + expect(rows.map(r => r.title)).toEqual(['Alpha Plan', 'Beta']); + const r2 = await importPlans({ dir: DIR, spaceId }); // re-run + expect(r2.created).toBe(0); + }); + it('dry-run writes nothing', async () => { + await resetDb(); await migrateUp(); const s = await ensureSpace('plans', 'Plans'); + const r = await importPlans({ dir: DIR, spaceId: s, dryRun: true }); + expect(r.created).toBe(2); + const { rows } = await pool.query(`SELECT count(*)::int n FROM pages WHERE space_id=$1`, [s]); + expect(rows[0].n).toBe(0); + }); +}); +``` + +- [ ] **Step 3: Run → FAIL.** +- [ ] **Step 4: Implement** `migrate/sources/plans.js` +```js +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import * as pages from '../../lib/db/repos/pages.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) || 'page'; +function titleOf(body, file) { + const m = body.match(/^#\s+(.+)$/m); + return m ? m[1].trim() : file.replace(/\.md$/, ''); +} + +export async function importPlans({ dir, spaceId, dryRun = false }) { + let created = 0; + for (const file of readdirSync(dir).filter(f => f.endsWith('.md')).sort()) { + if (await map.seen('plans', file, 'page')) continue; + const body = readFileSync(join(dir, file), 'utf8'); + const title = titleOf(body, file); + created++; + if (dryRun) continue; + const page = await pages.create({ space_id: spaceId, slug: slugify(title), title, body_md: body }, SYS); + await map.record('plans', file, 'page', page.id); + } + return { created }; +} +``` +- [ ] **Step 5: Run → PASS. Commit** `feat(migrate): plans importer` + +--- + +### Task 4: Void 1 SQLite importer + +**Files:** Create `migrate/sources/void1.js`; Test `tests/migrate/void1.test.js` + +Void 1 columns (verified): `wiki_pages(id,slug,title,content,tags,parent_id,created_at,updated_at)`, `projects(id,name,description,status,...)`, `project_tasks(id,project_id,text,done,position,...)`, `project_journal(id,project_id,content,created_at)`, `conversations(id,project_id,title,created_at)`, `messages(id,conversation_id,role,content,created_at)`. + +- [ ] **Step 1: Failing test** `tests/migrate/void1.test.js` (builds a fixture sqlite with `node:sqlite`) +```js +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); + }); +}); +``` + +- [ ] **Step 2: Run → FAIL.** +- [ ] **Step 3: Implement** `migrate/sources/void1.js` +```js +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) continue; + 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; +} +``` + +- [ ] **Step 4: Run → PASS. Commit** `feat(migrate): Void 1 SQLite importer` + +--- + +### Task 5: Karakeep importer (refs via upsertByExternal) + +**Files:** Create `migrate/sources/karakeep.js`; Test `tests/migrate/karakeep.test.js` + +- [ ] **Step 1: Failing test** +```js +import { describe, it, expect, beforeAll, vi } from 'vitest'; +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 { importKarakeep } from '../../migrate/sources/karakeep.js'; + +let spaceId; +beforeAll(async () => { await resetDb(); await migrateUp(); spaceId = await ensureSpace('bookmarks', 'Bookmarks'); }); + +describe('karakeep importer', () => { + it('imports bookmarks as url refs, idempotently', async () => { + const page = { bookmarks: [ + { id: 'b1', content: { type: 'link', url: 'https://a.test', title: 'A' } }, + { id: 'b2', content: { type: 'link', url: 'https://b.test', title: 'B' } } + ], nextCursor: null }; + const fetchImpl = vi.fn(async () => ({ ok: true, json: async () => page })); + const r1 = await importKarakeep({ apiUrl: 'http://k', token: 't', spaceId, fetchImpl }); + expect(r1.created).toBe(2); + const { rows } = await pool.query(`SELECT url FROM refs WHERE space_id=$1 ORDER BY url`, [spaceId]); + expect(rows.map(r => r.url)).toEqual(['https://a.test', 'https://b.test']); + const r2 = await importKarakeep({ apiUrl: 'http://k', token: 't', spaceId, fetchImpl }); // upsert, no dupes + const { rows: again } = await pool.query(`SELECT count(*)::int n FROM refs WHERE space_id=$1`, [spaceId]); + expect(again[0].n).toBe(2); + }); +}); +``` + +- [ ] **Step 2: Run → FAIL.** +- [ ] **Step 3: Implement** `migrate/sources/karakeep.js` +```js +import * as refs from '../../lib/db/repos/refs.js'; + +const SYS = { kind: 'system', id: null }; + +// Karakeep REST: GET /api/v1/bookmarks?cursor=... → { bookmarks:[{id,content:{url,title}}], nextCursor }. +export async function importKarakeep({ apiUrl = process.env.KARAKEEP_URL, token = process.env.KARAKEEP_TOKEN, spaceId, dryRun = false, fetchImpl = fetch }) { + let cursor = null, created = 0; + do { + const u = `${apiUrl}/api/v1/bookmarks?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`; + const res = await fetchImpl(u, { headers: { Authorization: `Bearer ${token}` } }); + if (!res.ok) throw new Error(`karakeep ${res.status}`); + const body = await res.json(); + for (const b of (body.bookmarks || [])) { + const url = b.content?.url; if (!url) continue; + created++; + if (dryRun) continue; + await refs.upsertByExternal({ space_id: spaceId, kind: 'url', url, title: b.content?.title || url, source_kind: 'karakeep', external_id: String(b.id) }, SYS); + } + cursor = body.nextCursor || null; + } while (cursor); + return { created }; +} +``` +- [ ] **Step 4: Run → PASS. Commit** `feat(migrate): Karakeep bookmarks importer` + +--- + +### Task 6: BookStack importer + +**Files:** Create `migrate/sources/bookstack.js`; Test `tests/migrate/bookstack.test.js` + +- [ ] **Step 1: Failing test** +```js +import { describe, it, expect, beforeAll, vi } from 'vitest'; +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 { importBookstack } from '../../migrate/sources/bookstack.js'; + +let spaceId; +beforeAll(async () => { await resetDb(); await migrateUp(); spaceId = await ensureSpace('wiki', 'Wiki'); }); + +describe('bookstack importer', () => { + it('imports pages with markdown body, idempotently', async () => { + const list = { data: [{ id: 1, name: 'Page One' }, { id: 2, name: 'Page Two' }] }; + const detail = (id) => ({ id, name: id === 1 ? 'Page One' : 'Page Two', markdown: `# P${id}\nbody`, html: `

body

` }); + const fetchImpl = vi.fn(async (u) => { + if (u.endsWith('/api/pages')) return { ok: true, json: async () => list }; + const id = Number(u.split('/').pop()); + return { ok: true, json: async () => detail(id) }; + }); + const r1 = await importBookstack({ apiUrl: 'http://bs', tokenId: 'i', tokenSecret: 's', spaceId, fetchImpl }); + expect(r1.created).toBe(2); + const { rows } = await pool.query(`SELECT title FROM pages WHERE space_id=$1 ORDER BY title`, [spaceId]); + expect(rows.map(r => r.title)).toEqual(['Page One', 'Page Two']); + const r2 = await importBookstack({ apiUrl: 'http://bs', tokenId: 'i', tokenSecret: 's', spaceId, fetchImpl }); + expect(r2.created).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run → FAIL.** +- [ ] **Step 3: Implement** `migrate/sources/bookstack.js` +```js +import * as pages from '../../lib/db/repos/pages.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) || 'page'; + +// BookStack REST: list GET /api/pages → {data:[{id,name}]}; detail GET /api/pages/:id +// → {id,name,markdown,html}. Auth header: "Token :". +export async function importBookstack({ apiUrl = process.env.BOOKSTACK_URL, tokenId = process.env.BOOKSTACK_TOKEN_ID, tokenSecret = process.env.BOOKSTACK_TOKEN_SECRET, spaceId, dryRun = false, fetchImpl = fetch }) { + const headers = { Authorization: `Token ${tokenId}:${tokenSecret}` }; + const listRes = await fetchImpl(`${apiUrl}/api/pages`, { headers }); + if (!listRes.ok) throw new Error(`bookstack list ${listRes.status}`); + const { data = [] } = await listRes.json(); + let created = 0; + for (const item of data) { + if (await map.seen('bookstack', `page:${item.id}`, 'page')) continue; + created++; if (dryRun) continue; + const dRes = await fetchImpl(`${apiUrl}/api/pages/${item.id}`, { headers }); + if (!dRes.ok) throw new Error(`bookstack page ${item.id} ${dRes.status}`); + const d = await dRes.json(); + const body = d.markdown && d.markdown.trim() ? d.markdown : (d.html || ''); + const p = await pages.create({ space_id: spaceId, slug: slugify(d.name), title: d.name || 'Untitled', body_md: body }, SYS); + await map.record('bookstack', `page:${item.id}`, 'page', p.id); + } + return { created }; +} +``` +- [ ] **Step 4: Run → PASS. Commit** `feat(migrate): BookStack importer` + +--- + +### Task 7: CLI + verify + +**Files:** Create `migrate/cli.js`, `migrate/verify.js`; Test `tests/migrate/verify.test.js` + +- [ ] **Step 1: `migrate/verify.js`** — counts migrated rows per source/type from `migration_map` + refs: +```js +import { pool } from '../lib/db/pool.js'; + +export async function verify() { + const { rows: map } = await pool.query( + `SELECT source, entity_type, count(*)::int n FROM migration_map GROUP BY source, entity_type ORDER BY source, entity_type`); + const { rows: [{ n: bookmarks }] } = await pool.query(`SELECT count(*)::int n FROM refs WHERE source_kind='karakeep'`); + return { map, bookmarks }; +} +``` + +- [ ] **Step 2: Verify test** `tests/migrate/verify.test.js` +```js +import { describe, it, expect, beforeAll } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import * as map from '../../lib/db/repos/migration_map.js'; +import { verify } from '../../migrate/verify.js'; +import { randomUUID } from 'crypto'; + +beforeAll(async () => { await resetDb(); await migrateUp(); await map.record('plans', 'a.md', 'page', randomUUID()); }); + +describe('verify', () => { + it('reports per-source/type counts', async () => { + const out = await verify(); + expect(out.map.find(r => r.source === 'plans' && r.entity_type === 'page').n).toBe(1); + }); +}); +``` + +- [ ] **Step 3: Run verify test → FAIL then PASS** after creating `verify.js`. + +- [ ] **Step 4: `migrate/cli.js`** (dispatch; manual-run entry point): +```js +import 'dotenv/config'; +import { ensureSpace } from './spaces.js'; +import { importPlans } from './sources/plans.js'; +import { importVoid1 } from './sources/void1.js'; +import { importKarakeep } from './sources/karakeep.js'; +import { importBookstack } from './sources/bookstack.js'; +import { verify } from './verify.js'; + +const [, , cmd, ...rest] = process.argv; +const dryRun = rest.includes('--dry-run'); + +const run = { + async plans() { return importPlans({ dir: process.env.PLANS_DIR || '/root/.claude/plans', spaceId: await ensureSpace('plans', 'Plans'), dryRun }); }, + async void1() { return importVoid1({ sqlitePath: process.env.VOID1_DB || '/tmp/void1.db', spaceId: await ensureSpace('void1', 'Void 1'), dryRun }); }, + async karakeep() { return importKarakeep({ spaceId: await ensureSpace('bookmarks', 'Bookmarks'), dryRun }); }, + async bookstack() { return importBookstack({ spaceId: await ensureSpace('wiki', 'Wiki'), dryRun }); }, + async verify() { return verify(); } +}; + +if (!run[cmd]) { console.error(`usage: node migrate/cli.js [--dry-run]`); process.exit(1); } +run[cmd]().then(r => { console.log(JSON.stringify(r, null, 2)); process.exit(0); }) + .catch(e => { console.error('migrate failed:', e.message); process.exit(1); }); +``` + +- [ ] **Step 5: Run → PASS. Commit** `feat(migrate): CLI dispatch + verify` + +--- + +### Task 8: Run the migration (gated execution — owner-authorized) + +**Files:** none. Requires: migration 018 applied to the void2 DB + source access. + +- [ ] **Step 1: Apply 018 to prod** — deploy (`bash deploy/push.sh`; runs `018_migration_map`) OR `npm run migrate` against the void2 DB. +- [ ] **Step 2: Stage sources on the run host** (CT 311 or dev box with the void2 `DATABASE_URL`): + - `scp` Void 1's `void.db` from CT 301 → set `VOID1_DB`. + - Set `PLANS_DIR` to the plans dir. + - **BookStack:** create an API token (Settings → API Tokens) → `BOOKSTACK_URL`, `BOOKSTACK_TOKEN_ID`, `BOOKSTACK_TOKEN_SECRET`. + - **Karakeep:** create an API key → `KARAKEEP_URL`, `KARAKEEP_TOKEN`. +- [ ] **Step 3: Dry-run each** — `node migrate/cli.js --dry-run` → review would-create counts. +- [ ] **Step 4: Import** — `node migrate/cli.js plans|void1|karakeep|bookstack`. +- [ ] **Step 5: `node migrate/cli.js verify`** — confirm counts vs sources; spot-check a few entities in the Void 2 UI (the 4 new Spaces). +- [ ] **Step 6: Memory** — record migrated counts + that Plan 8a is done; note 8b (cutover) is the remaining gate to 2.0.0. + +--- + +## Self-Review + +**Spec coverage:** migration_map→T1; ensureSpace/per-source Spaces→T2; plans→T3; void1 (wiki/projects/tasks/journal/conversations/messages)→T4; karakeep (refs upsertByExternal)→T5; bookstack→T6; CLI+dry-run+verify→T7; run/verify→T8. Idempotency tested in T3–T6 (re-run → 0). Out-of-scope (8b cutover) deferred. Covered. + +**Placeholder scan:** T8 env values (tokens, paths) are runtime provisioning inputs, not code gaps. No TBDs in code. + +**Type consistency:** importers return `{created}` (plans/karakeep/bookstack) and `{pages,projects,tasks,conversations,messages}` (void1) — tests match. `map.seen(source,source_id,type)`/`map.record(...)` consistent T1 def + T3–T6 use. `ensureSpace(slug,name)→id` consistent. Repo calls match verified signatures (`pages.create({space_id,slug,title,body_md})`, `tasks.create({space_id,project_id,title,position})`, `refs.upsertByExternal({space_id,kind,url,title,source_kind,external_id})`, `messages.append(convId,{role,body})`).