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

in-memory-db-table

v1.0.1

Published

MobX-backed in-memory tables with indexed equality queries and query-builder helpers.

Readme

in-memory-db-table

in-memory-db-table is a small TypeScript library for MobX-backed in-memory tables with database-style indexed equality lookups.

It is useful for app state that behaves like a normalized relational graph in memory, such as:

  • records keyed by a foreign key or status field
  • join tables for many-to-many relationships
  • filtered collections that must stay in sync as data is inserted, updated, or removed
  • lookup-heavy UI state where exact-match reads are common

The core idea is simple:

  • every row is stored by primary key id
  • you can opt into secondary indices for selected columns
  • queries are exact-match filters on indexed columns
  • chained filters behave like AND
  • the underlying data is MobX-observable, so computed values, reactions, and UI bindings can observe query results

This package is intentionally small. It does not try to be a full ORM, a SQL parser, or a normalized entity framework. It is a focused utility for “I want a fast, observable, in-memory table with predictable lookup semantics.”

Install

npm install in-memory-db-table mobx

Peer Dependencies

  • mobx >=6.0.0 <7

Who This Is For

This library is a good fit when you:

  • already keep client-side data in MobX state
  • want to normalize records by id
  • repeatedly answer questions like “give me all rows for this foreign key”
  • want to chain exact-match filters across a small set of indexed columns
  • want a lightweight abstraction instead of scanning arrays manually all over the codebase

This library is especially useful for feature state that behaves like a relational graph in the UI:

  • many-to-many join tables
  • lookup tables for ids -> entities
  • filtered collections that must stay in sync as data is inserted, updated, or removed

Mental Model

Think of an InMemoryDBTable as a MobX-observable table with:

  • a mandatory primary key: id
  • zero or more secondary equality indices
  • an immutable query builder for composing filters
  • snapshot-style read APIs
  • mutation APIs that keep indices in sync automatically

If you have ever modeled UI data with:

  • a Map<string, T> for direct access
  • extra Map<columnValue, Set<id>> structures for filtering
  • helper methods for “first”, “exists”, “count”, and “delete matching rows”

this package formalizes that pattern into one reusable primitive.

Quick Start

import { autorun } from 'mobx';
import { InMemoryDBTable } from 'in-memory-db-table';

type CourseSection = {
  id: string;
  courseId: string;
  roomId: string | null;
  colorHex: string;
};

const courseSections = new InMemoryDBTable<
  CourseSection,
  'courseId' | 'roomId'
>([], ['courseId', 'roomId']);

courseSections.upsert([
  {
    id: 'section-1',
    courseId: 'course-1',
    roomId: 'room-a',
    colorHex: '#2463eb',
  },
  {
    id: 'section-2',
    courseId: 'course-1',
    roomId: null,
    colorHex: '#1f8f5f',
  },
]);

const sameCourse = courseSections
  .whereIndexedColumn('courseId', 'course-1')
  .get();

console.log(sameCourse.length); // 2

const dispose = autorun(() => {
  console.log(
    'Sections in room-a:',
    courseSections
      .whereIndexedColumn('roomId', 'room-a')
      .count()
  );
});

courseSections.delete('section-1');

dispose();

Core API

new InMemoryDBTable(records?, columnsToIndex?)

Creates a table.

const table = new InMemoryDBTable<User, 'role' | 'teamId'>(
  [],
  ['role', 'teamId']
);

Rules

  • T must include id: string
  • columnsToIndex should only include columns you plan to query frequently
  • id is always available as an implicit primary-key index
  • only configured indexed columns can be used with whereIndexedColumn(...)

What gets stored internally

The table maintains:

  • a record map: id -> record
  • one secondary index per configured column: columnValue -> Set<id>

Whenever you insert, update, or delete rows, those index maps are kept in sync for you.

table.upsert(record) / table.upsert(records)

Adds or replaces rows by id.

table.upsert({
  id: 'teacher-1',
  departmentId: 'science',
  name: 'Ada Lovelace',
});
table.upsert([
  {
    id: 'teacher-1',
    departmentId: 'science',
    name: 'Ada Lovelace',
  },
  {
    id: 'teacher-2',
    departmentId: 'math',
    name: 'Grace Hopper',
  },
]);

Update semantics

If a row with the same id already exists:

  • the old record is replaced
  • all configured secondary indices are updated
  • old index entries that no longer apply are removed

That behavior is important for UI state where a record’s foreign key can change over time. For example, if a row moves from teacherId = a to teacherId = b, queries for a stop returning it and queries for b start returning it immediately.

table.delete(id) / table.delete(ids)

Deletes one or more rows by primary key.

table.delete('entry-1');
table.delete(['entry-2', 'entry-3']);

Missing ids are ignored. All secondary indices are cleaned up automatically.

table.get()

Returns every row currently in the table.

const allRows = table.get();

This is useful when:

  • you want a full snapshot
  • the table is small enough that filtering in memory is acceptable
  • you are hydrating another derived structure

table.get(id)

Returns a single row or null.

const row = table.get('section-1');

This is the direct primary-key lookup path.

table.get(ids)

Returns the rows for the provided ids, in the same order as the input, while skipping ids that are missing.

const teachers = teachersTable.get([
  'teacher-3',
  'teacher-1',
  'missing-teacher',
]);

That usage pattern shows up frequently when one table stores only relationship rows and another table stores the entity rows. A common pattern looks like:

  1. query a join table for teacherIds or classIds
  2. feed those ids into the entity table
  3. get back the matching loaded entities in a stable order

table.whereIndexedColumn(column, value)

Starts an indexed query.

const query = table.whereIndexedColumn(
  'teacherId',
  'teacher-1'
);

The returned query is immutable. Each additional filter returns a new query instance.

const results = table
  .whereIndexedColumn('teacherId', 'teacher-1')
  .whereIndexedColumn('room', 'room-a')
  .get();

This behaves like:

WHERE teacher_id = 'teacher-1'
  AND room = 'room-a'

table.whereIndexedColumnIn(column, values)

Starts an indexed query that matches any of the provided values for a single indexed column.

const query = table.whereIndexedColumnIn(
  'teacherId',
  ['teacher-1', 'teacher-3']
);

You can chain additional indexed filters after it.

const results = table
  .whereIndexedColumnIn('teacherId', [
    'teacher-1',
    'teacher-2',
  ])
  .whereIndexedColumn('room', 'room-a')
  .get();

This behaves like:

WHERE teacher_id IN ('teacher-1', 'teacher-2')
  AND room = 'room-a'

For id queries, missing ids are ignored the same way they are with table.get(ids).

Important limitation

This package supports exact-match lookups on indexed columns only. It does not support:

  • partial string matching
  • range queries
  • sorting operators
  • joins
  • arbitrary grouped OR conditions across different columns

If you need those, fetch the rows you want and derive the rest in normal JavaScript.

Query API

Once you have a query, you can use the following methods.

query.get()

Returns the matching rows.

const rows = table
  .whereIndexedColumn('courseId', 'course-1')
  .get();

Internally the query resolves the indexed candidate sets, picks the smallest one, and intersects the rest. That keeps chained equality queries efficient without scanning every row.

query.get(column)

Projects a single column out of the matched rows.

const teacherIds = courseSectionTeachers
  .whereIndexedColumn('courseSectionId', 'section-1')
  .get('teacherId');

This is a common usage pattern for join-table style records. Query by one foreign key, then project the opposite side of the relationship directly.

Examples:

  • “Give me every teacherId attached to this courseSectionId.”
  • “Give me every classId attached to this courseSectionId.”
  • “Give me every conflictId attached to this period.”

query.get(column, true)

Projects a column and removes duplicates.

const uniqueRoomIds = entries
  .whereIndexedColumn('teacherId', 'teacher-1')
  .get('roomId', true);

query.exists()

Returns true if any row matches.

const hasConflict = conflicts
  .whereIndexedColumn('id', conflictId)
  .whereIndexedColumn('periodId', periodId)
  .exists();

This pattern is useful for fast guard clauses and cheap boolean checks in computed values.

query.count()

Counts matching rows without allocating the result array.

const count = conflictsTable
  .whereIndexedColumn('periodId', periodId)
  .count();

This is a strong fit for:

  • badge counts
  • summary pills
  • empty-state checks
  • rendering optimizations where you only need the total

query.first()

Returns the first matching row or null.

const row = table
  .whereIndexedColumn('teacherId', 'teacher-2')
  .first();

Use this when the logical cardinality is “zero or one,” or when any single match is sufficient.

query.delete()

Deletes every row that matches the query.

courseSectionTeachers
  .whereIndexedColumn('courseSectionId', 'section-1')
  .delete();

This is especially convenient for join-table replacement flows:

  1. delete the existing relationship rows for an owner
  2. insert the replacement rows

That is a common pattern in feature state when the server returns the new authoritative list for a relationship.

table.uniqueColumnValues(column)

Returns a Set of unique values for an indexed column, or for id.

const dayNumbers = periods.uniqueColumnValues(
  'day_of_the_week'
);

This was added for UI patterns where you want to build filters or grouped views from the current contents of the table without rescanning every record manually.

Examples:

  • list every teacher that currently appears
  • list every day value represented in period rows
  • build facet-like filter controls from loaded data

MobX Behavior

The table and its indices are backed by MobX observable maps and sets.

That means MobX reactions can observe:

  • full-table reads
  • indexed query counts
  • query existence checks
  • query results used in computed values or autorun

Example:

import { autorun } from 'mobx';

const dispose = autorun(() => {
  const teacherOneCount = classes
    .whereIndexedColumn('teacherId', 'teacher-1')
    .count();

  console.log(teacherOneCount);
});

When matching rows are inserted, updated, or deleted, the reaction re-runs because the underlying observable state changed.

Real Usage Patterns

Multiple tables can be composed together to represent a normalized client-side data graph. These examples show common ways to use the library in that style.

1. Entity tables

Store full entities by id, optionally with a few useful secondary indices.

type CourseSection = {
  id: string;
  courseId: string;
  roomId: string | null;
  colorHex: string;
};

const courseSections = new InMemoryDBTable<
  CourseSection,
  'courseId'
>([], ['courseId']);

Use cases:

  • get a section by id
  • get all sections for a course
  • update a section in place

2. Join tables

Store relationship rows and project the opposite id back out of the query.

type CourseSectionTeacher = {
  id: string;
  courseSectionId: string;
  teacherId: string;
};

const courseSectionTeachers = new InMemoryDBTable<
  CourseSectionTeacher,
  'courseSectionId' | 'teacherId'
>([], ['courseSectionId', 'teacherId']);

const teacherIds = courseSectionTeachers
  .whereIndexedColumn('courseSectionId', 'section-1')
  .get('teacherId');

This lets feature-level state stay very explicit and easy to reason about.

3. Composite filtering

Chain multiple indexed columns to narrow a result set.

const entriesInPeriodForSection = periodEntries
  .whereIndexedColumn('courseSectionId', 'section-1')
  .whereIndexedColumn('periodId', 'period-3')
  .get();

This is effectively a composite lookup without requiring a dedicated combined index declaration.

4. Fast existence checks across normalized data

Use one query to pull relationship ids, then use another query to validate context.

const hasConflictInPeriod = conflictCourseSections
  .whereIndexedColumn('courseSectionId', 'section-1')
  .get('conflictId')
  .some((conflictId) =>
    conflicts
      .whereIndexedColumn('id', conflictId)
      .whereIndexedColumn('periodId', 'period-3')
      .exists()
  );

This keeps the data normalized while still giving feature-specific selectors readable building blocks.

5. Deriving entities from relationship rows

Fetch relationship ids first, then load the entities.

const classIds = courseSectionClasses
  .whereIndexedColumn('courseSectionId', 'section-1')
  .get('classId');

const classesForSection = classesTable.get(classIds);

This pattern is one of the main reasons the get(ids) overload exists.

Design Constraints

This library deliberately makes a few tradeoffs:

  • only id is treated as the primary key
  • indices are equality-only
  • secondary indices are opt-in
  • query ordering follows the iteration order of the underlying matching id set
  • there is no cross-table abstraction; composition is done in your own selectors and state objects

Those constraints keep the implementation small and the runtime behavior predictable.

TypeScript Notes

The generic parameters are:

InMemoryDBTable<T, IndexedColumns>

Where:

  • T is the record shape and must include id: string
  • IndexedColumns is a union of non-id keys you want to allow in whereIndexedColumn(...)

Example:

type PeriodEntry = {
  id: string;
  courseSectionId: string;
  periodId: string;
  orderNum: number;
};

const entries = new InMemoryDBTable<
  PeriodEntry,
  'courseSectionId' | 'periodId'
>([], ['courseSectionId', 'periodId']);

If you try to query a column that is not part of IndexedColumns (or id), TypeScript will reject it.

Testing

The package includes Jest tests covering:

  • primary-key reads
  • single-column and multi-column indexed queries
  • index updates after upserts
  • index cleanup after deletes
  • column projection
  • distinct projection
  • unique value extraction
  • query helpers like exists, count, first, and delete
  • MobX reaction behavior

Run them with:

npm test

Build

Build the package with:

npm run build

The package is bundled with tsup and emits:

  • ESM output
  • CommonJS output
  • type declarations
  • source maps

When Not To Use This

This is probably the wrong abstraction if:

  • your data is naturally just one small array
  • you need server-synchronized caching semantics like TanStack Query
  • you need relational writes, joins, or ad hoc querying
  • you need sorted indices or range scans
  • you are not using MobX and do not care about observable data structures

API Summary

const table = new InMemoryDBTable<T, IndexedColumns>(
  records?,
  indexedColumns?
);

table.upsert(record);
table.upsert(records);

table.delete(id);
table.delete(ids);

table.get();
table.get(id);
table.get(ids);

table.whereIndexedColumn(column, value);
table.whereIndexedColumnIn(column, values);
table.uniqueColumnValues(column);

query.whereIndexedColumn(column, value);
query.whereIndexedColumnIn(column, values);
query.get();
query.get(column, distinct?);
query.exists();
query.count();
query.first();
query.delete();

License

UNLICENSED