npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@detain/dbrel-data-js

v1.0.0

Published

Node.js backend data provider for database relationship visualization. MySQL-based counterpart to @detain/dbrel-data-php, produces identical JSON payload consumed by @detain/dbrel-viz.

Readme

dbrel-data-js

Node.js backend that turns your MySQL schema into an interactive relationship graph.

npm version npm downloads node license Build Status Code Coverage PRs Welcome

Sister package to dbrel-data-php. Same JSON output, same features — just Node.

Quick StartSchema FormatAPIExamplesCompanion Packages


TL;DRmysql2/promise in, visualization-ready JSON out. Pair it with @detain/dbrel-viz for an instant interactive database explorer.

Table of Contents


Why dbrel-data-js?

You've got a Node/Express (or Fastify, or Koa, or tRPC) stack and you want to show a "what does this customer own?" graph — one node per row, one edge per relationship, grouped by database, filtered to what matters.

dbrel-data-js is the smallest useful backend for that: give it a JSON schema, give it a mysql2/promise connection, and it hands you back a payload the dbrel-viz frontend can render with any of its 20 engines.

   ┌───────────────┐        ┌──────────────────────┐        ┌──────────────┐
   │  JSON schema  │        │   Your MySQL DBs     │        │ dbrel-viz    │
   │               │   ┌───▶│                      │◀───┐   │  (browser)   │
   │ • modules     │   │    │  accounts            │    │   │              │
   │ • prefixes    │   │    │  vps                 │    │   │  20 graph    │
   │ • primary keys│   │    │  domains …           │    │   │  renderers   │
   │ • relationships    │                               │   │              │
   └───────────────┘   │                                │   └──────┬───────┘
                       │                                │          │
                       ▼                                ▼          │
                 ┌───────────────────────────────────────┐         │
                 │          dbrel-data-js                │─────────┘
                 │                                       │   JSON
                 │  RelationshipSchema                   │
                 │  ├─ DataCollector (mysql2/promise)    │
                 │  ├─ RelationshipMatcher               │
                 │  └─ DataProvider                      │
                 └───────────────────────────────────────┘

Features

  • Promise-based & async/await friendly — everything is a Promise, no callbacks
  • Works with the entire mysql2 family — single connections, pools, read replicas, you name it
  • JSON-defined schema — the same schema used by the PHP sister package
  • Three relationship typesdirect, find_in_set, and cross_db
  • Pivot filtering — re-center on a specific table and auto-trim to tables within 2 hops
  • Row-level match arrays — edges know exactly which source row connects to which target rows
  • Virtual tables — register synthesized tables (e.g. EAV pivots) manually via addTable()
  • Metadata baked in — query time, counts, database list, pivot state
  • Payload parity with PHP — byte-identical JSON so you can migrate stacks with zero frontend changes
  • Zero framework lock-in — plain ES2020 with CommonJS exports, works in Express/Fastify/Koa/Nest/tRPC/etc.
  • No transpilation — the source works on every active LTS Node version
  • TypeScript types shipped (see src/index.d.ts in future releases)

Installation

npm install @detain/dbrel-data-js mysql2
# or
yarn add @detain/dbrel-data-js mysql2
# or
pnpm add @detain/dbrel-data-js mysql2

mysql2 is a peer — any version ≥ 3.x with the /promise entrypoint works.

Quick Start

An Express route that returns the payload for a given customer:

const express = require('express');
const mysql = require('mysql2/promise');
const {
    RelationshipSchema,
    DataCollector,
    DataProvider
} = require('@detain/dbrel-data-js');

const app = express();

// Load the schema once at boot
const schema = new RelationshipSchema(__dirname + '/config/db_relationships.json');

// Share a connection pool
const pool = mysql.createPool({
    host: 'localhost',
    user: 'root',
    password: '…',
    database: 'my',
    connectionLimit: 10
});

app.get('/api/db-relationships', async (req, res) => {
    const custid = parseInt(req.query.custid, 10) || 0;
    if (custid <= 0) return res.status(400).json({ error: 'custid required' });

    const collector = new DataCollector(pool);

    // Collect rows
    await collector.collect('my', 'accounts',
        'SELECT * FROM accounts WHERE account_id = ?', [custid], 1);
    await collector.collect('my', 'vps',
        'SELECT * FROM vps WHERE vps_custid = ?', [custid], 50);
    await collector.collect('my', 'domains',
        'SELECT * FROM domains WHERE domain_custid = ?', [custid], 50);
    await collector.collect('my', 'invoices_charges',
        'SELECT * FROM invoices_charges WHERE invoice_custid = ?', [custid], 50);

    const provider = new DataProvider(schema);
    res.json(provider.build(collector, {
        custid,
        primaryKeys:  { accounts: 'account_id', vps: 'vps_id' },
        prefixes:     { accounts: 'account_',   vps: 'vps_' },
        hiddenFields: ['password', 'api_token']
    }));
});

app.listen(3000, () => console.log('http://localhost:3000'));

Point dbrel-viz's ajaxUrl at /api/db-relationships and you're done.

Schema JSON Format

The schema is identical to the one used by dbrel-data-php. Keys are:

{
    "_metadata": {
        "generated":            "2026-04-14 00:20:16",
        "description":          "Billing system schema",
        "databases": {
            "my":        "10.0.0.1 (primary)",
            "pdns":      "10.0.0.2 (PowerDNS)",
            "kayako_v4": "10.0.0.3 (Helpdesk)"
        },
        "total_relationships": 319
    },
    "modules": {
        "vps":      { "table": "vps",      "prefix": "vps",     "title": "VPS",     "tblname": "VPS" },
        "domains":  { "table": "domains",  "prefix": "domain",  "title": "Domains", "tblname": "Domains" },
        "webhosting": { "table": "websites", "prefix": "website", "title": "Web Hosting", "tblname": "Websites" }
    },
    "table_to_module": {
        "websites": "webhosting",
        "vps":      "vps",
        "domains":  "domains"
    },
    "virtual_tables": {
        "accounts_ext": {
            "source_table": "accounts_extra",
            "pivot_on":     "account_extra_id",
            "key_column":   "account_extra_field",
            "value_column": "account_extra_value"
        }
    },
    "relationships": [
        {
            "source_db":    "my",
            "source_table": "vps",
            "source_field": "vps_custid",
            "target_db":    "my",
            "target_table": "accounts",
            "target_field": "account_id",
            "type":         "direct",
            "cardinality":  "N:1",
            "label":        "VPS → Account"
        },
        {
            "source_db":    "my",
            "source_table": "vps_groups",
            "source_field": "vps_group_hosts",
            "target_db":    "my",
            "target_table": "vps",
            "target_field": "vps_id",
            "type":         "find_in_set",
            "cardinality":  "N:M",
            "label":        "Group → VPS hosts"
        },
        {
            "source_db":    "my",
            "source_table": "accounts",
            "source_field": "account_id",
            "target_db":    "kayako_v4",
            "target_table": "swusers",
            "target_field": "externalid",
            "type":         "cross_db",
            "cardinality":  "1:1",
            "label":        "Account → Helpdesk user"
        }
    ]
}

| Field | Type | Required | Description | | :-- | :-- | :-: | :-- | | source_db | string | yes | Logical database name | | source_table | string | yes | Source table name | | source_field | string | yes | Column on the source holding the reference | | target_db | string | yes | Logical database name of the target | | target_table | string | yes | Target table name | | target_field | string | yes | Column on the target being referenced | | type | string | no | direct | find_in_set | cross_db (default direct) | | cardinality | string | no | 1:1 | 1:N | N:1 | N:M (default 1:N) | | label | string | no | Human-readable label for tooltips |

Unknown types are normalized to direct. Relationships with a parenthesized target (e.g. polymorphic (vps|websites) placeholders) are silently skipped.

API Reference

Entry module exports:

const {
    RelationshipSchema,
    RelationshipMatcher,
    DataCollector,
    DataProvider,
    version
} = require('@detain/dbrel-data-js');

RelationshipSchema

const schema = new RelationshipSchema(pathOrObject);

| Method | Returns | Description | | :-- | :-- | :-- | | constructor(pathOrObject: string \| object) | — | Accepts a file path or a pre-parsed object | | getRules(): object[] | object[] | Normalized rules ready for the matcher | | getModules(): object | object | Module definitions keyed by module name | | getTableToModule(): object | object | Table-to-module lookup | | getVirtualTables(): object | object | Virtual table definitions | | getMetadata(): object | object | The _metadata block | | getRaw(): object | object | The raw decoded JSON |

DataCollector

const collector = new DataCollector(conn);  // mysql2/promise Connection or Pool

| Method | Description | | :-- | :-- | | async collect(dbName, table, sql, params = [], limit = 50): Promise<void> | Runs the query (with optional bind params) and records up to limit rows + the full total count | | addTable(dbName, table, rows, columns?, total?): void | Manually add a virtual/computed table | | appendRows(dbName, table, rows): void | Append rows to an existing table (or create it) | | getTables(): object | All collected tables keyed by "db.table" | | has(key): bool | Whether a given "db.table" key exists | | getRows(key): object[] | Rows for a given key (empty if missing) | | getTotalRows(): number | Sum of total across all tables |

Note that collect() uses conn.query(sql, params) internally — this supports connection and pool objects from mysql2/promise. If you want a read replica or a second database, just instantiate another collector with its own connection:

const helpdesk = mysql.createPool({ host: 'helpdesk.internal', database: 'kayako_v4' });
const helpdeskCollector = new DataCollector(helpdesk);
await helpdeskCollector.collect('kayako_v4', 'swusers',
    'SELECT * FROM swusers WHERE externalid = ?', [custid], 5);

// Then merge its tables into your main collector:
Object.assign(collector.tables, helpdeskCollector.tables);

RelationshipMatcher

const matcher = new RelationshipMatcher();
const active  = matcher.compute(tablesData, rules);

| Method | Description | | :-- | :-- | | compute(tablesData, rules): object[] | Returns the active relationships, each with a matches array of [sourceRowIdx, [targetRowIdxs]] |

Algorithmic notes:

  • direct / cross_db — strict string-equality match on source_field vs. target_field
  • find_in_set — source field split on , then each value compared against the target column
  • Source values of null, undefined, '', '0', 0 are skipped (avoids noise from unset FKs)
  • Only relationships with at least one match are returned

DataProvider

const provider = new DataProvider(schema /*, matcher */);
const payload  = provider.build(collector, options);

| Method | Description | | :-- | :-- | | constructor(schema, matcher?) | Accepts an optional custom matcher; defaults to new RelationshipMatcher() | | build(collector, options?): object | Produces the payload | | getSchema(): RelationshipSchema | Accessor | | getMatcher(): RelationshipMatcher | Accessor |

| Key | Type | Default | Description | | :-- | :-- | :-: | :-- | | custid | number | 0 | Customer/entity ID — echoed into metadata | | primaryKeys | object | {} | Map of table → PK column name | | prefixes | object | {} | Map of table → prefix the frontend strips for display | | hiddenFields | string[] | [] | Columns the frontend must never render | | pivotTable | string | '' | If set, keep only tables within 2 hops of my.{pivotTable} | | pivotId | number | 0 | Echoed into metadata for the frontend's breadcrumb |

{
    custid: 12345,
    tables: {
        'my.accounts': {
            rows:      [{ account_id: 12345, /* ... */ }],
            columns:   ['account_id', /* ... */],
            total:     1,
            truncated: false
        }
        // …
    },
    relationships: [
        {
            source:       'my.accounts',
            source_field: 'account_id',
            target:       'my.vps',
            target_field: 'vps_custid',
            type:         'direct',
            cardinality:  '1:N',
            label:        'Account → VPS',
            matches:      [ [0, [0, 1, 2]] ]
        }
    ],
    metadata: {
        databases:          ['my', 'kayako_v4', 'pdns'],
        table_count:        14,
        total_rows:         42,
        relationship_count: 9,
        query_time_ms:      127.4,
        custid:             12345,
        pivot_table:        null,
        pivot_id:           null
    },
    prefixes:     { /* ... */ },
    primaryKeys:  { /* ... */ },
    hiddenFields: [ /* ... */ ]
}

Relationship Types

| Schema type | Computed as | Behavior | | :-- | :-: | :-- | | direct | direct | Exact string match between source_field and target_field | | fk_constraint | direct | Same as direct — explicit FK from information_schema | | implicit_fk | direct | Same as direct — discovered from code | | code_join | direct | Same as direct — found in app-code JOIN | | find_in_set | find_in_set | Source field is a CSV; split and match each piece | | cross_db | cross_db | Exact match, but the source and target live in different DBs | | polymorphic | — | Skipped (target is a parenthesized placeholder) | | conditional | direct | Treated as direct; add a notes field for humans |

Example: find_in_set

Imagine a vps_groups table with a vps_group_hosts column that stores a CSV of VPS IDs:

SELECT vps_group_id, vps_group_hosts FROM vps_groups;
-- 1,  "5,8,13,21"
-- 2,  "3,8"

With this rule:

{
  "source_db": "my", "source_table": "vps_groups", "source_field": "vps_group_hosts",
  "target_db": "my", "target_table": "vps",        "target_field": "vps_id",
  "type": "find_in_set"
}

…the matcher produces edges from group 1 to each of VPS rows 5, 8, 13, 21, and from group 2 to 3, 8 — even though no single query join could express this relationship cleanly.

Integration Examples

See the Quick Start section above — it's the canonical example.

const fastify = require('fastify')();
const mysql = require('mysql2/promise');
const { RelationshipSchema, DataCollector, DataProvider } = require('@detain/dbrel-data-js');

const schema = new RelationshipSchema('./config/db_relationships.json');
const pool = mysql.createPool({ /* ... */ });

fastify.get('/api/db-relationships', async (req) => {
    const custid = parseInt(req.query.custid, 10) || 0;
    if (custid <= 0) throw fastify.httpErrors.badRequest('custid required');

    const collector = new DataCollector(pool);
    await collector.collect('my', 'accounts',
        'SELECT * FROM accounts WHERE account_id = ?', [custid], 1);
    // … more collects …

    return new DataProvider(schema).build(collector, { custid });
});

fastify.listen({ port: 3000 });
app.get('/api/db-relationships/pivot', async (req, res) => {
    const custid     = parseInt(req.query.custid, 10) || 0;
    const pivotTable = String(req.query.pivot_table || '').replace(/[^a-z_]/gi, '');
    const pivotId    = parseInt(req.query.pivot_id, 10) || 0;

    const collector = new DataCollector(pool);
    // Collect everything relevant...

    const payload = new DataProvider(schema).build(collector, {
        custid, pivotTable, pivotId
    });
    // payload.tables now contains only tables within 2 hops of my.{pivotTable}
    res.json(payload);
});
const mainPool     = mysql.createPool({ host: 'db-main.internal',     database: 'my' });
const helpdeskPool = mysql.createPool({ host: 'db-helpdesk.internal', database: 'kayako_v4' });

async function buildPayload(custid) {
    const mainCol = new DataCollector(mainPool);
    const helpCol = new DataCollector(helpdeskPool);

    await Promise.all([
        mainCol.collect('my', 'accounts', 'SELECT * FROM accounts WHERE account_id = ?', [custid], 1),
        mainCol.collect('my', 'vps',      'SELECT * FROM vps WHERE vps_custid = ?',       [custid], 50),
        helpCol.collect('kayako_v4', 'swusers',
            'SELECT * FROM swusers WHERE externalid = ?', [custid], 5)
    ]);

    // Merge the second collector's tables into the first
    Object.assign(mainCol.tables, helpCol.tables);

    return new DataProvider(schema).build(mainCol, { custid });
}
// Pull EAV rows from accounts_extra and synthesize an accounts_ext "table"
const [eavRows] = await pool.query(
    'SELECT account_extra_id, account_extra_field, account_extra_value ' +
    'FROM accounts_extra WHERE account_extra_id = ?', [custid]);

const pivoted = {};
for (const r of eavRows) pivoted[r.account_extra_field] = r.account_extra_value;

collector.addTable('my', 'accounts_ext',
    [{ account_id: custid, ...pivoted }],
    ['account_id', ...Object.keys(pivoted)]);
class FuzzyMatcher extends RelationshipMatcher {
    compute(tablesData, rules) {
        const out = super.compute(tablesData, rules);
        // post-process to add additional matches from another algorithm
        return out;
    }
}

const provider = new DataProvider(schema, new FuzzyMatcher());

Architecture

┌───────────────────────────────────────────────────────────────────────┐
│                            Your application                           │
└──────────────────────────┬────────────────────────────────────────────┘
                           │
                           ▼
┌───────────────────────────────────────────────────────────────────────┐
│                        DataProvider.build()                           │
│                                                                       │
│    ┌─────────────────────┐    ┌─────────────────────────────────┐     │
│    │ RelationshipSchema  │    │ DataCollector                   │     │
│    │                     │    │                                 │     │
│    │ • Reads JSON        │    │ • collect() runs your SELECTs   │     │
│    │ • Normalizes rules  │    │ • addTable() for virtual tables │     │
│    │ • Skips polymorphic │    │ • appendRows()                  │     │
│    │                     │    │                                 │     │
│    │ getRules() ─────────┼────┼──▶ tables                       │     │
│    └─────────────────────┘    └─────────────────────────────────┘     │
│                                                                       │
│                              │                                        │
│                              ▼                                        │
│                  ┌─────────────────────────────┐                      │
│                  │ RelationshipMatcher         │                      │
│                  │                             │                      │
│                  │ direct:      strict eq      │                      │
│                  │ find_in_set: CSV explode    │                      │
│                  │ cross_db:    eq + flag      │                      │
│                  └─────────────────────────────┘                      │
│                              │                                        │
│                              ▼                                        │
│                   Pivot filter (optional)                             │
│                              │                                        │
│                              ▼                                        │
│                     JSON payload out                                  │
└───────────────────────────────────────────────────────────────────────┘

Data flow end-to-end

┌─────────────┐   ┌───────────────────────┐   ┌──────────────┐
│  MySQL      │──▶│  dbrel-data-js        │──▶│              │
│             │   │                       │   │ dbrel-viz    │
│  accounts   │   │  • RelationshipSchema │JSON│  (browser)   │
│  vps        │   │  • DataCollector      │──▶│              │
│  domains ...│   │  • RelationshipMatcher│   │  20 libs     │
└─────────────┘   │  • DataProvider       │   │              │
                  └───────────────────────┘   └──────────────┘

Companion Packages

This package is one of three in a family. Mix and match:

| Package | Language | Purpose | | :-- | :-- | :-- | | @detain/dbrel-viz | Browser JS | Frontend library — 20 pluggable renderers | | detain/dbrel-data-php | PHP ≥ 7.4 | Same API, for PHP/mysqli stacks | | @detain/dbrel-data-js | Node ≥ 14 | This package — the Node backend |

Payload paritydbrel-data-js and dbrel-data-php emit byte-identical JSON given the same schema and data. Migrate stacks without touching the frontend.

Requirements

  • Node.js ≥ 14 (tested on 14, 16, 18, 20)
  • mysql2 ≥ 3.0 with the /promise entrypoint
  • MySQL or MariaDB — any version that speaks the SQL you write

Contributing

git clone https://github.com/detain/dbrel-data-js.git
cd dbrel-data-js
npm install
npm test

PR guidelines

  1. One feature or fix per PR
  2. Keep JSON-output parity with dbrel-data-php — add a test that runs both side-by-side when possible
  3. New relationship types go on RelationshipMatcher (not on the schema format — the schema is frozen)
  4. Jest tests for all behavior changes
  5. Lowercase, descriptive commit messages

Ideas we'd love help with

  • TypeScript typessrc/index.d.ts stub
  • Schema generator — scan information_schema + code AST to auto-build the JSON
  • PostgreSQL adapter — the matcher is DB-agnostic; a pg-based collector would slot right in
  • Caching layercompute() is deterministic and a natural cache boundary

Code style

  • CommonJS for now (PRs adding ESM entrypoints via exports map welcome)
  • Plain ES2020 — no TypeScript compilation step
  • No framework in src/ — Express/Fastify/etc. belong in examples

License

MIT © 2025 Joe Huss / InterServer


⬆ back to top

Made with care by InterServer. Pair with dbrel-viz and dbrel-data-php.