@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.

Files changed (27) hide show
  1. package/CHANGELOG.md +372 -0
  2. package/package.json +1 -1
  3. package/src/commands/lifecycle.js +16 -0
  4. package/src/electron/icon.png +0 -0
  5. package/src/electron/main.js +24 -0
  6. package/src/plugins/bundled/fops-plugin-embeddings/index.js +9 -0
  7. package/src/plugins/bundled/fops-plugin-embeddings/lib/indexer.js +1 -1
  8. package/src/plugins/bundled/fops-plugin-file/demo/landscape.yaml +67 -0
  9. package/src/plugins/bundled/fops-plugin-file/demo/orders_bad.csv +6 -0
  10. package/src/plugins/bundled/fops-plugin-file/demo/orders_good.csv +7 -0
  11. package/src/plugins/bundled/fops-plugin-file/demo/orders_reference.csv +6 -0
  12. package/src/plugins/bundled/fops-plugin-file/demo/orders_renamed.aligned.csv +6 -0
  13. package/src/plugins/bundled/fops-plugin-file/demo/orders_renamed.csv +6 -0
  14. package/src/plugins/bundled/fops-plugin-file/demo/rules.json +8 -0
  15. package/src/plugins/bundled/fops-plugin-file/demo/run.sh +110 -0
  16. package/src/plugins/bundled/fops-plugin-file/index.js +140 -24
  17. package/src/plugins/bundled/fops-plugin-file/lib/embed-index.js +7 -0
  18. package/src/plugins/bundled/fops-plugin-file/lib/match.js +11 -4
  19. package/src/plugins/bundled/fops-plugin-foundation/index.js +1715 -2
  20. package/src/plugins/bundled/fops-plugin-foundation/lib/align.js +183 -0
  21. package/src/plugins/bundled/fops-plugin-foundation/lib/apply.js +83 -41
  22. package/src/plugins/bundled/fops-plugin-foundation/lib/client.js +40 -4
  23. package/src/plugins/bundled/fops-plugin-foundation/lib/stack-apply.js +4 -1
  24. package/src/plugins/bundled/fops-plugin-foundation/lib/tools-write.js +46 -0
  25. package/src/plugins/bundled/fops-plugin-foundation-graphql/index.js +39 -1
  26. package/src/plugins/bundled/fops-plugin-foundation-graphql/lib/graphql/resolvers/data-object.js +9 -6
  27. 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
- if (!parsed || !parsed.mesh) {
328
- throw new Error("No mesh block found in YAML content");
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
- if (!parsed || !parsed.mesh) {
352
- throw new Error("No mesh block found in YAML file");
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
- const props = {};
401
- const refs = {};
402
- const op = { type: entityType, name: entityName, props, mesh: meshName };
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
- operations.push(op);
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
- const inputUuid = refs[inp.ref];
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
- if (recreateBroken && (op.type === "data_product" || op.type === "data_object" || op.type === "data_source")) {
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 request(method, url, headers, body, this._timeoutMs);
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 request(method, url, headers, body, this._timeoutMs);
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: !!parsed.mesh,
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 Command: fops foundation query ──────────────────────────────────
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")
@@ -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
- name: f.name || f.column_name,
33
- dataType: f.data_type || f.dataType || f.type,
34
- primary: f.primary ?? f.is_primary ?? false,
35
- nullable: f.nullable ?? f.is_nullable ?? true,
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
  },
@@ -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
- name: f.name || f.column_name,
34
- dataType: f.data_type || f.dataType || f.type,
35
- primary: f.primary ?? f.is_primary ?? false,
36
- nullable: f.nullable ?? f.is_nullable ?? true,
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
  },