in-memory-db-table
v1.0.1
Published
MobX-backed in-memory tables with indexed equality queries and query-builder helpers.
Maintainers
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 mobxPeer 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
Tmust includeid: stringcolumnsToIndexshould only include columns you plan to query frequentlyidis 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:
- query a join table for
teacherIds orclassIds - feed those ids into the entity table
- 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
teacherIdattached to thiscourseSectionId.” - “Give me every
classIdattached to thiscourseSectionId.” - “Give me every
conflictIdattached 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:
- delete the existing relationship rows for an owner
- 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
idis 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:
Tis the record shape and must includeid: stringIndexedColumnsis a union of non-idkeys you want to allow inwhereIndexedColumn(...)
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, anddelete - MobX reaction behavior
Run them with:
npm testBuild
Build the package with:
npm run buildThe 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
