Files
jerapah-flow/packages/server/owner-migrate.js
T
nsrb 5615d989fa feat(migration): migrate legacy owner data and update config reference handling
- Implemented a migration process to move resources from the legacy owner "default" to the new default owner "local", ensuring no data loss during the transition.
- Updated configuration reference handling to replace legacy `$VAR_`, `$SECRET_`, and `$CONTEXT_` prefixes with mustache-style `{{ vars.name }}`, `{{ secrets.name }}`, and `{{ context.name }}`.
- Enhanced YAML configuration files and scripts to reflect the new mustache syntax, improving consistency across the application.
- Added tests to validate the migration process and ensure proper handling of legacy references.
- Updated documentation to guide users on the new configuration reference format.
2026-08-30 05:32:48 +07:00

269 lines
7.4 KiB
JavaScript

import fs from "fs";
import path from "path";
import { DEFAULT_OWNER } from "@jerapah-flow/shared";
import { db } from "./db.js";
import { log } from "./logger.js";
import { EXAMPLE_WORKFLOWS_DIR, WORKFLOWS_DIR } from "./paths.js";
import { TRASH_WORKFLOWS_DIR } from "./workflow-trash.js";
import { rewriteLegacyConfigRefsInText } from "./config-ref-rewrite.js";
const LEGACY_OWNER = "default";
/**
* @param {string} dir
* @returns {string[]}
*/
function listFilesRecursive(dir) {
if (!fs.existsSync(dir)) return [];
/** @type {string[]} */
const out = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...listFilesRecursive(full));
else out.push(full);
}
return out;
}
/**
* Move files from legacy owner dir into DEFAULT_OWNER. Skip name collisions.
* @param {string} rootDir
* @returns {{ moved: number, skipped: number }}
*/
function mergeOwnerDir(rootDir) {
const fromDir = path.join(rootDir, LEGACY_OWNER);
const toDir = path.join(rootDir, DEFAULT_OWNER);
if (!fs.existsSync(fromDir)) return { moved: 0, skipped: 0 };
fs.mkdirSync(toDir, { recursive: true });
let moved = 0;
let skipped = 0;
for (const name of fs.readdirSync(fromDir)) {
const from = path.join(fromDir, name);
const to = path.join(toDir, name);
const st = fs.statSync(from);
if (!st.isFile()) {
skipped += 1;
log.warn({ from }, "owner migrate: skip non-file under legacy owner dir");
continue;
}
if (fs.existsSync(to)) {
skipped += 1;
log.warn(
{ from, to },
"owner migrate: skip YAML collision (local already has file)",
);
continue;
}
fs.renameSync(from, to);
moved += 1;
}
const remaining = fs.existsSync(fromDir) ? fs.readdirSync(fromDir) : [];
if (remaining.length === 0 && fs.existsSync(fromDir)) {
fs.rmdirSync(fromDir);
}
return { moved, skipped };
}
/**
* @param {string} filePath
* @returns {boolean}
*/
function rewriteFileInPlace(filePath) {
const raw = fs.readFileSync(filePath, "utf8");
const { text, changed } = rewriteLegacyConfigRefsInText(raw);
if (!changed) return false;
fs.writeFileSync(filePath, text, "utf8");
return true;
}
/**
* @param {string} rootDir
* @returns {number}
*/
function rewriteYamlTree(rootDir) {
let n = 0;
for (const file of listFilesRecursive(rootDir)) {
if (!/\.ya?ml$/i.test(file)) continue;
if (rewriteFileInPlace(file)) n += 1;
}
return n;
}
/**
* @param {import("knex").Knex} knex
* @param {string} table
* @param {"name" | "file" | null} uniqueCol
*/
async function migrateOwnerColumn(knex, table, uniqueCol) {
const legacyRows = await knex(table).where({ owner: LEGACY_OWNER }).select("*");
let moved = 0;
let skipped = 0;
for (const row of legacyRows) {
if (uniqueCol != null) {
const conflict = await knex(table)
.where({ owner: DEFAULT_OWNER, [uniqueCol]: row[uniqueCol] })
.first();
if (conflict) {
skipped += 1;
log.warn(
{ table, id: row.id, [uniqueCol]: row[uniqueCol] },
"owner migrate: skip row collision",
);
continue;
}
}
await knex(table).where({ id: row.id }).update({ owner: DEFAULT_OWNER });
moved += 1;
}
return { moved, skipped };
}
/**
* @param {import("knex").Knex} knex
*/
async function migrateWorkflowRuns(knex) {
const n = await knex("workflow_runs")
.where({ owner: LEGACY_OWNER })
.update({ owner: DEFAULT_OWNER });
return { moved: Number(n) || 0, skipped: 0 };
}
/**
* @param {import("knex").Knex} knex
*/
async function migrateWorkflowRevisionsOwner(knex) {
const n = await knex("workflow_revisions")
.where({ owner: LEGACY_OWNER })
.update({ owner: DEFAULT_OWNER });
return { moved: Number(n) || 0, skipped: 0 };
}
/**
* @param {import("knex").Knex} knex
*/
async function migrateScriptStateNamespaces(knex) {
const rows = await knex("script_state")
.where("namespace", "like", `${LEGACY_OWNER}/%`)
.select("namespace", "key");
let moved = 0;
let skipped = 0;
for (const row of rows) {
const nextNs = `${DEFAULT_OWNER}${row.namespace.slice(LEGACY_OWNER.length)}`;
const conflict = await knex("script_state")
.where({ namespace: nextNs, key: row.key })
.first();
if (conflict) {
skipped += 1;
log.warn(
{ namespace: row.namespace, key: row.key, nextNs },
"owner migrate: skip script_state collision",
);
continue;
}
await knex("script_state")
.where({ namespace: row.namespace, key: row.key })
.update({ namespace: nextNs });
moved += 1;
}
return { moved, skipped };
}
/**
* @param {import("knex").Knex} knex
*/
async function rewriteDbConfigStrings(knex) {
let profiles = 0;
let revisions = 0;
const profileRows = await knex("profiles").select("id", "config");
for (const row of profileRows) {
const { text, changed } = rewriteLegacyConfigRefsInText(String(row.config ?? ""));
if (!changed) continue;
await knex("profiles").where({ id: row.id }).update({ config: text });
profiles += 1;
}
const revisionRows = await knex("workflow_revisions").select("id", "content");
for (const row of revisionRows) {
const { text, changed } = rewriteLegacyConfigRefsInText(String(row.content ?? ""));
if (!changed) continue;
await knex("workflow_revisions").where({ id: row.id }).update({ content: text });
revisions += 1;
}
return { profiles, revisions };
}
/**
* One-shot: move owner `default` → `local`, rewrite prefix refs to mustache.
* Idempotent when there is no remaining `default` data / prefix refs.
*/
export async function migrateDefaultOwnerIfNeeded() {
const knex = db;
const yamlLive = mergeOwnerDir(WORKFLOWS_DIR);
const yamlTrash = mergeOwnerDir(TRASH_WORKFLOWS_DIR);
const variables = await migrateOwnerColumn(knex, "variables", "name");
const secrets = await migrateOwnerColumn(knex, "secrets", "name");
const profiles = await migrateOwnerColumn(knex, "profiles", "name");
const trash = await migrateOwnerColumn(knex, "workflow_trash", "file");
const runs = await migrateWorkflowRuns(knex);
const revisionsOwner = await migrateWorkflowRevisionsOwner(knex);
const scriptState = await migrateScriptStateNamespaces(knex);
const yamlRewritten =
rewriteYamlTree(path.join(WORKFLOWS_DIR, DEFAULT_OWNER)) +
rewriteYamlTree(path.join(TRASH_WORKFLOWS_DIR, DEFAULT_OWNER)) +
rewriteYamlTree(path.join(WORKFLOWS_DIR, LEGACY_OWNER)) +
rewriteYamlTree(path.join(TRASH_WORKFLOWS_DIR, LEGACY_OWNER));
let examplesRewritten = 0;
if (fs.existsSync(EXAMPLE_WORKFLOWS_DIR)) {
examplesRewritten = rewriteYamlTree(EXAMPLE_WORKFLOWS_DIR);
}
const dbStrings = await rewriteDbConfigStrings(knex);
const summary = {
yamlLive,
yamlTrash,
variables,
secrets,
profiles,
trash,
runs,
revisionsOwner,
scriptState,
yamlRewritten,
examplesRewritten,
dbStrings,
};
const touched =
yamlLive.moved +
yamlTrash.moved +
variables.moved +
secrets.moved +
profiles.moved +
trash.moved +
runs.moved +
revisionsOwner.moved +
scriptState.moved +
yamlRewritten +
examplesRewritten +
dbStrings.profiles +
dbStrings.revisions >
0;
if (touched) {
log.info(summary, "migrated owner default → local and rewrote config refs");
}
return summary;
}