# 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})`).