From 681b091e4afafec9ec7e782f967d1b8344c7453e Mon Sep 17 00:00:00 2001 From: root Date: Thu, 4 Jun 2026 22:18:05 +1000 Subject: [PATCH] feat(migrate): migration_map idempotency ledger Co-Authored-By: Claude Opus 4.8 --- lib/db/migrations/018_migration_map.sql | 11 +++++++++++ lib/db/repos/migration_map.js | 15 +++++++++++++++ tests/db/migration_map.test.js | 22 ++++++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 lib/db/migrations/018_migration_map.sql create mode 100644 lib/db/repos/migration_map.js create mode 100644 tests/db/migration_map.test.js diff --git a/lib/db/migrations/018_migration_map.sql b/lib/db/migrations/018_migration_map.sql new file mode 100644 index 0000000..d4f0ed2 --- /dev/null +++ b/lib/db/migrations/018_migration_map.sql @@ -0,0 +1,11 @@ +-- 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) +); diff --git a/lib/db/repos/migration_map.js b/lib/db/repos/migration_map.js new file mode 100644 index 0000000..3efd46a --- /dev/null +++ b/lib/db/repos/migration_map.js @@ -0,0 +1,15 @@ +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]); +} diff --git a/tests/db/migration_map.test.js b/tests/db/migration_map.test.js new file mode 100644 index 0000000..d587ebf --- /dev/null +++ b/tests/db/migration_map.test.js @@ -0,0 +1,22 @@ +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); + }); +});