paradox-reader
v1.8.0
Published
Read-only, zero-dependency Paradox database (.DB/.MB) reader with SQL query support, low-memory streaming scan, and multi-file TsiLang SIB translation resolution
Maintainers
Readme
paradox-reader
Read-only, zero-dependency Paradox database (.DB) reader for Node.js.
Reads .DB files and their companion .MB memo/blob files entirely in memory. No file handles are kept open, no temp files are created, and the original files are never modified.
Quick start
const paradox = require('./paradox-reader');
// Read a single table
const { header, schema, records } = paradox.read('path/to/Table.DB');
// Memory-efficient streaming scan (for large files)
paradox.scan('path/to/History.DB', {
filter: row => row.ItemKey === 34,
onRow: (row) => process.stdout.write(JSON.stringify(row) + '\n'),
});
// SQL query across multiple tables
const rows = paradox.query(
`SELECT I.Name, C.CatKey FROM "Items.DB" AS I, "Categories.DB" AS C
WHERE I.CatKey = C.CatKey`,
{ basePath: 'path/to/tables/' }
);
// Pivot EAV tables into objects
const data = paradox.scan('Settings.DB');
const groups = paradox.pivot(data); // Map<GroupName, { prop: value, ... }>
// Export
const json = paradox.toJSON(rows); // always an array
const csv = paradox.toCSV(data); // with BOM + CRLFAPI
paradox.read(dbPath, [options])
Reads and parses a single Paradox table. Loads all records into memory. Best for small/medium tables.
| Parameter | Type | Description |
|-----------|------|-------------|
| dbPath | string | Path to the .DB file |
| options.encoding | string | Text encoding for alpha fields (default: 'latin1') |
| options.mbPath | string | Explicit path to .MB file (auto-detected if omitted) |
Returns { header, schema, records }
header— Table metadata:tableName,version,fileType,recordSize,headerSizeblockSize,numRecords,numFields,fileBlocksfirstBlock,lastBlock,encrypted,sortOrder
schema— Array of field descriptors:name— Field nametype— Human-readable type ('alpha','long','memo', etc.)typeCode— Raw Paradox type bytesize— Field size in bytes
records— Array of plain objects keyed by field name.
paradox.scan(dbPath, [options])
Memory-efficient streaming reader. Processes block-by-block and never accumulates all records unless you want it to. Ideal for large files (e.g. 40-90MB historical/config tables) on memory-constrained machines.
| Parameter | Type | Description |
|-----------|------|-------------|
| dbPath | string | Path to the .DB file |
| options.filter | function | fn(row) → boolean — only matching rows are kept/emitted |
| options.columns | string[] | Only parse these columns (skip the rest for speed) |
| options.onRow | function | fn(row, index) — streaming callback; rows are NOT accumulated |
| options.encoding | string | Text encoding for alpha fields (default: 'latin1') |
| options.mbPath | string | Explicit path to .MB file (auto-detected if omitted) |
Returns { header, schema, records } — records is [] if onRow was provided.
// Stream 30 days of 50MB encrypted files — heap stays ~4.5MB
const results = [];
for (const file of dayFiles) {
paradox.scan(file, {
filter: row => row.ItemKey === 34,
columns: ['ItemKey', 'EntryTime', 'Value'],
onRow: (row) => results.push(row),
});
}
// Or accumulate only filtered rows
const { records } = paradox.scan('BigTable.DB', {
filter: row => row.CategoryKey > 100 && row.CategoryKey < 200,
});paradox.query(sql, [options])
Execute a BDE-compatible SQL query across one or more Paradox tables.
Tables referenced in FROM are loaded from basePath.
Automatic optimization: For tables >5MB with pushable WHERE filters,
query() automatically uses scan() with filter pushdown instead of
loading the entire table. Small tables use read() with caching.
| Parameter | Type | Description |
|-----------|------|-------------|
| sql | string | SQL query string |
| options.basePath | string | Directory containing the .DB files |
| options.encoding | string | Text encoding (default: 'latin1') |
| options.noCache | boolean | Skip cache and always re-read from disk |
Returns object[] — Always an array. Empty result = [], single row = [{...}].
Supported SQL:
SELECT alias.Column AS OutputName, 0 AS LiteralCol
FROM "TableName.DB" AS Alias, "Other.DB" AS B
WHERE Alias.Col = B.Col AND Alias.Type = 'value'SELECTwith column references, aliases, numeric/string literals,*FROMwith quoted table paths (.DB), aliases, comma-joinsWHEREwithAND, comparison operators (=,<>,<,>,<=,>=)DISTINCT- Case-insensitive column matching and BDE-compatible type coercion
- Hash joins for multi-table queries
paradox.pivot(data, [options])
Transform Entity-Attribute-Value rows into one object per group. Designed for EAV/pivot tables where each logical "row" is spread across many records (GroupName=entity, PropName=attribute, PropValue=value).
| Parameter | Type | Description |
|-----------|------|-------------|
| data | object[] or { records } | Rows from read(), scan(), or query() |
| options.groupBy | string | Column to group by (default: 'GroupName') |
| options.nameCol | string | Column containing property names (default: 'PropName') |
| options.valueCol | string | Column containing values (default: 'PropValue') |
| options.typeCol | string | Column with type hints for coercion (default: 'PropType') |
| options.coerce | boolean | Coerce values using typeCol (default: true) |
Returns Map<string, object> — Each key is a group value, each value is
{ propName1: val1, propName2: val2, ... } with types coerced from PropType.
// Get all config for group "10" as a single object
const data = paradox.scan('Settings.DB', {
filter: row => row.GroupName === '10',
});
const groups = paradox.pivot(data);
const config = groups.get('10');
// config = { "Threshold": 5, "Timeout": 10080, "Mode": "A", ... }
// Full table pivot for mapping
const all = paradox.pivot(paradox.scan('Settings.DB'));
// all.size = 167 groups, each with hundreds of typed propertiesType coercion (when coerce: true):
| PropType | JS type |
|----------|---------|
| Float | number |
| Integer | number (parseInt) |
| Boolean | boolean |
| String | string |
| DateTime | string (as-is) |
paradox.toJSON(data, [pretty])
Convert query results or read() output to a JSON string.
paradox.toJSON(queryResults) // → '[{"Col":1}, ...]'
paradox.toJSON(paradox.read(path)) // → '[{"Col":1}, ...]' (extracts .records)
paradox.toJSON([]) // → '[]'
paradox.toJSON(results, true) // → pretty-printed with 2-space indentparadox.toCSV(data)
Converts the output of read() or scan() into a CSV string with BOM and CRLF endings.
const data = paradox.read('Table.DB');
require('fs').writeFileSync('Table.csv', paradox.toCSV(data), 'utf8');paradox.clearCache()
Clear the internal table cache used by query(). Call this if the
underlying .DB files have changed and you want to force a re-read.
paradox.setCacheTTL(ms)
Set the cache time-to-live in milliseconds. Cached tables that haven't
been accessed within this window are automatically evicted. Default is
60000 (1 minute). Set to 0 to disable caching entirely.
// Keep cached tables for 5 minutes
paradox.setCacheTTL(300000);
// Disable caching (every query reads from disk)
paradox.setCacheTTL(0);
// Restore default (1 minute)
paradox.setCacheTTL(60000);TsiLang SIB Translation Resolution
Some Paradox-based systems store translatable strings as encoded references
in alpha fields rather than plain text. This module
can resolve those references back to human-readable strings using .sib files
from a Translations directory.
Translation reference formats
| Pattern | Example | Resolves to |
|---------|---------|-------------|
| Simple | [~T~][~Categories\DB_5~] | Voltage |
| Multi-file (string key) | [~T:AppLib\TSilData_App~]~Zone A\[~strStatusFan~] | ~Zone A\Fan Output |
| Parametric (%s substitution) | [~TF:AppLib\TSilData_App~][~P:1~]~Zone A\[~strStage~] | ~Zone A\Stage 1 |
| Inline mixed | [~T~]~Room 3, [~Alerts\DB_42~] | ~Room 3, Door Ajar |
| Cross-file 3-part path | [~T~][~Lib\TSilData_Lib\strCategory~] | Refrigeration |
paradox.loadTranslations(configPath, [opts])
Load all .sib files from a Translations directory and return a resolver
object. Auto-discovers the directory by searching relative to configPath,
or use opts.translationsDir for an explicit path.
| Parameter | Type | Description |
|-----------|------|-------------|
| configPath | string | Base path used for auto-discovery (searches for a sibling/ancestor Translations/ folder) |
| opts.translationsDir | string | Explicit path to the directory containing .sib files (skips auto-discovery) |
| opts.sibPath | string | Explicit path to a single .sib file (fallback when no directory is found) |
| opts.lang | string | Language column: 'English' (default), 'French', or 'Spanish' |
Returns { tables, resolve, resolveAll } or null if no SIB files are found.
resolve(value)— Resolve a single string. Returns the original value if it contains no translation references.resolveAll(records)— Resolve all string fields in an array of row objects (mutates in-place, returns same array).tables— The internal lookup structure (for advanced use withresolveRef/resolveAllRefs).
const paradox = require('paradox-reader');
// Auto-discover: looks for Translations/ relative to the given path
const t = paradox.loadTranslations('/path/to/Config');
// Explicit directory
const t = paradox.loadTranslations('/any/path', {
translationsDir: '/path/to/Translations',
});
// Single SIB file fallback
const t = paradox.loadTranslations('/any/path', {
sibPath: '/path/to/Translations/DB.sib',
});
// Use French translations
const t = paradox.loadTranslations('/path/to/Config', { lang: 'French' });Resolving query results
const paradox = require('paradox-reader');
const translations = paradox.loadTranslations('/path/to/Config');
const rows = paradox.query(
'SELECT Name, TypeKey FROM Items.DB',
{ basePath: '/path/to/Config' }
);
// Resolve all [~...~] references in every row
if (translations) translations.resolveAll(rows);
// Or resolve a single value
if (translations) {
const name = translations.resolve('[~T~][~Categories\\DB_5~]');
// → "Voltage"
}Auto-discovery logic
When opts.translationsDir is not provided, loadTranslations searches for
a Translations/ directory at these paths relative to configPath:
configPath/../../Translations/configPath/../Translations/configPath/Translations/
The first path that exists is used. All .sib files in that directory are
parsed and indexed by filename (e.g., AppLib.sib → file key AppLib).
paradox.parseSib(sibPath)
Low-level: parse a single .sib file. Returns a Map<sectionName, Map<key, { English, French, Spanish }>>.
Keys are numeric for DB_N entries and strings for named entries.
paradox.resolveRef(value, tables, [lang])
Low-level: resolve a single translation reference using a tables object.
paradox.resolveAllRefs(records, tables, [lang])
Low-level: resolve all string fields in an array of records using a tables object.
Supported field types
| Type | Code | JS value |
|------|------|----------|
| Alpha | 0x01 | string or null |
| Date | 0x02 | "YYYY-MM-DD" string or null |
| Short | 0x03 | number or null |
| Long | 0x04 | number or null |
| Currency | 0x05 | number or null |
| Number | 0x06 | number or null |
| Logical | 0x09 | true, false, or null |
| Memo | 0x0C | string or null |
| Blob | 0x0D | string or null |
| FmtMemo | 0x0E | string or null |
| OLE | 0x10 | Buffer or null |
| Graphic | 0x11 | Buffer or null |
| Time | 0x14 | "HH:MM:SS" string or null |
| Timestamp | 0x15 | "YYYY-MM-DDTHH:MM:SS" string or null |
| AutoInc | 0x16 | number or null |
| BCD | 0x17 | number or null |
| Bytes | 0x18 | hex string or null |
How it works
fs.readFileSync()loads the .DB (and .MB if present) into memory buffers. File handles are closed immediately by Node — nothing is kept open.- The header is parsed to extract field descriptors, names, and block layout.
- Records are read by walking the data block chain (block-by-block).
- Memo/blob fields are resolved from the in-memory .MB buffer using the pxlib-compatible sub-allocator algorithm (type 2 and type 3 blocks).
- Encrypted tables are auto-detected and decrypted transparently using the pxlib-compatible chunk-based permutation cipher.
read() vs scan():
read()loads ALL records into an array — convenient for small/medium tables.scan()processes block-by-block with optional filter pushdown — memory stays flat (~4.5MB heap) regardless of table size. Use for large files.
query() smart loading:
- Small tables (<5MB): loaded with
read(), cached by mtime for reuse. Cache entries are auto-evicted after 1 minute of no access (configurable viasetCacheTTL()). - Large tables (>5MB) with WHERE filters: loaded with
scan()+ filter pushdown. Only matching rows enter memory. - Pass
noCache: trueto skip caching for one-off queries (e.g. historical day files).
Performance
| Scenario | Size | Records | Operation | Time | Heap |
|----------|------|---------|-----------|------|------|
| Encrypted daily log | 40MB | 1.2M | read() | ~700ms | ~160MB |
| Encrypted daily log | 40MB | 1.2M | scan() with onRow | ~500ms | ~4.5MB |
| 8 daily logs | 320MB | 9M | scan() sequentially | ~3.8s | ~4.5MB |
| 30 daily logs | 1.2GB | 36M | scan() (projected) | ~14s | ~4.5MB |
| Large EAV config | 87MB | 103K | scan() + pivot() | ~136ms | ~27MB |
| Large EAV config | 87MB | 103K | scan(filter) + pivot() | ~98ms | ~4.3MB |
| Large EAV config | 87MB | 103K | SQL with pushdown | ~95ms | ~4.6MB |
| Small lookup table | <1MB | 5.3K | read() (cached) | ~3ms | cached |
Supported versions
- Paradox 3.x, 4.x, 5.x, 7.x table formats
- Encrypted and unencrypted tables
- Memo (.MB) blob files with type 2 (single-block) and type 3 (sub-allocated) blocks
Safety guarantee
This module only reads. It uses fs.readFileSync which:
- Opens the file read-only
- Copies the contents into a Node.js Buffer
- Closes the file handle immediately
- Never writes, truncates, or modifies the source file
You can safely point it at live/production database files.
