feat(migrate): migration_map idempotency ledger

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-04 22:18:05 +10:00
parent bbb90c12c6
commit 681b091e4a
3 changed files with 48 additions and 0 deletions

View File

@@ -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)
);

View File

@@ -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]);
}

View File

@@ -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);
});
});