open-memex
Version:
Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.
121 lines (120 loc) • 5.07 kB
JavaScript
import fs from "node:fs";
import path from "node:path";
import { memoriesDirFor, memoriesDirPath } from "../paths.js";
import { db } from "./db.js";
import { iterMemoryFiles, readMemoryFile, serialize, timeToMs, atomicWriteTextSync, } from "./markdown.js";
import { syncScope } from "./sync.js";
import { PERSONAL_SCOPE } from "../scope.js";
/** True if this scope has at least one `.md` file on disk. Does not create
* the directory if it doesn't already exist. */
export function scopeHasFiles(scopeKey) {
const dir = memoriesDirPath(scopeKey);
if (!fs.existsSync(dir))
return false;
return fs.readdirSync(dir).some((f) => f.endsWith(".md"));
}
/**
* Move all memories from `fromKey` to `toKey`. Rewrites each file's
* `scope_key` (and optionally `project_name`) frontmatter. On id collision
* in the destination, `onConflict` decides:
* - "newer" (default): keep the file with the greater `updated_at`
* - "overwrite": always take the source
* - "skip": always keep the destination
*
* After moving files, clears any lingering FTS rows for `fromKey` and
* re-runs `syncScope(toKey)` so the index reflects reality. Removes the
* empty source directory when possible.
*/
export function migrateScope(fromKey, toKey, opts = {}) {
const log = opts.logger ?? (() => { });
const strategy = opts.onConflict ?? "newer";
const dryRun = !!opts.dryRun;
const stats = { moved: 0, skipped: 0, conflicts: 0, dryRun };
if (fromKey === toKey) {
throw new Error("migrate: --from and --to are identical");
}
const fromDir = memoriesDirPath(fromKey);
const toDir = memoriesDirFor(toKey);
if (!fs.existsSync(fromDir)) {
log(`no source directory: ${fromDir}`);
return stats;
}
for (const srcPath of iterMemoryFiles(fromKey)) {
const mf = readMemoryFile(srcPath);
if (!mf) {
log(`skip unparseable: ${srcPath}`);
stats.skipped++;
continue;
}
const dstPath = path.join(toDir, path.basename(srcPath));
let action = "write";
if (fs.existsSync(dstPath)) {
stats.conflicts++;
const dst = readMemoryFile(dstPath);
if (!dst) {
log(`conflict but destination unreadable, overwriting: ${dstPath}`);
}
else if (strategy === "skip") {
action = "skip";
log(`conflict: kept destination (--on-conflict=skip): ${dstPath}`);
}
else if (strategy === "overwrite") {
log(`conflict: overwriting destination (--on-conflict=overwrite): ${dstPath}`);
}
else if (timeToMs(dst.fm.updated_at) >= timeToMs(mf.fm.updated_at)) {
// Compare parsed instants, not RFC-3339 strings: lexicographic
// order breaks around fractional seconds ("…:00Z" > "…:00.005Z"),
// letting the older file win.
action = "skip";
log(`conflict: kept destination (newer updated_at): ${dstPath}`);
}
else {
log(`conflict: replaced destination (source newer): ${dstPath}`);
}
}
if (action === "skip") {
stats.skipped++;
continue;
}
// Re-key AND re-scope: scope/visibility must follow the destination
// (P0 review, 2026-10-03). Rewriting only scope_key produced files
// submit rejects ("scope is personal") or personal memories carrying
// visibility: internal into exports.
const toPersonal = toKey === PERSONAL_SCOPE.key;
const newFm = {
...mf.fm,
scope_key: toKey,
scope: toPersonal ? "personal" : "project",
visibility: toPersonal ? "private" : "internal",
project_name: opts.toProjectName ?? mf.fm.project_name,
};
const raw = serialize(newFm, mf.body);
if (!dryRun) {
atomicWriteTextSync(dstPath, raw);
// Only unlink source if it's a different path (paths differ because
// scope dir differs; belt-and-suspenders check).
if (path.resolve(srcPath) !== path.resolve(dstPath)) {
fs.unlinkSync(srcPath);
}
}
log(`${dryRun ? "would move" : "moved"}: ${srcPath} -> ${dstPath}`);
stats.moved++;
}
if (!dryRun) {
// Best-effort: remove now-empty source dir.
try {
if (fs.existsSync(fromDir) && fs.readdirSync(fromDir).length === 0) {
fs.rmdirSync(fromDir);
}
}
catch {
/* ignore */
}
// Drop any remaining FTS/memories rows for the old scope, then rebuild
// the destination index from disk. `syncScope` also handles removals for
// the destination scope if a conflict-skip left stale rows behind.
db().prepare(`DELETE FROM memories WHERE scope_key = ?`).run(fromKey);
syncScope(toKey);
}
return stats;
}