refactor(linkedin-studio): M0-4 — migrate-data.mjs atomic+locked+idempotent (created, not wired)

This commit is contained in:
Kjell Tore Guttormsen 2026-06-18 11:21:00 +02:00
commit 9e059f8a7f
2 changed files with 262 additions and 0 deletions

View file

@ -0,0 +1,109 @@
import { describe, test, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import {
mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync, statSync,
} from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { migrateData } from '../migrate-data.mjs';
function writeDeep(p, content) {
mkdirSync(dirname(p), { recursive: true });
writeFileSync(p, content);
}
describe('migrateData', () => {
let pluginRoot;
let dataRoot;
const savedData = process.env.LINKEDIN_STUDIO_DATA;
afterEach(() => {
for (const d of [pluginRoot, dataRoot]) {
if (d && existsSync(d)) rmSync(d, { recursive: true, force: true });
}
pluginRoot = undefined;
dataRoot = undefined;
if (savedData === undefined) delete process.env.LINKEDIN_STUDIO_DATA;
else process.env.LINKEDIN_STUDIO_DATA = savedData;
});
function setup({ withRuntime = false, withScaffolds = false } = {}) {
pluginRoot = mkdtempSync(join(tmpdir(), 'lis-plugin-'));
dataRoot = mkdtempSync(join(tmpdir(), 'lis-data-'));
process.env.LINKEDIN_STUDIO_DATA = dataRoot; // never the real $HOME
if (withRuntime) {
writeDeep(join(pluginRoot, 'assets/voice-samples/authentic-voice-samples.local.md'), 'REAL VOICE 227 lines');
writeDeep(join(pluginRoot, 'assets/drafts/queue.json'), '{"version":1,"queue":[]}');
writeDeep(join(pluginRoot, 'assets/analytics/content-history.md'), '# history');
writeDeep(join(pluginRoot, 'assets/analytics/exports/content-2026-W22.csv'), 'a,b\n1,2\n');
writeDeep(join(pluginRoot, 'assets/analytics/posts/2026-05-26.json'), '{"x":1}');
writeDeep(join(pluginRoot, 'assets/analytics/weekly-reports/2026-W22.json'), '{"w":22}');
}
if (withScaffolds) {
writeDeep(join(pluginRoot, 'assets/examples/high-engagement-posts.md'), 'MY REAL POST');
writeDeep(join(pluginRoot, 'assets/audience-insights/demographics.md'), 'demo');
writeDeep(join(pluginRoot, 'assets/audience-insights/engagement-patterns.md'), 'patterns');
writeDeep(join(pluginRoot, 'assets/templates/my-post-templates.md'), 'my templates');
}
}
test('(a) gitignored runtime files MOVED: source gone, dest byte-equal, voice drops .local, content-history moved', () => {
setup({ withRuntime: true });
migrateData({ pluginRoot });
assert.ok(!existsSync(join(pluginRoot, 'assets/voice-samples/authentic-voice-samples.local.md')), 'voice source gone');
assert.ok(!existsSync(join(pluginRoot, 'assets/drafts/queue.json')), 'queue source gone');
assert.ok(!existsSync(join(pluginRoot, 'assets/analytics/content-history.md')), 'content-history source gone');
assert.ok(!existsSync(join(pluginRoot, 'assets/analytics/exports/content-2026-W22.csv')), 'export source gone');
assert.equal(readFileSync(join(dataRoot, 'voice-samples/authentic-voice-samples.md'), 'utf-8'), 'REAL VOICE 227 lines');
assert.equal(readFileSync(join(dataRoot, 'drafts/queue.json'), 'utf-8'), '{"version":1,"queue":[]}');
assert.ok(existsSync(join(dataRoot, 'analytics/content-history.md')));
assert.ok(existsSync(join(dataRoot, 'analytics/exports/content-2026-W22.csv')));
assert.ok(existsSync(join(dataRoot, 'analytics/posts/2026-05-26.json')));
assert.ok(existsSync(join(dataRoot, 'analytics/weekly-reports/2026-W22.json')));
assert.ok(existsSync(join(dataRoot, '.migrated')), 'marker written');
});
test('(b) tracked scaffolds COPIED: external instance written, tracked source preserved', () => {
setup({ withScaffolds: true });
migrateData({ pluginRoot });
assert.ok(existsSync(join(pluginRoot, 'assets/examples/high-engagement-posts.md')), 'scaffold source preserved');
assert.ok(existsSync(join(pluginRoot, 'assets/templates/my-post-templates.md')), 'scaffold source preserved');
assert.equal(readFileSync(join(dataRoot, 'examples/high-engagement-posts.md'), 'utf-8'), 'MY REAL POST');
assert.equal(readFileSync(join(dataRoot, 'audience-insights/demographics.md'), 'utf-8'), 'demo');
assert.equal(readFileSync(join(dataRoot, 'audience-insights/engagement-patterns.md'), 'utf-8'), 'patterns');
assert.equal(readFileSync(join(dataRoot, 'templates/my-post-templates.md'), 'utf-8'), 'my templates');
});
test('(c) re-run is an idempotent no-op (.migrated short-circuits, no throw, dest unchanged)', () => {
setup({ withRuntime: true, withScaffolds: true });
migrateData({ pluginRoot });
const voiceDest = join(dataRoot, 'voice-samples/authentic-voice-samples.md');
const before = readFileSync(voiceDest, 'utf-8');
const mtimeBefore = statSync(voiceDest).mtimeMs;
const r2 = migrateData({ pluginRoot });
assert.equal(r2.status, 'already-migrated');
assert.equal(readFileSync(voiceDest, 'utf-8'), before);
assert.equal(statSync(voiceDest).mtimeMs, mtimeBefore);
});
test('(d) empty fixture (no user data) completes without error', () => {
setup({});
const r = migrateData({ pluginRoot });
assert.ok(r);
assert.ok(existsSync(join(dataRoot, '.migrated')));
});
test('(e) collision: existing external file kept, in-plugin source NOT clobbered', () => {
setup({ withRuntime: true });
writeDeep(join(dataRoot, 'voice-samples/authentic-voice-samples.md'), 'EXTERNAL CANONICAL');
migrateData({ pluginRoot });
assert.equal(readFileSync(join(dataRoot, 'voice-samples/authentic-voice-samples.md'), 'utf-8'), 'EXTERNAL CANONICAL');
assert.equal(readFileSync(join(pluginRoot, 'assets/voice-samples/authentic-voice-samples.local.md'), 'utf-8'), 'REAL VOICE 227 lines');
});
});

View file

@ -0,0 +1,153 @@
#!/usr/bin/env node
// M0 migration: relocate per-user data from the plugin tree to the external data
// root (getDataRoot()). Created at Step 4, WIRED into session-start at Step 12 —
// this module only acts when called. Atomic, locked, idempotent, and
// external-canonical (never overwrites an existing external file).
//
// Two move-classes:
// (a) gitignored runtime data → atomic MOVE (source removed only after a
// verified copy).
// (b) tracked D2 scaffolds → COPY (tracked source preserved; Step 13 later
// templatizes the in-plugin file).
//
// MOVE_FILES/COPY_FILES below are the SINGLE SOURCE OF TRUTH for external paths.
// Step 7's personalization-score split MUST resolve the same scaffold dests
// (import COPY_FILES from here, or mirror it) — keep them in lockstep.
// External dest = in-plugin path minus 'assets/' (the brief 7.1 convention).
import {
existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync,
renameSync, unlinkSync, rmSync, readdirSync,
openSync, fsyncSync, closeSync,
} from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { getDataRoot } from './data-root.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
// [in-plugin (rel pluginRoot), external (rel dataRoot)] — atomic MOVE.
const MOVE_FILES = [
['assets/voice-samples/authentic-voice-samples.local.md', 'voice-samples/authentic-voice-samples.md'], // D6: drop .local
['assets/drafts/queue.json', 'drafts/queue.json'],
['assets/analytics/content-history.md', 'analytics/content-history.md'], // B1
['config/user-profile.local.md', 'profile/user-profile.md'], // D1 (expected-absent today)
];
// Whole-directory MOVE: every file under src → matching path under dest.
const MOVE_DIRS = [
['assets/analytics/exports', 'analytics/exports'],
['assets/analytics/posts', 'analytics/posts'],
['assets/analytics/weekly-reports', 'analytics/weekly-reports'],
['assets/analytics/monthly-reports', 'analytics/monthly-reports'], // expected-absent
];
// drafts/week-* runtime subdirs → external drafts/week-*.
const DRAFTS_WEEK = ['assets/drafts', 'drafts', /^week-/];
// move-class (b): tracked scaffolds → external instance (COPY, source kept).
const COPY_FILES = [
['assets/examples/high-engagement-posts.md', 'examples/high-engagement-posts.md'],
['assets/audience-insights/demographics.md', 'audience-insights/demographics.md'],
['assets/audience-insights/engagement-patterns.md', 'audience-insights/engagement-patterns.md'],
['assets/templates/my-post-templates.md', 'templates/my-post-templates.md'],
];
export { MOVE_FILES, COPY_FILES };
// Atomic copy: tmp in dest dir → fsync → byte-verify → rename. Returns false
// (without touching anything) if dest already exists — external is canonical.
function atomicCopy(src, dest) {
if (existsSync(dest)) return false;
mkdirSync(dirname(dest), { recursive: true });
const tmp = `${dest}.migrating.${process.pid}`;
copyFileSync(src, tmp);
const fd = openSync(tmp, 'r+');
try { fsyncSync(fd); } finally { closeSync(fd); }
if (!readFileSync(src).equals(readFileSync(tmp))) {
unlinkSync(tmp);
throw new Error(`migrate verify failed: ${src}`);
}
renameSync(tmp, dest);
return true;
}
function moveFile(srcAbs, destAbs, log) {
if (!existsSync(srcAbs)) return; // expected-absent → no-op
if (atomicCopy(srcAbs, destAbs)) {
unlinkSync(srcAbs); // remove source only after verified copy
log.moved.push(destAbs);
} else {
log.skipped.push(destAbs); // collision: external kept, source untouched
}
}
function copyFile(srcAbs, destAbs, log) {
if (!existsSync(srcAbs)) return;
if (atomicCopy(srcAbs, destAbs)) log.copied.push(destAbs);
else log.skipped.push(destAbs); // source always preserved for COPY class
}
function walkFiles(dir) {
const out = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) out.push(...walkFiles(p));
else if (e.isFile()) out.push(p);
}
return out;
}
function moveDir(srcDir, destDir, log) {
if (!existsSync(srcDir)) return;
for (const f of walkFiles(srcDir)) {
moveFile(f, join(destDir, f.slice(srcDir.length + 1)), log);
}
}
export function migrateData({ pluginRoot, dataRoot } = {}) {
pluginRoot = pluginRoot || join(__dirname, '..', '..');
dataRoot = dataRoot || getDataRoot();
const marker = join(dataRoot, '.migrated');
if (existsSync(marker)) return { status: 'already-migrated', moved: [], copied: [], skipped: [] };
mkdirSync(dataRoot, { recursive: true });
const lockDir = join(dataRoot, '.migrate.lock');
try {
mkdirSync(lockDir); // atomic; throws EEXIST if held
} catch (e) {
if (e.code === 'EEXIST') return { status: 'locked', moved: [], copied: [], skipped: [] };
throw e;
}
const log = { status: 'migrated', moved: [], copied: [], skipped: [] };
try {
for (const [src, dest] of MOVE_FILES) moveFile(join(pluginRoot, src), join(dataRoot, dest), log);
for (const [src, dest] of MOVE_DIRS) moveDir(join(pluginRoot, src), join(dataRoot, dest), log);
const [wSrc, wDest, wRe] = DRAFTS_WEEK;
const wSrcAbs = join(pluginRoot, wSrc);
if (existsSync(wSrcAbs)) {
for (const e of readdirSync(wSrcAbs, { withFileTypes: true })) {
if (e.isDirectory() && wRe.test(e.name)) {
moveDir(join(wSrcAbs, e.name), join(dataRoot, wDest, e.name), log);
}
}
}
for (const [src, dest] of COPY_FILES) copyFile(join(pluginRoot, src), join(dataRoot, dest), log);
// Marker LAST → the whole run is idempotently skippable on re-entry.
writeFileSync(marker, `linkedin-studio M0 data migration\nmoved=${log.moved.length} copied=${log.copied.length} skipped=${log.skipped.length}\n`);
} finally {
rmSync(lockDir, { recursive: true, force: true });
}
return log;
}
// Standalone: run against the real roots.
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const r = migrateData();
console.log(`Migration: ${r.status} — moved ${r.moved.length}, copied ${r.copied.length}, skipped ${r.skipped.length}`);
}