A TypeScript library for querying databases using familiar JavaScript array methods (filter, find, map, etc.) through a pluggable driver abstraction. Push filters down to the database when possible; fall back to in-memory evaluation when a driver doesn't support a particular optimization.
- Array-like API — Use
filter(),find(),map(),reduce(), etc. on database queries - Filter pushdown — Complex queries (
Filter<T>expressions) are pushed to the database when the driver supports it - Pluggable drivers — Swap SQLite, PostgreSQL, or any other database without changing your code
- TypeScript native — Full type safety with zero compromises
- Zero runtime dependencies — Ships as CJS and ESM; peer dependencies (like
better-sqlite3) are optional - Fallback evaluation — If a driver doesn't support a feature,
matchesFilter()evaluates it in JavaScript - TTL caching — Opt-in result caching via
lru-cache; configurable TTL and max entries, with runtime enable/disable
npm install tabletop
# or
yarn add tabletopFor SQLite support:
npm install better-sqlite3
npm install --save-dev @types/better-sqlite3import { SQLiteDriver } from 'tabletop/sqlite';
import Database from 'better-sqlite3';
// Open your database
const db = new Database(':memory:');
// Create a table
db.exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
age INTEGER
)
`);
// Insert some data
db.prepare('INSERT INTO users (name, age) VALUES (?, ?)').run('Alice', 30);
db.prepare('INSERT INTO users (name, age) VALUES (?, ?)').run('Bob', 25);
// Query using tabletop
const driver = new SQLiteDriver(db, 'users');
// Filter with automatic pushdown to SQL
const adults = await driver
.filter({ age: { gte: 25 } })
.map((u) => u.name)
.toArray();
// → ['Alice', 'Bob']
// Find one
const user = await driver.find({ name: 'Alice' });
// → { id: 1, name: 'Alice', age: 30 }
// Count with pushdown
const count = await driver.count({ age: { gte: 30 } });
// → 1All standard array methods are supported:
const users = new ArrayDB(driver);
// filter + map
users
.filter((u) => u.age > 25)
.map((u) => ({ name: u.name, adult: true }))
.forEach((u) => console.log(u));
// find with complex filters
const result = users.find({
AND: [{ age: { gte: 25 } }, { name: { like: '%ice' } }],
});
// reduce
const avgAge = users.reduce((sum, u) => sum + u.age, 0) / users.count();
// count with filter
const count = users.count({ age: { gt: 30 } });Pass a cache option to enable TTL result caching. Requires lru-cache as a peer dependency:
yarn add lru-cacheimport { ArrayDB } from 'tabletop';
const db = new ArrayDB(driver, {
cache: {
ttl: 30_000, // entries expire after 30 seconds
max: 500, // keep at most 500 entries (default)
},
});
// Results are cached automatically
const users = db.filter({ active: true }); // hits driver
const users2 = db.filter({ active: true }); // cache hit — driver not called
// Runtime control
db.disableCache(); // bypass cache (also clears it)
db.enableCache(); // resume caching
db.clearCache(); // flush entries, stay enabledCache entries are keyed by operation and serialized filter. If you write directly through the driver, call db.clearCache() after the write or rely on TTL expiry to restore consistency.
Filters are Prisma-style objects that support field operators and logical combinators.
{
name: 'Alice', // eq (shorthand)
age: { eq: 30 }, // equals
score: { ne: 0 }, // not equals
rating: { gt: 4.5 }, // greater than
count: { gte: 10 }, // greater than or equal
price: { lt: 100 }, // less than
votes: { lte: 5 }, // less than or equal
status: { in: ['active', 'pending'] }, // in array
tag: { notIn: ['archived'] }, // not in array
email: { like: '%@example.com' }, // SQL LIKE pattern
deleted: { isNull: true }, // is NULL
active: { isNotNull: true } // is NOT NULL
}// AND (all conditions must be true)
{
AND: [{ age: { gte: 18 } }, { status: 'active' }];
}
// OR (any condition can be true)
{
OR: [{ name: 'Alice' }, { name: 'Bob' }];
}
// NOT (negate a condition)
{
NOT: {
status: 'deleted';
}
}
// Combined
{
AND: [
{ age: { gte: 18 } },
{
OR: [{ role: 'admin' }, { role: 'moderator' }],
},
];
}| Method | Description |
|---|---|
filter(predicate) |
Filter items using a function or Filter<T> expression. Returns a new ArrayDB<T>. |
find(predicate) |
Find the first item matching a function or Filter<T> expression. |
findIndex(predicate) |
Return the index of the first item matching a function. |
map(fn) |
Transform items and return a new ArrayDB<U>. |
forEach(fn) |
Iterate over each item and execute a function. |
some(predicate) |
Return true if any item matches the predicate. |
every(predicate) |
Return true if all items match the predicate. |
reduce(fn, initial) |
Reduce items to a single value. |
count(predicate?) |
Count items, optionally filtered by a function or Filter<T> expression. |
close() |
Close the underlying driver and release resources. |
enableCache() |
Resume caching. No-op if cache was never configured. |
disableCache() |
Pause caching and clear current cache entries. |
clearCache() |
Flush cache entries without changing enabled/disabled state. |
Implement this interface to create a new driver:
interface Driver<T extends Row> {
// Required
fetchAll(): T[];
close(): void;
// Optional (for pushdown optimization)
fetchWhere?(filter: Filter<T>): T[];
findWhere?(filter: Filter<T>): T | undefined;
countWhere?(filter: Filter<T>): number;
// Optional (for mutations)
insert?(row: T): void;
delete?(predicate: Filter<T> | ((item: T) => boolean)): number;
update?(predicate: Filter<T> | ((item: T) => boolean), patch: Partial<T>): number;
}Drivers must implement fetchAll() and close(). Pushdown methods (fetchWhere, findWhere, countWhere) are optional optimizations:
class MyDriver<T extends Row> implements Driver<T> {
async fetchAll(): T[] {
// Load all rows from your data source
}
async fetchWhere(filter: Filter<T>): T[] {
// Implement pushdown: translate filter to your query language
// Fallback: ArrayDB will use fetchAll() + matchesFilter()
}
async close(): void {
// Cleanup
}
}For Prisma or custom query builders, implement just the fetchAll() method and let ArrayDB handle filtering:
class PrismaDriver<T extends Row> implements Driver<T> {
constructor(
private client: PrismaClient,
private model: string
) {}
async fetchAll(): T[] {
return (this.client as any)[this.model].findMany();
}
async close(): void {
await this.client.$disconnect();
}
}Install dependencies:
yarn installRun tests:
yarn test # Run all tests
yarn test:watch # Watch modeBuild:
yarn build # Compile to dist/ (CJS, ESM, DTS)
yarn clean # Remove dist/ and docs/Lint and format:
yarn lint # ESLint
yarn format # PrettierGenerate docs:
yarn docs # TypeDoc → docs/MIT