Extends the Test Datasheet Pipeline on AD2:C:\Shares\testdatadb to
generate web-published datasheets for the SCMVAS-Mxxx (obsolete) and
SCMHVAS-Mxxxx (replacement) High Voltage Input Module product lines.
Both are tested either with the existing TESTHV3 software (production
VASLOG .DAT logs) or in Engineering with plain .txt output.
Key changes on AD2 (all deployed 2026-04-12 with dated backups):
- parsers/spec-reader.js: getSpecs() returns a `{_family:'SCMVAS',
_noSpecs:true}` sentinel for SCMVAS/SCMHVAS/VAS-M/HVAS-M model prefixes
so the export pipeline does not silently skip them for missing specs.
- templates/datasheet-exact.js: new Accuracy-only template branch
(generateSCMVASDatasheet + helpers) that mirrors the existing shipped
format byte-for-byte. Extraction regex covers both QuickBASIC STR$()
output formats: scientific-with-trailing-status-digit (98.4% of
records) and plain-decimal (1.6% of records above QB's threshold).
- parsers/vaslog-engtxt.js (new): parses the Engineering-Tested .txt
files in TS-3R\LOGS\VASLOG\VASLOG - Engineering Tested\. Filename SN
regex strips optional trailing 14-digit timestamp; in-file "SN:"
header is the authoritative source when the filename is malformed.
- database/import.js: LOG_TYPES grows a VASLOG_ENG entry with
subfolder + recursive flags. Pre-existing 7 log types keep their
implicit recursive=true behaviour (config.recursive !== false).
importFiles() routes VASLOG_ENG paths before the generic loop so a
VASLOG - Engineering Tested/*.txt path does not mis-dispatch to the
multiline parser.
- database/export-datasheets.js: VASLOG_ENG records are written
verbatim via fs.copyFileSync(source_file, For_Web/<SN>.TXT) for true
byte-level pass-through, with a graceful raw_data fallback when the
source file is no longer on disk.
Deploy outcome:
- 27,503 SCMVAS/SCMHVAS datasheets rendered (27,065 from scientific +
438 from plain-decimal PASS lines, post-patch rerun)
- 434 Engineering-Tested .txt files pass-through-copied to For_Web
- 0 errors across both batches
Repo layout added here:
- scmvas-hvas-research/: discovery artifacts (source .BAS, hvin.dat,
sample .DAT + .txt, binary-format notes, IMPLEMENTATION_PLAN.md)
- implementation/: staged final code + deploy helpers + local test
harness + per-step verification scripts
- backups/pre-deploy-20260412/: independent local snapshot of the 4
AD2 files replaced, pulled byte-for-byte before deploy
All helper scripts fetch the AD2 password at runtime from the SOPS
vault (clients/dataforth/ad2.sops.yaml). None of the committed files
contain the plaintext credential. Known vault-entry hygiene issue
(stale shell-escape backslash before the `!`) is documented in the
fetcher comments and stripped at read-time; flagged separately for
cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
261 lines
9.3 KiB
JavaScript
261 lines
9.3 KiB
JavaScript
/**
|
|
* SQLite to PostgreSQL Data Migration
|
|
*
|
|
* Streams all data from the SQLite testdata.db into PostgreSQL.
|
|
* Uses batch INSERTs for performance.
|
|
*
|
|
* Usage:
|
|
* node migrate-data.js Migrate all tables
|
|
* node migrate-data.js --skip-tsvector Skip tsvector rebuild (faster, trigger handles it)
|
|
* node migrate-data.js --table test_records Migrate only one table
|
|
*/
|
|
|
|
const path = require('path');
|
|
const Database = require('better-sqlite3');
|
|
const db = require('./db');
|
|
|
|
const SQLITE_PATH = path.join(__dirname, 'testdata.db');
|
|
const BATCH_SIZE = 5000;
|
|
|
|
async function migrateTestRecords(sqlite) {
|
|
console.log('\n--- Migrating test_records ---');
|
|
|
|
const total = sqlite.prepare('SELECT COUNT(*) as cnt FROM test_records').get().cnt;
|
|
console.log(` Source records: ${total.toLocaleString()}`);
|
|
|
|
// Disable triggers during bulk load for performance
|
|
await db.execute('ALTER TABLE test_records DISABLE TRIGGER trg_search_vector');
|
|
|
|
const stmt = sqlite.prepare('SELECT * FROM test_records ORDER BY id');
|
|
let migrated = 0;
|
|
let batch = [];
|
|
|
|
for (const row of stmt.iterate()) {
|
|
batch.push(row);
|
|
|
|
if (batch.length >= BATCH_SIZE) {
|
|
await insertTestRecordsBatch(batch);
|
|
migrated += batch.length;
|
|
batch = [];
|
|
process.stdout.write(`\r Migrated: ${migrated.toLocaleString()} / ${total.toLocaleString()}`);
|
|
}
|
|
}
|
|
|
|
// Flush remaining
|
|
if (batch.length > 0) {
|
|
await insertTestRecordsBatch(batch);
|
|
migrated += batch.length;
|
|
}
|
|
|
|
console.log(`\r Migrated: ${migrated.toLocaleString()} / ${total.toLocaleString()}`);
|
|
|
|
// Rebuild search_vector for all rows
|
|
console.log(' Rebuilding search_vector (this may take a few minutes)...');
|
|
await db.execute(`
|
|
UPDATE test_records SET search_vector = to_tsvector('english',
|
|
COALESCE(serial_number, '') || ' ' ||
|
|
COALESCE(model_number, '') || ' ' ||
|
|
COALESCE(raw_data, '')
|
|
)
|
|
`);
|
|
console.log(' search_vector rebuilt.');
|
|
|
|
// Re-enable trigger
|
|
await db.execute('ALTER TABLE test_records ENABLE TRIGGER trg_search_vector');
|
|
|
|
// Reset sequence to max id
|
|
await db.execute(`SELECT setval('test_records_id_seq', (SELECT COALESCE(MAX(id), 1) FROM test_records))`);
|
|
|
|
return migrated;
|
|
}
|
|
|
|
async function insertTestRecordsBatch(batch) {
|
|
// Build a multi-row INSERT
|
|
const cols = ['id', 'log_type', 'model_number', 'serial_number', 'test_date',
|
|
'test_station', 'overall_result', 'raw_data', 'source_file',
|
|
'import_date', 'datasheet_exported_at', 'forweb_exported_at', 'work_order'];
|
|
|
|
const values = [];
|
|
const params = [];
|
|
let paramIdx = 0;
|
|
|
|
for (const row of batch) {
|
|
const placeholders = cols.map(() => {
|
|
paramIdx++;
|
|
return `$${paramIdx}`;
|
|
});
|
|
values.push(`(${placeholders.join(',')})`);
|
|
|
|
params.push(
|
|
row.id,
|
|
row.log_type,
|
|
row.model_number,
|
|
row.serial_number,
|
|
row.test_date,
|
|
row.test_station,
|
|
row.overall_result,
|
|
row.raw_data,
|
|
row.source_file,
|
|
row.import_date,
|
|
row.datasheet_exported_at,
|
|
row.forweb_exported_at,
|
|
row.work_order
|
|
);
|
|
}
|
|
|
|
const sql = `INSERT INTO test_records (${cols.join(',')})
|
|
VALUES ${values.join(',')}
|
|
ON CONFLICT (log_type, model_number, serial_number, test_date, test_station)
|
|
DO NOTHING`;
|
|
|
|
await db.execute(sql, params);
|
|
}
|
|
|
|
async function migrateWorkOrders(sqlite) {
|
|
console.log('\n--- Migrating work_orders ---');
|
|
|
|
const rows = sqlite.prepare('SELECT * FROM work_orders ORDER BY id').all();
|
|
console.log(` Source records: ${rows.length.toLocaleString()}`);
|
|
|
|
let migrated = 0;
|
|
|
|
const cols = ['wo_number', 'wo_date', 'program', 'version',
|
|
'lib_version', 'test_station', 'source_file', 'import_date'];
|
|
|
|
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
|
|
const batch = rows.slice(i, i + BATCH_SIZE);
|
|
const values = [];
|
|
const params = [];
|
|
let paramIdx = 0;
|
|
|
|
for (const row of batch) {
|
|
const placeholders = cols.map(() => { paramIdx++; return `$${paramIdx}`; });
|
|
values.push(`(${placeholders.join(',')})`);
|
|
params.push(row.wo_number, row.wo_date, row.program, row.version,
|
|
row.lib_version, row.test_station, row.source_file, row.import_date);
|
|
}
|
|
|
|
await db.execute(
|
|
`INSERT INTO work_orders (${cols.join(',')}) VALUES ${values.join(',')}
|
|
ON CONFLICT (wo_number, test_station) DO NOTHING`,
|
|
params
|
|
);
|
|
migrated += batch.length;
|
|
}
|
|
|
|
console.log(` Migrated: ${migrated.toLocaleString()}`);
|
|
return migrated;
|
|
}
|
|
|
|
async function migrateWorkOrderLines(sqlite) {
|
|
console.log('\n--- Migrating work_order_lines ---');
|
|
|
|
const total = sqlite.prepare('SELECT COUNT(*) as cnt FROM work_order_lines').get().cnt;
|
|
console.log(` Source records: ${total.toLocaleString()}`);
|
|
|
|
const stmt = sqlite.prepare('SELECT * FROM work_order_lines ORDER BY id');
|
|
let migrated = 0;
|
|
let batch = [];
|
|
|
|
for (const row of stmt.iterate()) {
|
|
batch.push(row);
|
|
|
|
if (batch.length >= BATCH_SIZE) {
|
|
await insertWoLinesBatch(batch);
|
|
migrated += batch.length;
|
|
batch = [];
|
|
process.stdout.write(`\r Migrated: ${migrated.toLocaleString()} / ${total.toLocaleString()}`);
|
|
}
|
|
}
|
|
|
|
if (batch.length > 0) {
|
|
await insertWoLinesBatch(batch);
|
|
migrated += batch.length;
|
|
}
|
|
|
|
console.log(`\r Migrated: ${migrated.toLocaleString()} / ${total.toLocaleString()}`);
|
|
return migrated;
|
|
}
|
|
|
|
async function insertWoLinesBatch(batch) {
|
|
const cols = ['wo_number', 'serial_number', 'status', 'model_number',
|
|
'ds_filename', 'test_date', 'test_time', 'test_station'];
|
|
const values = [];
|
|
const params = [];
|
|
let paramIdx = 0;
|
|
|
|
for (const row of batch) {
|
|
const placeholders = cols.map(() => { paramIdx++; return `$${paramIdx}`; });
|
|
values.push(`(${placeholders.join(',')})`);
|
|
params.push(row.wo_number, row.serial_number, row.status,
|
|
row.model_number, row.ds_filename, row.test_date, row.test_time, row.test_station);
|
|
}
|
|
|
|
await db.execute(
|
|
`INSERT INTO work_order_lines (${cols.join(',')}) VALUES ${values.join(',')}
|
|
ON CONFLICT (wo_number, serial_number, test_date, test_time) DO NOTHING`,
|
|
params
|
|
);
|
|
}
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
const tableArg = args.indexOf('--table');
|
|
const targetTable = tableArg >= 0 ? args[tableArg + 1] : null;
|
|
|
|
console.log('========================================');
|
|
console.log('SQLite -> PostgreSQL Data Migration');
|
|
console.log('========================================');
|
|
console.log(`SQLite: ${SQLITE_PATH}`);
|
|
console.log(`Start: ${new Date().toISOString()}`);
|
|
|
|
const sqlite = new Database(SQLITE_PATH, { readonly: true });
|
|
|
|
try {
|
|
if (!targetTable || targetTable === 'test_records') {
|
|
await migrateTestRecords(sqlite);
|
|
}
|
|
if (!targetTable || targetTable === 'work_orders') {
|
|
await migrateWorkOrders(sqlite);
|
|
}
|
|
if (!targetTable || targetTable === 'work_order_lines') {
|
|
await migrateWorkOrderLines(sqlite);
|
|
}
|
|
|
|
// VACUUM ANALYZE
|
|
console.log('\n--- Running VACUUM ANALYZE ---');
|
|
await db.execute('VACUUM ANALYZE test_records');
|
|
await db.execute('VACUUM ANALYZE work_orders');
|
|
await db.execute('VACUUM ANALYZE work_order_lines');
|
|
console.log(' Done.');
|
|
|
|
// Verify counts
|
|
console.log('\n--- Verification ---');
|
|
const pgTestCount = await db.queryOne('SELECT COUNT(*) as cnt FROM test_records');
|
|
const pgWoCount = await db.queryOne('SELECT COUNT(*) as cnt FROM work_orders');
|
|
const pgWolCount = await db.queryOne('SELECT COUNT(*) as cnt FROM work_order_lines');
|
|
|
|
const sqliteTestCount = sqlite.prepare('SELECT COUNT(*) as cnt FROM test_records').get().cnt;
|
|
const sqliteWoCount = sqlite.prepare('SELECT COUNT(*) as cnt FROM work_orders').get().cnt;
|
|
const sqliteWolCount = sqlite.prepare('SELECT COUNT(*) as cnt FROM work_order_lines').get().cnt;
|
|
|
|
console.log(` test_records: SQLite=${sqliteTestCount.toLocaleString()} PG=${parseInt(pgTestCount.cnt).toLocaleString()} ${parseInt(pgTestCount.cnt) === sqliteTestCount ? '[OK]' : '[MISMATCH]'}`);
|
|
console.log(` work_orders: SQLite=${sqliteWoCount.toLocaleString()} PG=${parseInt(pgWoCount.cnt).toLocaleString()} ${parseInt(pgWoCount.cnt) === sqliteWoCount ? '[OK]' : '[MISMATCH]'}`);
|
|
console.log(` work_order_lines: SQLite=${sqliteWolCount.toLocaleString()} PG=${parseInt(pgWolCount.cnt).toLocaleString()} ${parseInt(pgWolCount.cnt) === sqliteWolCount ? '[OK]' : '[MISMATCH]'}`);
|
|
|
|
} finally {
|
|
sqlite.close();
|
|
await db.close();
|
|
}
|
|
|
|
console.log(`\n========================================`);
|
|
console.log(`Migration Complete`);
|
|
console.log(`========================================`);
|
|
console.log(`End: ${new Date().toISOString()}`);
|
|
}
|
|
|
|
main().catch(err => {
|
|
console.error('Migration failed:', err);
|
|
process.exit(1);
|
|
});
|