README.md

tabletop

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.

Features

  • 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

Installation

npm install tabletop
# or
yarn add tabletop

For SQLite support:

npm install better-sqlite3
npm install --save-dev @types/better-sqlite3

Quick Start

Using the SQLite Driver

import { 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 } });
// → 1

Using Array Methods

All 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 } });

Caching

Pass a cache option to enable TTL result caching. Requires lru-cache as a peer dependency:

yarn add lru-cache
import { 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 enabled

Cache 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.

Filter Expressions

Filters are Prisma-style objects that support field operators and logical combinators.

Field Operators

{
  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
}

Logical Combinators

// 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' }],
    },
  ];
}

API Reference

ArrayDB

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.

Driver

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;
}

Creating a Custom Driver

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();
  }
}

Development

Install dependencies:

yarn install

Run tests:

yarn test          # Run all tests
yarn test:watch    # Watch mode

Build:

yarn build         # Compile to dist/ (CJS, ESM, DTS)
yarn clean         # Remove dist/ and docs/

Lint and format:

yarn lint          # ESLint
yarn format        # Prettier

Generate docs:

yarn docs          # TypeDoc → docs/

License

MIT