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.
This commit is contained in:
2026-08-30 05:32:48 +07:00
parent e2e70a5aad
commit 5615d989fa
24 changed files with 775 additions and 208 deletions
@@ -66,7 +66,8 @@ export function SecretEditorModal({ mode, initial, onClose, onSaved }) {
</label>
<p className="text-xs opacity-60">
Values are encrypted at rest and never shown again after save. Values shorter than 8
characters are not redacted from logs.
characters are not redacted from logs. In workflows use{" "}
<span className="font-mono">{"{{ secrets.name }}"}</span> (quote in YAML).
</p>
{upsert.isError ? (
<p className="text-error text-sm">{errorMessage(upsert.error)}</p>
@@ -129,7 +129,9 @@ export function VariableEditorModal({ mode, initial, onClose, onSaved }) {
</label>
<p className="text-xs opacity-60">
Stored in plaintext. Use Secrets for credentials. In workflows use{" "}
<span className="font-mono">$VAR_name</span> as a whole field.
<span className="font-mono">{"{{ vars.name }}"}</span> (quote strings that
start with <span className="font-mono">{"{"}</span>). Nested:{" "}
<span className="font-mono">{"{{ context.user.id }}"}</span>.
</p>
{formError ? <p className="text-error text-sm">{formError}</p> : null}
{upsert.isError ? (
@@ -4,22 +4,40 @@ import { LuEye, LuEyeOff, LuExternalLink } from "react-icons/lu";
import { useVariables } from "../../api/hooks.js";
const VAR_PEEK_MAX = 48;
const MUSTACHE_RE =
/\{\{\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)*)\s*\}\}/g;
const LEGACY_PREFIX_RE = /^\s*\$(VAR|SECRET|CONTEXT)_([A-Za-z0-9._-]*)\s*$/;
const CONFIG_REF_PREFIXES = [
{ prefix: "$SECRET_", label: "secret" },
{ prefix: "$CONTEXT_", label: "context" },
{ prefix: "$VAR_", label: "variable" },
];
/**
* @param {unknown} value
* @returns {{
* kind: "mustache" | "legacy",
* path?: string,
* root?: string,
* name?: string,
* legacyKind?: string,
* legacyName?: string,
* } | null}
*/
function describeConfigRef(value) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
for (const { prefix, label } of CONFIG_REF_PREFIXES) {
if (trimmed.startsWith(prefix) && trimmed.length > prefix.length) {
return { label, name: trimmed.slice(prefix.length), kind: prefix };
}
const legacy = LEGACY_PREFIX_RE.exec(trimmed);
if (legacy) {
const legacyKind =
legacy[1] === "VAR" ? "variable" : legacy[1] === "SECRET" ? "secret" : "context";
return {
kind: "legacy",
legacyKind,
legacyName: legacy[2] ?? "",
};
}
return null;
MUSTACHE_RE.lastIndex = 0;
const match = MUSTACHE_RE.exec(trimmed);
if (!match) return null;
const path = match[1];
const root = path.split(".")[0];
return { kind: "mustache", path, root, name: path.slice(root.length + 1) };
}
function formatVarDisplay(value) {
@@ -33,7 +51,7 @@ function truncatePeek(text, maxLen = VAR_PEEK_MAX) {
return `${text.slice(0, Math.max(0, maxLen - 1))}…`;
}
/** Edit-time lookup for `$VAR_` against workflow owner (not a runtime guarantee). */
/** Edit-time lookup for `{{ vars.name }}` against workflow owner (not a runtime guarantee). */
function lookupVariable(variables, owner, name) {
const list = Array.isArray(variables) ? variables : [];
const match = list.find((v) => v.owner === owner && v.name === name);
@@ -62,7 +80,7 @@ function variablesDeepLink({ owner, name, missing }) {
export function ConfigRefHint({ value, owner }) {
const ref = describeConfigRef(value);
const isVar = ref?.kind === "$VAR_";
const isVar = ref?.kind === "mustache" && ref.root === "vars" && Boolean(ref.name);
const { data: variables = [], isPending } = useVariables(undefined, { enabled: isVar });
const [revealed, setRevealed] = useState(false);
@@ -72,10 +90,32 @@ export function ConfigRefHint({ value, owner }) {
if (!ref) return null;
if (ref.kind === "legacy") {
const hint =
ref.legacyKind === "variable"
? `{{ vars.${ref.legacyName || "name"} }}`
: ref.legacyKind === "secret"
? `{{ secrets.${ref.legacyName || "name"} }}`
: `{{ context.${ref.legacyName || "name"} }}`;
return (
<p className="text-xs text-error">
legacy ${ref.legacyKind} ref — use <span className="font-mono">{hint}</span>
</p>
);
}
if (!isVar) {
const label =
ref.root === "secrets"
? "secret"
: ref.root === "context"
? "context"
: ref.root === "data"
? "data"
: "expression";
return (
<p className="text-xs opacity-60">
from {ref.label} <span className="font-mono">{ref.name}</span>
from {label} <span className="font-mono">{ref.path}</span>
</p>
);
}
+1 -2
View File
@@ -1,2 +1 @@
/** Default owner folder for new resources (latent tenant id). */
export const DEFAULT_OWNER = "local";
export { DEFAULT_OWNER } from "@jerapah-flow/shared";
+1 -1
View File
@@ -179,7 +179,7 @@ export function VariablesPage() {
title={confirmDelete ? `Delete ${confirmDelete.name}?` : ""}
message={
confirmDelete
? `This cannot be undone. Workflows that reference $VAR_${confirmDelete.name} will fail.`
? `This cannot be undone. Workflows that reference {{ vars.${confirmDelete.name} }} will fail.`
: ""
}
error={del.isError ? errorMessage(del.error) : null}