@meshxdata/fops 0.1.31 → 0.1.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of @meshxdata/fops might be problematic. Click here for more details.
- package/CHANGELOG.md +372 -0
- package/package.json +1 -1
- package/src/commands/lifecycle.js +16 -0
- package/src/electron/icon.png +0 -0
- package/src/electron/main.js +24 -0
- package/src/plugins/bundled/fops-plugin-embeddings/index.js +9 -0
- package/src/plugins/bundled/fops-plugin-embeddings/lib/indexer.js +1 -1
- package/src/plugins/bundled/fops-plugin-file/demo/landscape.yaml +67 -0
- package/src/plugins/bundled/fops-plugin-file/demo/orders_bad.csv +6 -0
- package/src/plugins/bundled/fops-plugin-file/demo/orders_good.csv +7 -0
- package/src/plugins/bundled/fops-plugin-file/demo/orders_reference.csv +6 -0
- package/src/plugins/bundled/fops-plugin-file/demo/orders_renamed.aligned.csv +6 -0
- package/src/plugins/bundled/fops-plugin-file/demo/orders_renamed.csv +6 -0
- package/src/plugins/bundled/fops-plugin-file/demo/rules.json +8 -0
- package/src/plugins/bundled/fops-plugin-file/demo/run.sh +110 -0
- package/src/plugins/bundled/fops-plugin-file/index.js +140 -24
- package/src/plugins/bundled/fops-plugin-file/lib/embed-index.js +7 -0
- package/src/plugins/bundled/fops-plugin-file/lib/match.js +11 -4
- package/src/plugins/bundled/fops-plugin-foundation/index.js +1715 -2
- package/src/plugins/bundled/fops-plugin-foundation/lib/align.js +183 -0
- package/src/plugins/bundled/fops-plugin-foundation/lib/apply.js +83 -41
- package/src/plugins/bundled/fops-plugin-foundation/lib/client.js +40 -4
- package/src/plugins/bundled/fops-plugin-foundation/lib/stack-apply.js +4 -1
- package/src/plugins/bundled/fops-plugin-foundation/lib/tools-write.js +46 -0
- package/src/plugins/bundled/fops-plugin-foundation-graphql/index.js +39 -1
- package/src/plugins/bundled/fops-plugin-foundation-graphql/lib/graphql/resolvers/data-object.js +9 -6
- package/src/plugins/bundled/fops-plugin-foundation-graphql/lib/graphql/resolvers/data-product.js +9 -6
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Column alignment: maps source CSV column names to expected schema column names.
|
|
3
|
+
* Three-pass: exact → embedding (if model available) → Levenshtein ratio fallback.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function normalizeColName(s) {
|
|
7
|
+
return String(s || "")
|
|
8
|
+
.toLowerCase()
|
|
9
|
+
.replace(/\s+/g, "_")
|
|
10
|
+
.replace(/[^a-z0-9_]/g, "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function cosineSimilarity(a, b) {
|
|
14
|
+
let dot = 0, na = 0, nb = 0;
|
|
15
|
+
for (let i = 0; i < a.length; i++) {
|
|
16
|
+
dot += a[i] * b[i];
|
|
17
|
+
na += a[i] * a[i];
|
|
18
|
+
nb += b[i] * b[i];
|
|
19
|
+
}
|
|
20
|
+
const denom = Math.sqrt(na) * Math.sqrt(nb);
|
|
21
|
+
return denom === 0 ? 0 : dot / denom;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function levenshteinRatio(a, b) {
|
|
25
|
+
const x = String(a || "");
|
|
26
|
+
const y = String(b || "");
|
|
27
|
+
if (x === y) return 1;
|
|
28
|
+
const m = x.length, n = y.length;
|
|
29
|
+
if (m === 0 || n === 0) return 0;
|
|
30
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
31
|
+
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
|
32
|
+
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
|
33
|
+
for (let i = 1; i <= m; i++) {
|
|
34
|
+
for (let j = 1; j <= n; j++) {
|
|
35
|
+
const cost = x[i - 1] === y[j - 1] ? 0 : 1;
|
|
36
|
+
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return 1 - dp[m][n] / Math.max(m, n);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Align source column names to expected column names using three passes.
|
|
44
|
+
*
|
|
45
|
+
* @param {string[]} sourceColumns
|
|
46
|
+
* @param {string[]} expectedColumns
|
|
47
|
+
* @param {{ embedTexts?: Function, threshold?: number }} opts
|
|
48
|
+
* @returns {Promise<{ mappings, unmappedSource, unmappedExpected, suggestedTransformation? }>}
|
|
49
|
+
*/
|
|
50
|
+
export async function alignColumns(sourceColumns, expectedColumns, { embedTexts, threshold = 0.7 } = {}) {
|
|
51
|
+
const mappings = [];
|
|
52
|
+
const unmappedSrc = new Set(sourceColumns);
|
|
53
|
+
const unmappedExp = new Set(expectedColumns);
|
|
54
|
+
|
|
55
|
+
// Pass 1 — Exact (normalized)
|
|
56
|
+
for (const src of sourceColumns) {
|
|
57
|
+
const normSrc = normalizeColName(src);
|
|
58
|
+
for (const exp of [...unmappedExp]) {
|
|
59
|
+
if (normalizeColName(exp) === normSrc) {
|
|
60
|
+
mappings.push({ sourceColumn: src, expectedColumn: exp, confidence: 1.0, method: "exact" });
|
|
61
|
+
unmappedSrc.delete(src);
|
|
62
|
+
unmappedExp.delete(exp);
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Helpers shared by embedding and Levenshtein passes
|
|
69
|
+
// Expand common opaque abbreviations so the embedding sees real words
|
|
70
|
+
const ABBREV = {
|
|
71
|
+
ccy: "currency code", cust: "customer", amt: "amount", qty: "quantity",
|
|
72
|
+
dt: "date", desc: "description", num: "number", addr: "address",
|
|
73
|
+
prod: "product", yr: "year", mo: "month", cnt: "count",
|
|
74
|
+
avg: "average", pct: "percent", dept: "department",
|
|
75
|
+
};
|
|
76
|
+
// Suffixes that strongly constrain a column's semantic role
|
|
77
|
+
const SEMANTIC_SUFFIXES = new Set([
|
|
78
|
+
"id", "date", "status", "name", "type", "code", "key",
|
|
79
|
+
"region", "currency", "amount", "count", "number",
|
|
80
|
+
]);
|
|
81
|
+
const toHuman = (s) => {
|
|
82
|
+
const parts = normalizeColName(s).split("_").filter(Boolean);
|
|
83
|
+
return parts.map((p) => ABBREV[p] || p).join(" ");
|
|
84
|
+
};
|
|
85
|
+
const getSuffix = (s) => {
|
|
86
|
+
const parts = normalizeColName(s).split("_").filter(Boolean);
|
|
87
|
+
const last = parts[parts.length - 1];
|
|
88
|
+
return SEMANTIC_SUFFIXES.has(last) ? last : null;
|
|
89
|
+
};
|
|
90
|
+
// Adjust a raw similarity score by structural compatibility:
|
|
91
|
+
// containment: source name IS the last token of expected → +0.30 (e.g. "id" → "order_id")
|
|
92
|
+
// same suffix: both end with same known suffix → +0.10 (confirms alignment)
|
|
93
|
+
// suffix mismatch: both have different known suffixes → -0.15 (strong signal of mismatch)
|
|
94
|
+
const adjustScore = (raw, src, exp) => {
|
|
95
|
+
let score = raw;
|
|
96
|
+
const ss = getSuffix(src), es = getSuffix(exp);
|
|
97
|
+
if (ss && es) score = ss === es ? Math.min(1, score + 0.1) : Math.max(0, score - 0.15);
|
|
98
|
+
// Containment: "id" IS the last token of "order_id" → strong structural match
|
|
99
|
+
const normSrc = normalizeColName(src);
|
|
100
|
+
const expParts = normalizeColName(exp).split("_").filter(Boolean);
|
|
101
|
+
if (expParts.length > 1 && expParts[expParts.length - 1] === normSrc) {
|
|
102
|
+
score = Math.min(1, score + 0.3);
|
|
103
|
+
}
|
|
104
|
+
return score;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Pass 2 — Embedding (if embedTexts provided)
|
|
108
|
+
if (typeof embedTexts === "function" && unmappedSrc.size > 0 && unmappedExp.size > 0) {
|
|
109
|
+
try {
|
|
110
|
+
const srcList = [...unmappedSrc];
|
|
111
|
+
const expList = [...unmappedExp];
|
|
112
|
+
const srcTexts = srcList.map(toHuman);
|
|
113
|
+
const expTexts = expList.map(toHuman);
|
|
114
|
+
|
|
115
|
+
const allVecs = await embedTexts([...srcTexts, ...expTexts]);
|
|
116
|
+
if (allVecs && allVecs.length === srcTexts.length + expTexts.length) {
|
|
117
|
+
const srcVecs = allVecs.slice(0, srcTexts.length);
|
|
118
|
+
const expVecs = allVecs.slice(srcTexts.length);
|
|
119
|
+
|
|
120
|
+
// Build score matrix and collect all pairs above threshold
|
|
121
|
+
const candidates = [];
|
|
122
|
+
for (let i = 0; i < srcList.length; i++) {
|
|
123
|
+
for (let j = 0; j < expList.length; j++) {
|
|
124
|
+
const score = adjustScore(cosineSimilarity(srcVecs[i], expVecs[j]), srcList[i], expList[j]);
|
|
125
|
+
if (score >= threshold) candidates.push({ i, j, score });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Greedy assign highest-confidence pairs first
|
|
129
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
130
|
+
const usedSrc = new Set(), usedExp = new Set();
|
|
131
|
+
for (const { i, j, score } of candidates) {
|
|
132
|
+
if (usedSrc.has(i) || usedExp.has(j)) continue;
|
|
133
|
+
usedSrc.add(i);
|
|
134
|
+
usedExp.add(j);
|
|
135
|
+
mappings.push({ sourceColumn: srcList[i], expectedColumn: expList[j], confidence: score, method: "embedding" });
|
|
136
|
+
unmappedSrc.delete(srcList[i]);
|
|
137
|
+
unmappedExp.delete(expList[j]);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} catch { /* model not ready — fall through to pass 3 */ }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Pass 3 — Levenshtein ratio fallback
|
|
144
|
+
if (unmappedSrc.size > 0 && unmappedExp.size > 0) {
|
|
145
|
+
const srcList = [...unmappedSrc];
|
|
146
|
+
const expList = [...unmappedExp];
|
|
147
|
+
const candidates = [];
|
|
148
|
+
for (const src of srcList) {
|
|
149
|
+
for (const exp of expList) {
|
|
150
|
+
const score = adjustScore(levenshteinRatio(normalizeColName(src), normalizeColName(exp)), src, exp);
|
|
151
|
+
if (score >= threshold) candidates.push({ src, exp, score });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
155
|
+
const usedSrc = new Set(), usedExp = new Set();
|
|
156
|
+
for (const { src, exp, score } of candidates) {
|
|
157
|
+
if (usedSrc.has(src) || usedExp.has(exp)) continue;
|
|
158
|
+
usedSrc.add(src);
|
|
159
|
+
usedExp.add(exp);
|
|
160
|
+
mappings.push({ sourceColumn: src, expectedColumn: exp, confidence: score, method: "levenshtein" });
|
|
161
|
+
unmappedSrc.delete(src);
|
|
162
|
+
unmappedExp.delete(exp);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const result = {
|
|
167
|
+
mappings,
|
|
168
|
+
unmappedSource: [...unmappedSrc],
|
|
169
|
+
unmappedExpected: [...unmappedExp],
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
if (mappings.length > 0) {
|
|
173
|
+
const renameMapping = {};
|
|
174
|
+
for (const { sourceColumn, expectedColumn } of mappings) {
|
|
175
|
+
if (sourceColumn !== expectedColumn) renameMapping[sourceColumn] = expectedColumn;
|
|
176
|
+
}
|
|
177
|
+
if (Object.keys(renameMapping).length > 0) {
|
|
178
|
+
result.suggestedTransformation = { transform: "rename_column", mapping: renameMapping };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
@@ -324,8 +324,9 @@ export function parseYamlContent(content) {
|
|
|
324
324
|
throw err;
|
|
325
325
|
}
|
|
326
326
|
}
|
|
327
|
-
|
|
328
|
-
|
|
327
|
+
const hasRootEntities = ENTITY_ORDER.slice(1).some((t) => parsed?.[t]);
|
|
328
|
+
if (!parsed || (!parsed.mesh && !hasRootEntities)) {
|
|
329
|
+
throw new Error("No mesh block or entity blocks found in YAML content");
|
|
329
330
|
}
|
|
330
331
|
return { operations: parseYamlOperations(parsed), fileName: "inline.yaml" };
|
|
331
332
|
}
|
|
@@ -348,8 +349,9 @@ export function parseYamlLandscape(filePath) {
|
|
|
348
349
|
}
|
|
349
350
|
}
|
|
350
351
|
|
|
351
|
-
|
|
352
|
-
|
|
352
|
+
const hasRootEntities = ENTITY_ORDER.slice(1).some((t) => parsed?.[t]);
|
|
353
|
+
if (!parsed || (!parsed.mesh && !hasRootEntities)) {
|
|
354
|
+
throw new Error("No mesh block or entity blocks found in YAML file");
|
|
353
355
|
}
|
|
354
356
|
|
|
355
357
|
return { operations: parseYamlOperations(parsed), fileName: path.basename(filePath) };
|
|
@@ -377,6 +379,43 @@ function parseYamlOperations(parsed) {
|
|
|
377
379
|
ENTITY_ORDER.slice(1).map((type) => [type, parsed[type]]),
|
|
378
380
|
);
|
|
379
381
|
|
|
382
|
+
function parseEntityBlock(entityType, entityName, eBlock, meshName) {
|
|
383
|
+
const props = {};
|
|
384
|
+
const refs = {};
|
|
385
|
+
const op = { type: entityType, name: entityName, props, mesh: meshName || null };
|
|
386
|
+
|
|
387
|
+
for (const [k, v] of Object.entries(eBlock)) {
|
|
388
|
+
if (k === "connection" && typeof v === "object" && !Array.isArray(v)) {
|
|
389
|
+
op.connection = v;
|
|
390
|
+
} else if (k === "config" && typeof v === "object" && !Array.isArray(v)) {
|
|
391
|
+
op.config = v;
|
|
392
|
+
} else if (k === "template") {
|
|
393
|
+
op.template = v;
|
|
394
|
+
} else if (k === "select_columns") {
|
|
395
|
+
op.selectColumns = v;
|
|
396
|
+
} else if (k === "cast_changes") {
|
|
397
|
+
op.castChanges = Array.isArray(v) ? v : [v];
|
|
398
|
+
} else if (k === "schema" && Array.isArray(v)) {
|
|
399
|
+
op.schema = v;
|
|
400
|
+
} else if (k === "pipeline" && typeof v === "object" && !Array.isArray(v)) {
|
|
401
|
+
op.pipeline = parseYamlPipeline(v);
|
|
402
|
+
} else if (k === "secret" && typeof v === "object" && !Array.isArray(v)) {
|
|
403
|
+
op.secret = v;
|
|
404
|
+
} else if (REF_TO_TYPE[k]) {
|
|
405
|
+
const ref = parseYamlRef(v);
|
|
406
|
+
if (ref) refs[k] = ref;
|
|
407
|
+
} else if (!SPECIAL_KEYS.has(k)) {
|
|
408
|
+
props[k] = v;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (refs.system) op.parent = refs.system;
|
|
413
|
+
else if (refs.source) op.parent = refs.source;
|
|
414
|
+
else if (refs.object) op.parent = refs.object;
|
|
415
|
+
|
|
416
|
+
return op;
|
|
417
|
+
}
|
|
418
|
+
|
|
380
419
|
for (const [meshName, meshBlock] of meshEntries) {
|
|
381
420
|
if (!meshBlock || typeof meshBlock !== "object" || Array.isArray(meshBlock)) continue;
|
|
382
421
|
const meshProps = {};
|
|
@@ -397,40 +436,19 @@ function parseYamlOperations(parsed) {
|
|
|
397
436
|
|
|
398
437
|
for (const [entityName, eBlock] of Object.entries(group)) {
|
|
399
438
|
if (!eBlock || typeof eBlock !== "object" || Array.isArray(eBlock)) continue;
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
for (const [k, v] of Object.entries(eBlock)) {
|
|
405
|
-
if (k === "connection" && typeof v === "object" && !Array.isArray(v)) {
|
|
406
|
-
op.connection = v;
|
|
407
|
-
} else if (k === "config" && typeof v === "object" && !Array.isArray(v)) {
|
|
408
|
-
op.config = v;
|
|
409
|
-
} else if (k === "template") {
|
|
410
|
-
op.template = v;
|
|
411
|
-
} else if (k === "select_columns") {
|
|
412
|
-
op.selectColumns = v;
|
|
413
|
-
} else if (k === "cast_changes") {
|
|
414
|
-
op.castChanges = Array.isArray(v) ? v : [v];
|
|
415
|
-
} else if (k === "schema" && Array.isArray(v)) {
|
|
416
|
-
op.schema = v;
|
|
417
|
-
} else if (k === "pipeline" && typeof v === "object" && !Array.isArray(v)) {
|
|
418
|
-
op.pipeline = parseYamlPipeline(v);
|
|
419
|
-
} else if (k === "secret" && typeof v === "object" && !Array.isArray(v)) {
|
|
420
|
-
op.secret = v;
|
|
421
|
-
} else if (REF_TO_TYPE[k]) {
|
|
422
|
-
const ref = parseYamlRef(v);
|
|
423
|
-
if (ref) refs[k] = ref;
|
|
424
|
-
} else if (!SPECIAL_KEYS.has(k)) {
|
|
425
|
-
props[k] = v;
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
if (refs.system) op.parent = refs.system;
|
|
430
|
-
else if (refs.source) op.parent = refs.source;
|
|
431
|
-
else if (refs.object) op.parent = refs.object;
|
|
439
|
+
operations.push(parseEntityBlock(entityType, entityName, eBlock, meshName));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
432
443
|
|
|
433
|
-
|
|
444
|
+
// Handle root-level entity blocks with no mesh context (e.g. UUID-keyed updates)
|
|
445
|
+
if (meshEntries.length === 0) {
|
|
446
|
+
for (const entityType of ENTITY_ORDER.slice(1)) {
|
|
447
|
+
const group = rootEntityGroups[entityType];
|
|
448
|
+
if (!group || typeof group !== "object") continue;
|
|
449
|
+
for (const [entityName, eBlock] of Object.entries(group)) {
|
|
450
|
+
if (!eBlock || typeof eBlock !== "object" || Array.isArray(eBlock)) continue;
|
|
451
|
+
operations.push(parseEntityBlock(entityType, entityName, eBlock, null));
|
|
434
452
|
}
|
|
435
453
|
}
|
|
436
454
|
}
|
|
@@ -591,12 +609,21 @@ async function deleteEntity(client, type, identifier) {
|
|
|
591
609
|
/**
|
|
592
610
|
* Search for an existing entity by type and name. Returns its identifier or null.
|
|
593
611
|
*/
|
|
612
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
613
|
+
|
|
594
614
|
async function findEntity(client, type, name) {
|
|
615
|
+
// If name is a UUID, look up directly by identifier
|
|
616
|
+
if (UUID_RE.test(name)) {
|
|
617
|
+
try {
|
|
618
|
+
const res = await client.get(`/data/${type}/${name}`);
|
|
619
|
+
if (res?.identifier) return res.identifier;
|
|
620
|
+
} catch { /* fall through */ }
|
|
621
|
+
}
|
|
595
622
|
try {
|
|
596
623
|
// Use type-specific list endpoint for reliable lookup
|
|
597
624
|
const res = await client.get(`/data/${type}/list?per_page=100`);
|
|
598
625
|
const entities = parseListResponse(res);
|
|
599
|
-
const match = entities.find((e) => e.name === name);
|
|
626
|
+
const match = entities.find((e) => e.name === name || (UUID_RE.test(name) && e.identifier === name));
|
|
600
627
|
if (match) return match.identifier;
|
|
601
628
|
} catch { /* fall through */ }
|
|
602
629
|
try {
|
|
@@ -891,7 +918,8 @@ async function runSubActions(client, op, uuid, refs) {
|
|
|
891
918
|
*/
|
|
892
919
|
async function applyPipelineOp(client, op, uuid, refs) {
|
|
893
920
|
const pipelineInputs = op.pipeline.inputs.map((inp) => {
|
|
894
|
-
|
|
921
|
+
// Allow raw UUIDs as refs (e.g. from FCL patch) — no lookup needed
|
|
922
|
+
const inputUuid = refs[inp.ref] ?? (UUID_RE.test(inp.ref) ? inp.ref : null);
|
|
895
923
|
if (!inputUuid) throw new Error(`unresolved pipeline input ref: ${inp.ref}`);
|
|
896
924
|
return { input_type: inp.type, identifier: inputUuid };
|
|
897
925
|
});
|
|
@@ -899,7 +927,7 @@ async function applyPipelineOp(client, op, uuid, refs) {
|
|
|
899
927
|
// Build refToKey map for resolving transform "other" refs
|
|
900
928
|
const refToKey = {};
|
|
901
929
|
for (const inp of op.pipeline.inputs) {
|
|
902
|
-
const inputUuid = refs[inp.ref];
|
|
930
|
+
const inputUuid = refs[inp.ref] ?? (UUID_RE.test(inp.ref) ? inp.ref : null);
|
|
903
931
|
if (inputUuid) refToKey[inp.ref] = `input_${inputUuid.replace(/-/g, "_")}`;
|
|
904
932
|
}
|
|
905
933
|
|
|
@@ -931,6 +959,18 @@ async function applyPipelineOp(client, op, uuid, refs) {
|
|
|
931
959
|
if (cols && cols.length > 0) slotCols[slot] = cols;
|
|
932
960
|
}
|
|
933
961
|
if (Object.keys(slotCols).length > 0) {
|
|
962
|
+
// Prune rename_column steps: remove entries whose source column doesn't exist
|
|
963
|
+
// in any slot (data is already named correctly — mapping was generated from a
|
|
964
|
+
// raw file whose column names differ from the ingested data object schema).
|
|
965
|
+
const allSlotCols = new Set(Object.values(slotCols).flat());
|
|
966
|
+
builderBody.transformations = (builderBody.transformations || []).flatMap((t) => {
|
|
967
|
+
if (t.transform !== "rename_column") return [t];
|
|
968
|
+
const changes = Object.fromEntries(
|
|
969
|
+
Object.entries(t.changes || {}).filter(([from]) => allSlotCols.has(from))
|
|
970
|
+
);
|
|
971
|
+
if (Object.keys(changes).length === 0) return []; // step is a no-op — drop it
|
|
972
|
+
return [{ ...t, changes }];
|
|
973
|
+
});
|
|
934
974
|
const flowErr = validateTransformColumnFlow(slotCols, builderBody.transformations || []);
|
|
935
975
|
if (flowErr) throw new Error(flowErr);
|
|
936
976
|
}
|
|
@@ -998,7 +1038,9 @@ export async function applyLandscape(client, operations, opts = {}) {
|
|
|
998
1038
|
const existing = await findEntity(client, op.type, op.name);
|
|
999
1039
|
if (existing) {
|
|
1000
1040
|
let recreateFallThrough = false;
|
|
1001
|
-
|
|
1041
|
+
// When op.name is a UUID, we're targeting an existing entity by identifier — never delete/recreate.
|
|
1042
|
+
const opNameIsUuid = UUID_RE.test(op.name);
|
|
1043
|
+
if (!opNameIsUuid && recreateBroken && (op.type === "data_product" || op.type === "data_object" || op.type === "data_source")) {
|
|
1002
1044
|
const broken = await isEntityBroken(client, op.type, existing, op);
|
|
1003
1045
|
if (broken) {
|
|
1004
1046
|
const deleted = await deleteEntity(client, op.type, existing);
|
|
@@ -5,6 +5,8 @@ import path from "node:path";
|
|
|
5
5
|
|
|
6
6
|
const DEFAULT_BASE_URL = "http://127.0.0.1:9001/api";
|
|
7
7
|
const TOKEN_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
8
|
+
const RETRY_COUNT = 3;
|
|
9
|
+
const RETRY_DELAY_MS = 10_000;
|
|
8
10
|
|
|
9
11
|
/** Resolve API base URL: config.apiUrl, FOUNDATION_API_URL, or default. Ensures path ends with /api. */
|
|
10
12
|
function resolveBaseUrl(config) {
|
|
@@ -18,6 +20,18 @@ function resolveBaseUrl(config) {
|
|
|
18
20
|
return url.endsWith("/api") ? url : `${url}/api`;
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
/** Resolve x-org header value: config.orgId, ORG_ID env, ORG_NAME env, root .env, or "root". */
|
|
24
|
+
function resolveOrgId(config, rootEnv = {}) {
|
|
25
|
+
return (
|
|
26
|
+
config?.orgId?.trim() ||
|
|
27
|
+
process.env.ORG_ID?.trim() ||
|
|
28
|
+
rootEnv.ORG_ID?.trim() ||
|
|
29
|
+
process.env.ORG_NAME?.trim() ||
|
|
30
|
+
rootEnv.ORG_NAME?.trim() ||
|
|
31
|
+
"root"
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
21
35
|
function isValidJwt(token) {
|
|
22
36
|
if (!token) return false;
|
|
23
37
|
const parts = token.split(".");
|
|
@@ -47,7 +61,6 @@ function request(method, url, headers = {}, body, timeoutMs = 15_000) {
|
|
|
47
61
|
method,
|
|
48
62
|
headers: {
|
|
49
63
|
"Content-Type": "application/json",
|
|
50
|
-
"x-org": "root",
|
|
51
64
|
...headers,
|
|
52
65
|
},
|
|
53
66
|
timeout: timeoutMs,
|
|
@@ -73,6 +86,28 @@ function request(method, url, headers = {}, body, timeoutMs = 15_000) {
|
|
|
73
86
|
});
|
|
74
87
|
}
|
|
75
88
|
|
|
89
|
+
function sleep(ms) {
|
|
90
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Like request() but retries on network errors and 5xx responses. */
|
|
94
|
+
async function requestWithRetries(method, url, headers, body, timeoutMs, maxRetries = RETRY_COUNT, delayMs = RETRY_DELAY_MS) {
|
|
95
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
96
|
+
let res;
|
|
97
|
+
try {
|
|
98
|
+
res = await request(method, url, headers, body, timeoutMs);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
if (attempt < maxRetries) { await sleep(delayMs); continue; }
|
|
101
|
+
throw new Error(`Request failed after ${maxRetries} attempts: ${e.message}`);
|
|
102
|
+
}
|
|
103
|
+
if (res.status >= 500 && attempt < maxRetries) {
|
|
104
|
+
await sleep(delayMs);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
return res;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
76
111
|
/**
|
|
77
112
|
* Like request() but returns response headers, does NOT follow redirects,
|
|
78
113
|
* and supports both JSON and url-encoded bodies.
|
|
@@ -251,6 +286,7 @@ export class FoundationClient {
|
|
|
251
286
|
constructor(config = {}, opts = {}) {
|
|
252
287
|
this._config = { ...config };
|
|
253
288
|
this._baseUrl = resolveBaseUrl(config);
|
|
289
|
+
this._org = resolveOrgId(config, loadRootProjectEnv());
|
|
254
290
|
this._getCredentials = opts.getCredentials || null;
|
|
255
291
|
this._token = null;
|
|
256
292
|
this._tokenExpiry = 0;
|
|
@@ -501,7 +537,7 @@ export class FoundationClient {
|
|
|
501
537
|
*/
|
|
502
538
|
async _request(method, apiPath, body) {
|
|
503
539
|
const url = apiPath.startsWith("http") ? apiPath : `${this._baseUrl}${apiPath}`;
|
|
504
|
-
const headers = {};
|
|
540
|
+
const headers = { "x-org": this._org };
|
|
505
541
|
|
|
506
542
|
await this.ensureAuth();
|
|
507
543
|
headers.Authorization = `Bearer ${this._token}`;
|
|
@@ -526,7 +562,7 @@ export class FoundationClient {
|
|
|
526
562
|
headers["CF-Access-Client-Secret"] = cfSecret;
|
|
527
563
|
}
|
|
528
564
|
|
|
529
|
-
let res = await
|
|
565
|
+
let res = await requestWithRetries(method, url, headers, body, this._timeoutMs);
|
|
530
566
|
|
|
531
567
|
if (res.status === 401) {
|
|
532
568
|
this._token = null;
|
|
@@ -537,7 +573,7 @@ export class FoundationClient {
|
|
|
537
573
|
headers.Authorization = `Bearer ${this._token}`;
|
|
538
574
|
if (this._cfJwt) headers.Cookie = `CF_Authorization=${this._cfJwt}`;
|
|
539
575
|
else delete headers.Cookie;
|
|
540
|
-
res = await
|
|
576
|
+
res = await requestWithRetries(method, url, headers, body, this._timeoutMs);
|
|
541
577
|
}
|
|
542
578
|
|
|
543
579
|
if (res.status >= 400) {
|
|
@@ -70,10 +70,13 @@ export function parseStackFile(filePath) {
|
|
|
70
70
|
const parsed = yaml.load(content);
|
|
71
71
|
if (!parsed) return { stack: null, hasStack: false, hasMesh: false };
|
|
72
72
|
|
|
73
|
+
const ENTITY_TYPES = ["data_system", "data_source", "data_object", "data_product", "application"];
|
|
74
|
+
const hasMesh = !!parsed.mesh || ENTITY_TYPES.some((t) => !!parsed[t]);
|
|
75
|
+
|
|
73
76
|
return {
|
|
74
77
|
stack: parsed.stack ? parseStackBlock(parsed.stack) : null,
|
|
75
78
|
hasStack: !!parsed.stack,
|
|
76
|
-
hasMesh
|
|
79
|
+
hasMesh,
|
|
77
80
|
};
|
|
78
81
|
}
|
|
79
82
|
|
|
@@ -2440,6 +2440,52 @@ export function registerWriteTools(api, client) {
|
|
|
2440
2440
|
return `Unknown scope: ${raw.scope}`;
|
|
2441
2441
|
},
|
|
2442
2442
|
},
|
|
2443
|
+
// foundation_align
|
|
2444
|
+
{
|
|
2445
|
+
name: "foundation_align",
|
|
2446
|
+
description: "Align source CSV column names to expected schema column names using exact, embedding, and Levenshtein matching. Returns mappings and a ready-to-use rename_column transformation.",
|
|
2447
|
+
inputSchema: {
|
|
2448
|
+
type: "object",
|
|
2449
|
+
properties: {
|
|
2450
|
+
source_columns: { type: "array", items: { type: "string" }, description: "Raw source column names (e.g. from a CSV header)" },
|
|
2451
|
+
expected_columns: { type: "array", items: { type: "string" }, description: "Target schema column names (e.g. from a data product schema)" },
|
|
2452
|
+
source_identifier: { type: "string", description: "Entity identifier to fetch source columns from (data_object or data_product)" },
|
|
2453
|
+
target_identifier: { type: "string", description: "Entity identifier to fetch expected columns from (data_object or data_product)" },
|
|
2454
|
+
threshold: { type: "number", description: "Minimum similarity score 0–1 (default 0.7)" },
|
|
2455
|
+
},
|
|
2456
|
+
},
|
|
2457
|
+
async execute(input) {
|
|
2458
|
+
const { alignColumns } = await import("./align.js");
|
|
2459
|
+
let sourceCols = input.source_columns;
|
|
2460
|
+
let expectedCols = input.expected_columns;
|
|
2461
|
+
|
|
2462
|
+
if (!sourceCols && input.source_identifier) {
|
|
2463
|
+
const cols = await fetchEntityColumns(client, input.source_identifier, "data_object");
|
|
2464
|
+
if (!cols) return `Error: could not fetch columns for source identifier "${input.source_identifier}"`;
|
|
2465
|
+
sourceCols = cols;
|
|
2466
|
+
}
|
|
2467
|
+
if (!expectedCols && input.target_identifier) {
|
|
2468
|
+
const cols = await fetchEntityColumns(client, input.target_identifier, "data_product");
|
|
2469
|
+
if (!cols) return `Error: could not fetch columns for target identifier "${input.target_identifier}"`;
|
|
2470
|
+
expectedCols = cols;
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
if (!sourceCols || sourceCols.length === 0) return "Error: source_columns or source_identifier required";
|
|
2474
|
+
if (!expectedCols || expectedCols.length === 0) return "Error: expected_columns or target_identifier required";
|
|
2475
|
+
|
|
2476
|
+
let embedTexts;
|
|
2477
|
+
try {
|
|
2478
|
+
const embSvc = typeof api.getService === "function" && api.getService("embeddings:search");
|
|
2479
|
+
if (embSvc?.embedTexts) embedTexts = embSvc.embedTexts.bind(embSvc);
|
|
2480
|
+
} catch { /* embeddings not available */ }
|
|
2481
|
+
|
|
2482
|
+
const result = await alignColumns(sourceCols, expectedCols, {
|
|
2483
|
+
embedTexts,
|
|
2484
|
+
threshold: typeof input.threshold === "number" ? input.threshold : 0.7,
|
|
2485
|
+
});
|
|
2486
|
+
return fmt(result);
|
|
2487
|
+
},
|
|
2488
|
+
},
|
|
2443
2489
|
];
|
|
2444
2490
|
|
|
2445
2491
|
return tools;
|
|
@@ -81,7 +81,7 @@ export function register(api) {
|
|
|
81
81
|
},
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
-
// ─── CLI
|
|
84
|
+
// ─── CLI Commands: fops foundation graphql / query ───────────────────────
|
|
85
85
|
api.registerCommand((program) => {
|
|
86
86
|
// Find or create the 'foundation' parent command
|
|
87
87
|
let foundation = program.commands.find((c) => c.name() === "foundation");
|
|
@@ -89,6 +89,44 @@ export function register(api) {
|
|
|
89
89
|
foundation = program.command("foundation").description("Foundation platform operations");
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
foundation
|
|
93
|
+
.command("graphql")
|
|
94
|
+
.description("Start a local GraphiQL explorer for the Foundation GraphQL API")
|
|
95
|
+
.option("--port <port>", "Port to listen on (default: random)", "0")
|
|
96
|
+
.action(async (opts) => {
|
|
97
|
+
try {
|
|
98
|
+
const facade = await getFacade();
|
|
99
|
+
const { createGraphQLRoute } = await import("./lib/graphql/hono-route.js");
|
|
100
|
+
const { serve } = await import("@hono/node-server");
|
|
101
|
+
const { exec } = await import("node:child_process");
|
|
102
|
+
|
|
103
|
+
const route = createGraphQLRoute(facade);
|
|
104
|
+
const port = parseInt(opts.port, 10) || 0;
|
|
105
|
+
|
|
106
|
+
const server = serve({ fetch: route.fetch, port }, (info) => {
|
|
107
|
+
const url = `http://127.0.0.1:${info.port}`;
|
|
108
|
+
console.log(chalk.cyan(`\n ── Foundation GraphQL Explorer ${"─".repeat(18)}`));
|
|
109
|
+
console.log(` ${chalk.green("✓")} Listening on ${chalk.bold(url)}`);
|
|
110
|
+
console.log(chalk.dim(" Press Ctrl+C to stop\n"));
|
|
111
|
+
|
|
112
|
+
// Open browser with platform-specific command
|
|
113
|
+
const opener =
|
|
114
|
+
process.platform === "darwin" ? "open" :
|
|
115
|
+
process.platform === "win32" ? "start" : "xdg-open";
|
|
116
|
+
exec(`${opener} ${url}`);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Keep alive until Ctrl+C
|
|
120
|
+
await new Promise((resolve) => {
|
|
121
|
+
process.on("SIGINT", () => { server.close(); resolve(); });
|
|
122
|
+
process.on("SIGTERM", () => { server.close(); resolve(); });
|
|
123
|
+
});
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error(chalk.red(` ${err.message}`));
|
|
126
|
+
process.exitCode = 1;
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
92
130
|
foundation
|
|
93
131
|
.command("query <graphql>")
|
|
94
132
|
.description("Execute an ad-hoc GraphQL query against the Foundation API")
|
package/src/plugins/bundled/fops-plugin-foundation-graphql/lib/graphql/resolvers/data-object.js
CHANGED
|
@@ -28,12 +28,15 @@ export const dataObjectResolvers = {
|
|
|
28
28
|
// Normalize: the API may return { fields: [...] } or { schema: { fields: [...] } }
|
|
29
29
|
const fields = res?.fields || res?.schema?.fields || [];
|
|
30
30
|
return {
|
|
31
|
-
fields: fields.map((f) =>
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
fields: fields.map((f) => {
|
|
32
|
+
const raw = f.data_type || f.dataType || f.type;
|
|
33
|
+
return {
|
|
34
|
+
name: f.name || f.column_name,
|
|
35
|
+
dataType: raw && typeof raw === "object" ? raw.column_type ?? null : raw ?? null,
|
|
36
|
+
primary: f.primary ?? f.is_primary ?? false,
|
|
37
|
+
nullable: f.nullable ?? f.is_nullable ?? true,
|
|
38
|
+
};
|
|
39
|
+
}),
|
|
37
40
|
};
|
|
38
41
|
} catch { return null; }
|
|
39
42
|
},
|
package/src/plugins/bundled/fops-plugin-foundation-graphql/lib/graphql/resolvers/data-product.js
CHANGED
|
@@ -29,12 +29,15 @@ export const dataProductResolvers = {
|
|
|
29
29
|
const res = await loaders.dataProductSchema.load(id);
|
|
30
30
|
if (res instanceof Error) return null;
|
|
31
31
|
const details = res?.details || res?.schema || res;
|
|
32
|
-
const columns = (res?.columns || res?.fields || []).map((f) =>
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
const columns = (res?.columns || res?.fields || []).map((f) => {
|
|
33
|
+
const raw = f.data_type || f.dataType || f.type;
|
|
34
|
+
return {
|
|
35
|
+
name: f.name || f.column_name,
|
|
36
|
+
dataType: raw && typeof raw === "object" ? raw.column_type ?? null : raw ?? null,
|
|
37
|
+
primary: f.primary ?? f.is_primary ?? false,
|
|
38
|
+
nullable: f.nullable ?? f.is_nullable ?? true,
|
|
39
|
+
};
|
|
40
|
+
});
|
|
38
41
|
return { details, columns };
|
|
39
42
|
} catch { return null; }
|
|
40
43
|
},
|