d0f90542e9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
228 lines
8.6 KiB
JavaScript
228 lines
8.6 KiB
JavaScript
'use strict';
|
|
// Lectura de tablas FoxPro en SOLO LECTURA. Nunca se abre el original para leer registros:
|
|
// se copia DBF+FPT a una carpeta temporal y se lee la copia (no choca con los bloqueos de FoxPro).
|
|
const fs = require('fs');
|
|
const fsp = require('fs/promises');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const { DBFFile, DELETED } = require('dbffile');
|
|
const { ID_RE } = require('./config');
|
|
|
|
class HttpError extends Error {
|
|
constructor(status, message) {
|
|
super(message);
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
// --- Resolución segura de archivos (el nombre llega del cliente; solo se acepta si existe en la carpeta) ---
|
|
|
|
async function dirIndex(source) {
|
|
const names = await fsp.readdir(source.path);
|
|
const map = new Map(); // MAYÚSCULAS -> nombre real
|
|
for (const n of names) map.set(n.toUpperCase(), n);
|
|
return map;
|
|
}
|
|
|
|
function tableAllowed(source, table) {
|
|
return source.tables === '*' || Object.prototype.hasOwnProperty.call(source.tables, table);
|
|
}
|
|
|
|
async function resolveTable(source, tableName) {
|
|
if (!ID_RE.test(tableName)) throw new HttpError(400, 'Nombre de tabla inválido');
|
|
const table = tableName.toUpperCase();
|
|
if (!tableAllowed(source, table)) throw new HttpError(404, 'Tabla no encontrada');
|
|
const index = await dirIndex(source);
|
|
const dbfName = index.get(`${table}.DBF`);
|
|
if (!dbfName) throw new HttpError(404, 'Tabla no encontrada');
|
|
const memoName = index.get(`${table}.FPT`) || index.get(`${table}.DBT`) || null;
|
|
return {
|
|
table,
|
|
dbfPath: path.join(source.path, dbfName),
|
|
memoPath: memoName ? path.join(source.path, memoName) : null,
|
|
};
|
|
}
|
|
|
|
function visibleFields(source, table, fields) {
|
|
const rule = source.tables === '*' ? null : source.tables[table];
|
|
const allow = rule && rule.fields ? new Set(rule.fields) : null;
|
|
const deny = new Set(source.denyFields);
|
|
return fields.filter((f) => {
|
|
const n = f.name.toUpperCase();
|
|
return !deny.has(n) && (!allow || allow.has(n));
|
|
});
|
|
}
|
|
|
|
// Firma del archivo: cambia cuando FoxPro escribe (sirve al sistema para saber si hay que resincronizar).
|
|
async function signature(ref) {
|
|
const a = await fsp.stat(ref.dbfPath);
|
|
const b = ref.memoPath ? await fsp.stat(ref.memoPath) : null;
|
|
const raw = `${a.size}:${a.mtimeMs}:${b ? `${b.size}:${b.mtimeMs}` : '-'}`;
|
|
return { version: crypto.createHash('sha1').update(raw).digest('hex').slice(0, 16), modifiedAt: new Date(Math.max(a.mtimeMs, b ? b.mtimeMs : 0)) };
|
|
}
|
|
|
|
function lockError(err) {
|
|
if (['EBUSY', 'EPERM', 'EACCES'].includes(err.code)) {
|
|
return new HttpError(423, 'La tabla está bloqueada por FoxPro (uso exclusivo); intente más tarde');
|
|
}
|
|
return err;
|
|
}
|
|
|
|
async function withSnapshot(ref, fn) {
|
|
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'conector-fox-'));
|
|
try {
|
|
const dbfCopy = path.join(dir, 't.dbf');
|
|
try {
|
|
await fsp.copyFile(ref.dbfPath, dbfCopy);
|
|
if (ref.memoPath) await fsp.copyFile(ref.memoPath, path.join(dir, ref.memoPath.toLowerCase().endsWith('.dbt') ? 't.dbt' : 't.fpt'));
|
|
} catch (err) {
|
|
throw lockError(err);
|
|
}
|
|
// VFP con autoincrement/varchar (0x31/0x32) usa el mismo formato de memo que 0x30; la librería solo
|
|
// busca el .fpt para 0x30/0xF5, así que se normaliza el byte de versión EN LA COPIA.
|
|
const fd = await fsp.open(dbfCopy, 'r+');
|
|
try {
|
|
const b = Buffer.alloc(1);
|
|
await fd.read(b, 0, 1, 0);
|
|
if (b[0] === 0x31 || b[0] === 0x32) await fd.write(Buffer.from([0x30]), 0, 1, 0);
|
|
} finally {
|
|
await fd.close();
|
|
}
|
|
return await fn(dbfCopy);
|
|
} finally {
|
|
await fsp.rm(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function toPlain(value, type) {
|
|
if (value === null || value === undefined) return null;
|
|
if (value instanceof Date) {
|
|
if (Number.isNaN(value.getTime())) return null;
|
|
return type === 'D' ? value.toISOString().slice(0, 10) : value.toISOString();
|
|
}
|
|
if (typeof value === 'string') {
|
|
const s = value.replace(/\0+$/, '').trim();
|
|
return s === '' ? null : s;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
// --- Caché por tabla (se invalida sola cuando cambia la firma) ---
|
|
|
|
const cache = new Map(); // `${source}/${table}` -> { version, fields, records }
|
|
|
|
function cachePut(key, entry, limits) {
|
|
cache.delete(key);
|
|
cache.set(key, entry);
|
|
while (cache.size > limits.cacheMaxTables) cache.delete(cache.keys().next().value);
|
|
}
|
|
|
|
async function loadTable(source, ref, limits) {
|
|
const key = `${source.id}/${ref.table}`;
|
|
const sig = await signature(ref);
|
|
const hit = cache.get(key);
|
|
if (hit && hit.version === sig.version) {
|
|
cache.delete(key);
|
|
cache.set(key, hit); // LRU
|
|
return hit;
|
|
}
|
|
const entry = await withSnapshot(ref, async (copy) => {
|
|
const dbf = await DBFFile.open(copy, { readMode: 'loose', encoding: source.encoding, includeDeletedRecords: true });
|
|
const fields = visibleFields(source, ref.table, dbf.fields);
|
|
const records = [];
|
|
let recno = 0;
|
|
for await (const r of dbf) {
|
|
recno += 1;
|
|
const row = { _recno: recno, _deleted: Boolean(r[DELETED]) };
|
|
for (const f of fields) row[f.name] = toPlain(r[f.name], f.type);
|
|
records.push(row);
|
|
}
|
|
return { version: sig.version, modifiedAt: sig.modifiedAt, fields, records };
|
|
});
|
|
if (entry.records.length <= limits.cacheMaxRecordsPerTable) cachePut(key, entry, limits);
|
|
return entry;
|
|
}
|
|
|
|
// --- Operaciones públicas ---
|
|
|
|
async function listTables(source) {
|
|
const index = await dirIndex(source);
|
|
const out = [];
|
|
for (const [upper] of index) {
|
|
if (!upper.endsWith('.DBF')) continue;
|
|
const table = upper.slice(0, -4);
|
|
if (!ID_RE.test(table) || !tableAllowed(source, table)) continue;
|
|
try {
|
|
const ref = await resolveTable(source, table);
|
|
const dbf = await DBFFile.open(ref.dbfPath, { readMode: 'loose', encoding: source.encoding }); // solo lee la cabecera
|
|
const sig = await signature(ref);
|
|
out.push({ name: table, records: dbf.recordCount, fields: visibleFields(source, table, dbf.fields).length, hasMemo: Boolean(ref.memoPath), version: sig.version, modifiedAt: sig.modifiedAt });
|
|
} catch (err) {
|
|
out.push({ name: table, error: lockError(err).status === 423 ? 'bloqueada' : 'no legible' });
|
|
}
|
|
}
|
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
async function getSchema(source, tableName) {
|
|
const ref = await resolveTable(source, tableName);
|
|
let dbf;
|
|
try {
|
|
dbf = await DBFFile.open(ref.dbfPath, { readMode: 'loose', encoding: source.encoding });
|
|
} catch (err) {
|
|
throw lockError(err);
|
|
}
|
|
const sig = await signature(ref);
|
|
return {
|
|
table: ref.table,
|
|
records: dbf.recordCount,
|
|
version: sig.version,
|
|
modifiedAt: sig.modifiedAt,
|
|
fields: visibleFields(source, ref.table, dbf.fields).map((f) => ({ name: f.name, type: f.type, size: f.size, decimals: f.decimalPlaces || 0 })),
|
|
};
|
|
}
|
|
|
|
function fold(s) {
|
|
return String(s).normalize('NFD').replace(/[̀-ͯ]/g, '').toUpperCase();
|
|
}
|
|
|
|
async function readRows(source, tableName, q, limits) {
|
|
const ref = await resolveTable(source, tableName);
|
|
const data = await loadTable(source, ref, limits);
|
|
const known = new Set(data.fields.map((f) => f.name.toUpperCase()));
|
|
const pick = (list, what) => {
|
|
const names = String(list).split(',').map((s) => s.trim().toUpperCase()).filter(Boolean);
|
|
for (const n of names) if (!known.has(n)) throw new HttpError(400, `Campo desconocido en ${what}: ${n}`);
|
|
return names;
|
|
};
|
|
|
|
const fields = q.fields ? pick(q.fields, 'fields') : data.fields.map((f) => f.name);
|
|
const limit = Math.min(Math.max(parseInt(q.limit, 10) || 100, 1), limits.maxPageSize);
|
|
const offset = Math.max(parseInt(q.offset, 10) || 0, 0);
|
|
const includeDeleted = q.includeDeleted === '1' || q.includeDeleted === 'true';
|
|
|
|
const eq = Object.entries(q)
|
|
.filter(([k]) => k.startsWith('eq.'))
|
|
.map(([k, v]) => [pick(k.slice(3), 'eq')[0], fold(v).trim()]);
|
|
const text = q.q ? fold(q.q).trim() : '';
|
|
const qFields = text ? (q.qFields ? pick(q.qFields, 'qFields') : fields) : [];
|
|
|
|
const matches = data.records.filter((r) => {
|
|
if (!includeDeleted && r._deleted) return false;
|
|
for (const [f, v] of eq) if (r[f] === null || fold(r[f]).trim() !== v) return false;
|
|
if (text && !qFields.some((f) => r[f] !== null && fold(r[f]).includes(text))) return false;
|
|
return true;
|
|
});
|
|
|
|
const rows = matches.slice(offset, offset + limit).map((r) => {
|
|
const o = { _recno: r._recno };
|
|
if (includeDeleted) o._deleted = r._deleted;
|
|
for (const f of fields) o[f] = r[f];
|
|
return o;
|
|
});
|
|
return { table: ref.table, version: data.version, modifiedAt: data.modifiedAt, total: matches.length, offset, limit, fields, rows };
|
|
}
|
|
|
|
module.exports = { listTables, getSchema, readRows, HttpError };
|