// Automatic upgrade migration: legacy Mate memory and knowledge files into the // Clay-wide append-only Knowledge backend. // // This module owns discovery, the run lock, and versioned state. The actual // source-to-record work lives in mate-knowledge-sync.js, which is the same code // the live write-through bridge uses, so a startup import and a post-mutation // mirror can never disagree about how a source is represented. // // Properties this migration guarantees: // - Non-destructive. Legacy files are read and never written, renamed, or // removed. They stay the live source for the existing read paths. // - Idempotent. Every source unit carries a stable import key, so a restart, // a partial run, or a deleted state file re-imports nothing. // - Overlap-safe. A run lock keeps two daemons from importing at once, and // the importer folds away a duplicate even if the lock is bypassed. // - Torn-input tolerant. An unparseable journal line is counted and skipped; // it never aborts the run. // - Honest. A source that cannot be represented is reported as a failure // rather than silently truncated or skipped. // - Isolated. Each user's Mates land in their own scope, derived from the // Mate storage root that already separates users on disk. var fs = require("fs"); var path = require("path"); var config = require("./config"); var recordStore = require("./knowledge-record-store"); var mateSync = require("./mate-knowledge-sync"); var MIGRATION_NAME = "mate-knowledge"; var MIGRATION_VERSION = 2; var LOCK_STALE_MS = 10 * 60 * 1000; function knowledgeRoot() { return recordStore.knowledgeRoot(); } function statePath(baseDir) { return path.join(baseDir || knowledgeRoot(), "migration-state.json"); } function lockPath(baseDir) { return path.join(baseDir || knowledgeRoot(), ".mate-migration.lock"); } function isMateDirName(name) { return typeof name === "string" && name.indexOf("mate_") === 0; } function safeReadDir(dir) { try { return fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return []; } } function mateNames(matesRoot) { var names = {}; try { var parsed = JSON.parse(fs.readFileSync(path.join(matesRoot, "mates.json"), "utf8")); var list = (parsed && parsed.mates) || []; for (var i = 0; i < list.length; i++) { if (list[i] && list[i].id) names[list[i].id] = list[i].name || null; } } catch (e) { // A missing or corrupt registry only costs provenance detail. } return names; } function collectFromRoot(matesRoot, out) { var names = mateNames(matesRoot); var entries = safeReadDir(matesRoot); for (var i = 0; i < entries.length; i++) { if (!entries[i].isDirectory() || !isMateDirName(entries[i].name)) continue; var scope = mateSync.scopeForMateDir(path.join(matesRoot, entries[i].name)); if (!scope) continue; scope.mateName = names[entries[i].name] || null; out.push(scope); } } // Both storage layouts are walked directly rather than through the Mate // registry, so a Mate whose registry entry is missing or corrupt still gets // its durable content migrated. // single-user: {CONFIG_DIR}/mates/mate_* // multi-user: {CONFIG_DIR}/mates/{userId}/mate_* // OS users: roots supplied by the caller function discoverMates(opts) { var options = opts || {}; var out = []; var matesHome = options.matesHome || path.join(config.CONFIG_DIR, "mates"); collectFromRoot(matesHome, out); var entries = safeReadDir(matesHome); for (var i = 0; i < entries.length; i++) { if (!entries[i].isDirectory() || isMateDirName(entries[i].name)) continue; collectFromRoot(path.join(matesHome, entries[i].name), out); } var roots = options.extraRoots || []; for (var r = 0; r < roots.length; r++) { if (!roots[r] || !roots[r].matesRoot) continue; collectFromRoot(roots[r].matesRoot, out); } return out; } // --- Versioned state ------------------------------------------------- // State is a fast-path hint and an audit trail, never the authority. Deleting // it makes the next run re-scan and import nothing, because the import keys // already present in the records are what actually decide. function loadState(baseDir) { try { var parsed = JSON.parse(fs.readFileSync(statePath(baseDir), "utf8")); if (parsed && parsed.version === MIGRATION_VERSION && parsed.scopes) return parsed; } catch (e) {} return { version: MIGRATION_VERSION, migration: MIGRATION_NAME, scopes: {}, lastRunAt: 0 }; } function saveState(baseDir, state) { var target = statePath(baseDir); try { fs.mkdirSync(path.dirname(target), { recursive: true }); var temporary = target + ".tmp." + process.pid; fs.writeFileSync(temporary, JSON.stringify(state, null, 2) + "\n"); config.chmodSafe(temporary, 0o600); fs.renameSync(temporary, target); } catch (e) { // A migration that cannot record state still completed its imports. } } // --- Run lock --------------------------------------------------------- function writeLock(target) { var payload = JSON.stringify({ pid: process.pid, startedAt: Date.now() }) + "\n"; var fd = fs.openSync(target, "wx"); try { fs.writeSync(fd, Buffer.from(payload, "utf8")); } finally { fs.closeSync(fd); } return target; } // A live holder always wins, no matter how long it has been running. Stealing // a lock from a running daemon would let two daemons import at once and would // let the first one unlink the second one's lock on completion. Age alone can // only reclaim a lock whose holder is gone or unidentifiable. function acquireLock(baseDir) { var target = lockPath(baseDir); try { fs.mkdirSync(path.dirname(target), { recursive: true }); } catch (e) {} try { return writeLock(target); } catch (e) { if (!e || e.code !== "EEXIST") return null; } var holder = null; try { holder = JSON.parse(fs.readFileSync(target, "utf8")); } catch (e2) {} var pid = holder && holder.pid; if (pid && config.isPidAlive(pid)) return null; // No identifiable holder: only reclaim once it is unambiguously abandoned. if (!pid) { var age = Date.now() - ((holder && holder.startedAt) || 0); if (age < LOCK_STALE_MS) return null; } try { fs.unlinkSync(target); return writeLock(target); } catch (e3) { return null; } } function releaseLock(target) { if (!target) return; try { fs.unlinkSync(target); } catch (e) {} } // --- Entry point ------------------------------------------------------- function run(opts) { var options = opts || {}; var baseDir = options.baseDir || knowledgeRoot(); var summary = mateSync.newSummary(); summary.migration = MIGRATION_NAME; summary.version = MIGRATION_VERSION; summary.scopes = 0; var lock = acquireLock(baseDir); if (!lock) { summary.skipped = "locked"; return summary; } try { var mates = discoverMates(options); var state = loadState(baseDir); for (var i = 0; i < mates.length; i++) { var before = summary.failed; try { mateSync.reconcileMate({ scope: mates[i], mateName: mates[i].mateName, baseDir: baseDir, actor: { type: "migration" }, summary: summary, }); } catch (e) { summary.failed++; summary.errors.push({ scopeId: mates[i].scopeId, message: e.message }); } summary.scopes++; state.scopes[mates[i].scopeId] = { version: MIGRATION_VERSION, mateId: mates[i].mateId, userId: mates[i].userId, completedAt: Date.now(), // A scope with a failure is recorded as incomplete so it is never // reported as migrated while a source is missing content. complete: summary.failed === before, }; } state.lastRunAt = Date.now(); saveState(baseDir, state); return summary; } finally { releaseLock(lock); } } // Startup wrapper: never throws, never blocks boot, logs only when it did work // or when a source could not be represented. function runAtStartup(opts) { var summary; try { summary = run(opts); } catch (e) { console.error("[knowledge-migration] Mate knowledge migration failed:", e.message); return null; } if (summary.skipped === "locked") return summary; if (summary.created > 0 || summary.revised > 0 || summary.deleted > 0) { console.log("[knowledge-migration] Mate knowledge: " + summary.created + " imported, " + summary.revised + " revised, " + summary.deleted + " tombstoned, " + summary.unchanged + " unchanged across " + summary.scopes + " scope(s)"); } if (summary.failed > 0) { console.error("[knowledge-migration] " + summary.failed + " source(s) could not be migrated; " + "legacy files remain authoritative. First error: " + (summary.errors[0] && summary.errors[0].message)); } return summary; } module.exports = { MIGRATION_NAME: MIGRATION_NAME, MIGRATION_VERSION: MIGRATION_VERSION, LOCK_STALE_MS: LOCK_STALE_MS, IDENTITY_FILES: mateSync.IDENTITY_FILES, EXCLUDED_FILES: mateSync.EXCLUDED_FILES, JOURNAL_KINDS: mateSync.JOURNAL_KINDS, ownerKeyForRoot: mateSync.ownerKeyForRoot, scopeIdFor: mateSync.scopeIdFor, scopeForMateDir: mateSync.scopeForMateDir, sourceKindFor: mateSync.sourceKindFor, discoverMates: discoverMates, statePath: statePath, lockPath: lockPath, loadState: loadState, acquireLock: acquireLock, releaseLock: releaseLock, run: run, runAtStartup: runAtStartup, };