From 9e059f8a7f36449a48fb6e701fa41b484a3b2519 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Thu, 18 Jun 2026 11:21:00 +0200 Subject: [PATCH] =?UTF-8?q?refactor(linkedin-studio):=20M0-4=20=E2=80=94?= =?UTF-8?q?=20migrate-data.mjs=20atomic+locked+idempotent=20(created,=20no?= =?UTF-8?q?t=20wired)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hooks/scripts/__tests__/migrate-data.test.mjs | 109 +++++++++++++ hooks/scripts/migrate-data.mjs | 153 ++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 hooks/scripts/__tests__/migrate-data.test.mjs create mode 100644 hooks/scripts/migrate-data.mjs diff --git a/hooks/scripts/__tests__/migrate-data.test.mjs b/hooks/scripts/__tests__/migrate-data.test.mjs new file mode 100644 index 0000000..036a32c --- /dev/null +++ b/hooks/scripts/__tests__/migrate-data.test.mjs @@ -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'); + }); +}); diff --git a/hooks/scripts/migrate-data.mjs b/hooks/scripts/migrate-data.mjs new file mode 100644 index 0000000..2116c9c --- /dev/null +++ b/hooks/scripts/migrate-data.mjs @@ -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}`); +}