Created
August 21, 2025 21:02
-
-
Save ludioao/b3f6d0e88bdcb6a31a6f431c4b16847d to your computer and use it in GitHub Desktop.
nocodb sync
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import axios from "axios"; | |
import fs from "fs"; | |
import path from "path"; | |
import { fileURLToPath } from "url"; | |
import dotenv from "dotenv"; | |
import pLimit from "p-limit"; | |
dotenv.config(); | |
const __filename = fileURLToPath(import.meta.url); | |
const __dirname = path.dirname(__filename); | |
/** ========= CONFIG ========= */ | |
const SRC = { | |
host: process.env.SRC_HOST, | |
token: process.env.SRC_TOKEN, | |
projectSlug: process.env.SRC_PROJECT_SLUG, // pode ser SLUG ou ID | |
}; | |
const DST = { | |
host: process.env.DST_HOST, | |
token: process.env.DST_TOKEN, | |
projectSlug: process.env.DST_PROJECT_SLUG, // pode ser SLUG ou ID | |
}; | |
const PAGE_LIMIT = parseInt(process.env.PAGE_LIMIT ?? "1000", 10); | |
const BULK_CHUNK = parseInt(process.env.BULK_CHUNK ?? "1000", 10); | |
const CREATE_SCHEMA = String(process.env.CREATE_SCHEMA ?? "true") === "true"; | |
const CONCURRENCY = parseInt(process.env.CONCURRENCY ?? "6", 10); | |
/** ========= HELPERS ========= */ | |
const hdr = (token) => ({ | |
headers: { "xc-token": token, "Content-Type": "application/json" }, | |
timeout: 60_000, | |
}); | |
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
async function withRetry(fn, { retries = 4, baseDelay = 500 } = {}) { | |
let attempt = 0; | |
for (;;) { | |
try { | |
return await fn(); | |
} catch (err) { | |
attempt++; | |
if (attempt > retries) throw err; | |
const wait = baseDelay * Math.pow(2, attempt - 1); | |
console.warn(`Retry ${attempt}/${retries} in ${wait}ms: ${err?.message || err}`); | |
await sleep(wait); | |
} | |
} | |
} | |
/** ========= META (SCHEMA) – leitura origem ========= | |
* NOTA: estes endpoints podem variar por versão. Ajuste se necessário. | |
*/ | |
async function getProjectBySlugOrId(host, token, slugOrId) { | |
// Tenta buscar por slug, senão assume que já é ID | |
try { | |
const { data } = await withRetry(() => | |
axios.get(`${host}/api/v2/meta/projects?limit=2000`, hdr(token)) | |
); | |
const list = data?.list || data?.projects || []; | |
const found = list.find((p) => p.title === slugOrId || p.slug === slugOrId || p.id === slugOrId); | |
if (found) return found; | |
} catch (_) {} | |
// fallback: retorno mínimo com id=slugOrId | |
return { id: slugOrId, slug: slugOrId, title: slugOrId }; | |
} | |
async function listTables(host, token, projectId) { | |
const out = []; | |
let offset = 0; | |
const limit = 200; | |
for (;;) { | |
const url = `${host}/api/v2/meta/tables?projectId=${encodeURIComponent( | |
projectId | |
)}&limit=${limit}&offset=${offset}`; | |
const { data } = await withRetry(() => axios.get(url, hdr(token))); | |
const chunk = data?.list || data?.tables || []; | |
out.push(...chunk); | |
const tot = data?.pageInfo?.total ?? chunk.length; | |
if (offset + limit >= tot || chunk.length === 0) break; | |
offset += limit; | |
} | |
return out; | |
} | |
async function listColumns(host, token, tableId) { | |
const url = `${host}/api/v2/meta/tables/${tableId}/columns?limit=5000`; | |
const { data } = await withRetry(() => axios.get(url, hdr(token))); | |
return data?.list || data?.columns || []; | |
} | |
/** ========= META (SCHEMA) – criação destino ========= | |
* Dependente da versão/perm da Meta API. Ajuste os payloads conforme sua instância. | |
*/ | |
async function createTable(host, token, projectId, tableDef) { | |
// tableDef: { title, table_name, ... } | |
const payload = { | |
title: tableDef.title || tableDef.table_name, | |
table_name: tableDef.table_name || tableDef.title, | |
project_id: projectId, | |
}; | |
const url = `${host}/api/v2/meta/tables`; | |
const { data } = await withRetry(() => axios.post(url, payload, hdr(token))); | |
return data; | |
} | |
function mapColumnType(col) { | |
// Mapeamento simples → ajuste para seus casos | |
const t = (col?.uidt || col?.type || "").toLowerCase(); | |
if (t.includes("text") || t === "longtext" || t === "richtext") return "text"; | |
if (t.includes("number") || t === "numeric") return "number"; | |
if (t.includes("bool")) return "boolean"; | |
if (t.includes("date") || t.includes("time")) return "date"; | |
if (t.includes("select")) return "select"; | |
if (t.includes("lookup")) return "lookup"; | |
if (t.includes("formula")) return "formula"; | |
if (t.includes("link")) return "link"; | |
if (t.includes("json")) return "json"; | |
if (t.includes("attachment") || t.includes("file")) return "attachment"; | |
return "text"; | |
} | |
function buildColumnPayload(col, destTableId, tableNameToId) { | |
const type = mapColumnType(col); | |
const base = { | |
title: col.title || col.column_name, | |
column_name: col.column_name || col.title, | |
uidt: type, | |
table_id: destTableId, | |
// required, default, precision, options, etc — ajuste conforme sua necessidade | |
}; | |
if (type === "select" && Array.isArray(col?.options)) { | |
base.options = col.options; | |
} | |
if (type === "link") { | |
// link columns precisam saber a tabela alvo | |
// Muitas instâncias usam col.relation?/fk_table_id?/fk_table_name? | |
const targetTableName = | |
col?.fk_table_name || col?.ref_table_name || col?.meta?.refTableName; | |
const targetId = tableNameToId.get(targetTableName); | |
if (!targetId) { | |
console.warn( | |
`Atenção: coluna link "${base.column_name}" aponta para tabela desconhecida: ${targetTableName}` | |
); | |
} else { | |
base.ref_table_id = targetId; | |
} | |
} | |
return base; | |
} | |
async function createColumn(host, token, colPayload) { | |
const url = `${host}/api/v2/meta/columns`; | |
const { data } = await withRetry(() => axios.post(url, colPayload, hdr(token))); | |
return data; | |
} | |
/** ========= DATA API ========= */ | |
async function listAllRecords(host, token, tableId, pageLimit = 1000) { | |
const out = []; | |
let offset = 0; | |
for (;;) { | |
const url = `${host}/api/v2/tables/${tableId}/records?limit=${pageLimit}&offset=${offset}`; | |
const { data } = await withRetry(() => axios.get(url, hdr(token))); | |
const rows = data?.list || data?.records || []; | |
out.push(...rows); | |
const tot = data?.pageInfo?.total ?? rows.length; | |
if (offset + pageLimit >= tot || rows.length === 0) break; | |
offset += pageLimit; | |
} | |
return out; | |
} | |
async function bulkInsert(host, token, tableId, records, chunkSize = 1000) { | |
for (let i = 0; i < records.length; i += chunkSize) { | |
const slice = records.slice(i, i + chunkSize); | |
const url = `${host}/api/v2/tables/${tableId}/records/bulk`; | |
await withRetry(() => axios.post(url, { records: slice }, hdr(token))); | |
} | |
} | |
async function updateRecord(host, token, tableId, recId, payload) { | |
const url = `${host}/api/v2/tables/${tableId}/records/${recId}`; | |
await withRetry(() => axios.patch(url, payload, hdr(token))); | |
} | |
/** ========= PIPELINE PRINCIPAL ========= */ | |
(async () => { | |
// 1) identificar projetos origem/destino | |
const srcProject = await getProjectBySlugOrId(SRC.host, SRC.token, SRC.projectSlug); | |
const dstProject = await getProjectBySlugOrId(DST.host, DST.token, DST.projectSlug); | |
console.log("SRC project:", srcProject.id, srcProject.slug || srcProject.title); | |
console.log("DST project:", dstProject.id, dstProject.slug || dstProject.title); | |
// 2) ler schema da origem | |
const srcTables = await listTables(SRC.host, SRC.token, srcProject.id); | |
// montar dicionário tabela → colunas | |
const schema = []; | |
for (const t of srcTables) { | |
const cols = await listColumns(SRC.host, SRC.token, t.id); | |
schema.push({ | |
table: { id: t.id, title: t.title, table_name: t.table_name || t.title }, | |
columns: cols.map((c) => ({ | |
id: c.id, | |
title: c.title, | |
column_name: c.column_name || c.title, | |
uidt: c.uidt || c.type, // tipo | |
options: c.options ?? undefined, | |
meta: c.meta ?? undefined, | |
fk_table_name: | |
c.fk_table_name || c.ref_table_name || c.meta?.refTableName || undefined, // para links | |
})), | |
}); | |
} | |
// salvar manifesto do schema | |
const manifestPath = path.join(__dirname, "manifest.schema.json"); | |
fs.writeFileSync(manifestPath, JSON.stringify({ srcProject, dstProject, schema }, null, 2)); | |
console.log(`Schema manifesto salvo em: ${manifestPath}`); | |
// 3) criar schema no destino (tabelas + colunas), se habilitado | |
// mantermos um mapa: srcTableName -> { srcId, dstId, columnMaps } | |
const tableNameToId_DST = new Map(); | |
const tableMaps = new Map(); // key = srcTableName, value = { srcId, dstId, colMap: Map<srcColName, dstColName> } | |
if (CREATE_SCHEMA) { | |
console.log("Criando schema no destino…"); | |
// criar tabelas | |
for (const entry of schema) { | |
const { table } = entry; | |
const tname = table.table_name || table.title; | |
const created = await createTable(DST.host, DST.token, dstProject.id, { | |
title: table.title, | |
table_name: tname, | |
}); | |
const dstTableId = created?.id || created?.table?.id || created?.table_id || created?.uuid; | |
if (!dstTableId) { | |
throw new Error(`Não foi possível obter ID da tabela destino para "${tname}".`); | |
} | |
tableNameToId_DST.set(tname, dstTableId); | |
tableMaps.set(tname, { srcId: table.id, dstId: dstTableId, colMap: new Map() }); | |
console.log(`- Tabela criada: ${tname} → ${dstTableId}`); | |
} | |
// criar colunas | |
// importante: criar primeiro as colunas não-link; depois voltamos nas link | |
const linkColsQueue = []; | |
for (const entry of schema) { | |
const { table, columns } = entry; | |
const tname = table.table_name || table.title; | |
const dstTableId = tableNameToId_DST.get(tname); | |
for (const col of columns) { | |
const type = mapColumnType(col); | |
if (type === "link") { | |
linkColsQueue.push({ tname, dstTableId, col }); | |
continue; | |
} | |
const payload = buildColumnPayload(col, dstTableId, tableNameToId_DST); | |
const created = await createColumn(DST.host, DST.token, payload); | |
const dstColName = | |
created?.column_name || created?.title || payload.column_name || payload.title; | |
tableMaps.get(tname).colMap.set(col.column_name || col.title, dstColName); | |
console.log(` - Coluna criada: ${tname}.${dstColName} [${type}]`); | |
} | |
} | |
// agora criar colunas de link (já temos tabela alvo mapeada) | |
for (const { tname, dstTableId, col } of linkColsQueue) { | |
const payload = buildColumnPayload(col, dstTableId, tableNameToId_DST); | |
const created = await createColumn(DST.host, DST.token, payload); | |
const dstColName = | |
created?.column_name || created?.title || payload.column_name || payload.title; | |
tableMaps.get(tname).colMap.set(col.column_name || col.title, dstColName); | |
console.log(` - Coluna LINK criada: ${tname}.${dstColName}`); | |
} | |
} else { | |
// Descobrir IDs das tabelas já existentes no destino por nome | |
console.log("CREATE_SCHEMA=false → assumindo que o schema já existe no destino."); | |
const dstTables = await listTables(DST.host, DST.token, dstProject.id); | |
for (const entry of schema) { | |
const tname = entry.table.table_name || entry.table.title; | |
const dstT = dstTables.find( | |
(x) => (x.table_name || x.title) === tname || x.title === tname | |
); | |
if (!dstT) throw new Error(`Tabela "${tname}" não encontrada no destino.`); | |
tableNameToId_DST.set(tname, dstT.id); | |
const dstCols = await listColumns(DST.host, DST.token, dstT.id); | |
const colMap = new Map(); | |
for (const c of entry.columns) { | |
// mapeamento simples por nome | |
const match = dstCols.find( | |
(dc) => | |
(dc.column_name || dc.title) === (c.column_name || c.title) || | |
dc.title === (c.column_name || c.title) | |
); | |
if (match) colMap.set(c.column_name || c.title, match.column_name || match.title); | |
} | |
tableMaps.set(tname, { srcId: entry.table.id, dstId: dstT.id, colMap }); | |
} | |
} | |
// 4) migrar dados “simples” (sem preencher colunas Link ainda) | |
console.log("Migrando dados (primeira passada) …"); | |
// guardaremos IDs origem→destino por tabela | |
const idMaps = new Map(); // tableName -> Map<sourceId, destId> | |
for (const entry of schema) { | |
const tname = entry.table.table_name || entry.table.title; | |
const srcTableId = entry.table.id; | |
const dstTableId = tableNameToId_DST.get(tname); | |
const cols = entry.columns; | |
// listar registros origem | |
const rows = await listAllRecords(SRC.host, SRC.token, srcTableId, PAGE_LIMIT); | |
console.log(`- ${tname}: ${rows.length} registros encontrados.`); | |
if (rows.length === 0) { | |
idMaps.set(tname, new Map()); | |
continue; | |
} | |
// construir payload sem colunas LINK / LOOKUP / FORMULA (essas recalculam depois) | |
const linkColNames = new Set( | |
cols | |
.filter((c) => { | |
const tt = mapColumnType(c); | |
return tt === "link" || tt === "lookup" || tt === "formula"; | |
}) | |
.map((c) => c.column_name || c.title) | |
); | |
const colMap = tableMaps.get(tname).colMap; | |
const simpleRecords = []; | |
const srcIdToPayloadIndex = new Map(); | |
rows.forEach((r, idx) => { | |
const payload = {}; | |
Object.entries(r).forEach(([k, v]) => { | |
if (k === "id" || k === "_rowId" || k === "_id") return; // ID da origem | |
if (linkColNames.has(k)) return; // pula link/lookup/formula | |
const dstCol = colMap.get(k) || k; | |
// attachments como URL (se string/array de string) | |
payload[dstCol] = v; | |
}); | |
simpleRecords.push(payload); | |
// guardamos o índice para correlacionar depois | |
const srcId = r.id ?? r._rowId ?? r._id ?? String(idx); | |
srcIdToPayloadIndex.set(srcId, simpleRecords.length - 1); | |
}); | |
// enviar em bulk | |
await bulkInsert(DST.host, DST.token, dstTableId, simpleRecords, BULK_CHUNK); | |
// Precisamos descobrir os IDs gerados no destino para mapear origem→destino. | |
// Estratégia: reler registros destino ordenados por created/seq e assumir mesma ordem. | |
// Caso sua instância permita, troque para “bulk insert com retorno” | |
const dstRows = await listAllRecords(DST.host, DST.token, dstTableId, PAGE_LIMIT); | |
if (dstRows.length < simpleRecords.length) { | |
console.warn( | |
` Aviso: menos linhas no destino do que inseridas (esperado=${simpleRecords.length}, atual=${dstRows.length}).` | |
); | |
} | |
// Mapeamento por heurística (melhore conforme seu caso, ex: por chave única) | |
const map = new Map(); | |
const minLen = Math.min(rows.length, dstRows.length); | |
for (let i = 0; i < minLen; i++) { | |
const s = rows[i]; | |
const d = dstRows[i]; | |
const sId = s.id ?? s._rowId ?? s._id ?? String(i); | |
const dId = d.id ?? d._rowId ?? d._id ?? String(i); | |
map.set(String(sId), String(dId)); | |
} | |
idMaps.set(tname, map); | |
console.log(` - Inseridos ${simpleRecords.length}. Mapeados ${map.size} IDs.`); | |
} | |
// 5) segunda passada: popular colunas de LINK usando os mapas de IDs | |
console.log("Preenchendo relações (segunda passada) …"); | |
const limit = pLimit(CONCURRENCY); | |
for (const entry of schema) { | |
const tname = entry.table.table_name || entry.table.title; | |
const srcTableId = entry.table.id; | |
const dstTableId = tableNameToId_DST.get(tname); | |
const cols = entry.columns.filter((c) => mapColumnType(c) === "link"); | |
if (cols.length === 0) continue; | |
const rowsSrc = await listAllRecords(SRC.host, SRC.token, srcTableId, PAGE_LIMIT); | |
const rowsDst = await listAllRecords(DST.host, DST.token, dstTableId, PAGE_LIMIT); | |
const srcToDst = idMaps.get(tname); | |
// índice destino por posição (mesma heurística) | |
const indexByPos = new Map(); | |
const minLen = Math.min(rowsSrc.length, rowsDst.length); | |
for (let i = 0; i < minLen; i++) { | |
const sId = rowsSrc[i].id ?? rowsSrc[i]._rowId ?? rowsSrc[i]._id ?? String(i); | |
const dId = rowsDst[i].id ?? rowsDst[i]._rowId ?? rowsDst[i]._id ?? String(i); | |
indexByPos.set(String(sId), String(dId)); | |
} | |
// Para cada linha origem, montar payload com colunas link mapeadas e dar PATCH | |
const tasks = []; | |
for (let i = 0; i < rowsSrc.length; i++) { | |
const s = rowsSrc[i]; | |
const sId = String(s.id ?? s._rowId ?? s._id ?? String(i)); | |
const dId = indexByPos.get(sId) || srcToDst.get(sId); | |
if (!dId) continue; | |
const payload = {}; | |
for (const col of cols) { | |
const srcVal = s[col.column_name || col.title]; | |
if (srcVal == null) continue; | |
const targetTableName = | |
col.fk_table_name || col.ref_table_name || col.meta?.refTableName; | |
if (!targetTableName) continue; | |
const targetMap = idMaps.get(targetTableName); | |
if (!targetMap) { | |
console.warn( | |
` (skip) relação ${tname}.${col.column_name} → ${targetTableName} sem mapa de IDs` | |
); | |
continue; | |
} | |
// link pode ser simples (um id) ou múltiplo (array de ids) | |
if (Array.isArray(srcVal)) { | |
const mapped = srcVal | |
.map((orig) => targetMap.get(String(orig)) || null) | |
.filter(Boolean); | |
payload[col.column_name || col.title] = mapped; | |
} else { | |
const mapped = targetMap.get(String(srcVal)); | |
if (mapped) payload[col.column_name || col.title] = mapped; | |
} | |
} | |
if (Object.keys(payload).length > 0) { | |
tasks.push( | |
limit(() => updateRecord(DST.host, DST.token, dstTableId, dId, payload)) | |
); | |
} | |
} | |
await Promise.all(tasks); | |
console.log(` - ${tname}: atualizadas ${tasks.length} linhas com relações.`); | |
} | |
console.log("✅ Concluído. Schema (se habilitado) + dados + relações migrados."); | |
})().catch((err) => { | |
console.error("Falhou:", err?.response?.data || err); | |
process.exit(1); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment