Skip to content
tsbouncerpreview
guide

Stores

The one mandatory primitive, what each adapter is for, and what is not verified.

TypeScript
import { createAuthz } from '@tsbouncer/tsbouncer';
import { kyselyStore } from '@tsbouncer/kysely';
import type {
  Cache,
  DeleteInput,
  Page,
  ReadTupleQuery,
  Tuple,
  TupleStore,
  WriteInput,
} from '@tsbouncer/tsbouncer';
import type { Kysely } from 'kysely';
import { cacheConformance, storeConformance } from '@tsbouncer/testkit';

declare const db: Kysely<unknown>;

The contract

One primitive is mandatory: a filtered read. Everything else — reverse walks, expand, listResources, listSubjects — is derived by the engine.

TypeScript
declare const contract: {
  readonly capabilities: { atomicWrite: boolean };
  read(query?: ReadTupleQuery): Promise<Page<Tuple>>;
  write(input: WriteInput): Promise<void>;
  delete(input: DeleteInput): Promise<void>;
};
void contract;

Stores move tuples. They do not evaluate conditions, resolve permissions, or decide anything.

The two ports

Backends implement ports, and there are exactly two of them. TupleStore is the durable record of grants — the contract above. Cache is the fast, losable layer in front of repeated questions:

TypeScript
interface Cache {
  readonly capabilities: { persistent: boolean; ttl: boolean };
  get(key: string): Promise<unknown>; // undefined on a miss
  set(key: string, value: unknown, options?: { ttlMs?: number }): Promise<void>;
  delete(key: string): Promise<void>;
  clear(prefix: string): Promise<void>; // every key starting with prefix
}
// Shape sketch — the contract lives in `tsbouncer`, asserted by conformance.

Anything in a cache may vanish at any time without violating the contract; that is the whole difference between the ports, and the reason one backend can implement both. Values must be JSON-serializable — a value written through one adapter has to be readable through another — and undefined is never storable, because it is the miss signal. A ttlMs on a backend without ttl support is rejected rather than silently ignored: an expiry that does not happen is a staleness bug wearing a success mask.

withCache(authz, cache, { namespace }) memoizes can and check through any Cache, invalidating by resource on every write. The namespace is required and should be versioned with the model — two applications sharing one cache must never read each other’s decisions. explain, expand, and the list queries stay uncached; withStore drops the layer, because transactional reads must never hit a cache shared with committed state.

packageimplements
@tsbouncer/in-memoryTupleStore (memoryStore) and Cache (memoryCache)
@tsbouncer/json-fileTupleStore (jsonStore)
@tsbouncer/kysely, @tsbouncer/drizzle, @tsbouncer/prismaTupleStore
@tsbouncer/redisTupleStore (redisStore) and Cache (redisCache)
@tsbouncer/defaultsneither — it chooses: in-memory, or a JSON file when given a path

Capabilities are declared, not sniffed

TypeScript
interface TupleStoreCapabilities {
  atomicWrite: boolean; // writes are one all-or-nothing unit
  persistent: boolean; // state survives process exit
  atomicReplace: boolean; // `replace` deletes are atomic with their write
  pagination: boolean; // `read` honours `limit` and returns a `cursor`
  transaction: boolean; // a transaction handle can be produced
  watch: boolean; // invalidations are emitted on change
}

A store that does not support something should say so rather than pretending. A store that claims support and does not deliver it is a bug, and the conformance suite is written to catch exactly that.

Choosing one

packagetakestested against
@tsbouncer/in-memorynothing — process-localin-process
@tsbouncer/json-filea file pathon disk, atomic
@tsbouncer/kyselyyour Kysely instanceSQLite in CI, PostgreSQL locally
@tsbouncer/drizzleyour db and table objectSQLite (sync) and libsql (async)
@tsbouncer/prismayour PrismaClientSQLite via Prisma 7
@tsbouncer/redisyour Redis clientlocal server via env gate

memoryStore for tests, jsonStore for fixtures and local development, and one of the SQL adapters for anything with more than one process. Whether authorization state should be durable is a decision about the application, so the choice is never inferred from the environment.

TypeScript
declare const model: Parameters<typeof createAuthz>[0]['model'];

const authz = createAuthz({ model, store: kyselyStore(db) });

The adapter ships its own DDL, so an app’s migration and its authorization schema cannot drift apart:

TypeScript
import { createTupleTableSql } from '@tsbouncer/kysely';

raw.exec(createTupleTableSql('sqlite')); // 'postgres' and 'mysql' are also available

It is the same SQL the conformance suite runs, against a real database.

The one table

All four SQL-backed adapters share a contract, so an application can move between them without migrating data:

subject_type · subject_id · subject_relation · relation · resource_type · resource_id · condition · context

A reference spans three columns, which is why each filter is a set of per-reference ANDs. subject_relation and condition are '' when absent, never NULL: a unique constraint containing a NULL never fires in Postgres, and write({ mode: 'insert' }) would silently stop rejecting duplicates.

listResources is O(store size) in the worst case

Worth knowing before you put it in a hot path. Finding the resources a subject can read means finding tuples whose subject is a group — and the store contract has no index for “subjects that are usersets”, so the engine reads the whole table and walks membership for each distinct group.

The evaluation budget bounds it, so a huge table produces truncated: true rather than a hang. That is honest but it means the answer is partial, and a UI that ignores the flag will show a short list as if it were complete.

TypeScript
declare const session: { userId: string };

const { resources, truncated } = await authz.listResources({
  subject: `user:${session.userId}`,
  permission: 'document.read',
});
if (truncated) {
  // narrow the query, or paginate by resource type
}

Two properties follow from the shape above, and both are load-bearing rather than incidental:

  • Result lists are materialized. resources is a full array, sorted, held in memory — there is no streaming form in v1, so a very large answer is a very large allocation inside a bounded budget.
  • Evaluator reads are unpaged on purpose. A partial page inside check would decide on incomplete data, so the engine never sets limit on a read a decision depends on. The set-grant scan is the exception that proves it: it only collects candidates that are each evaluated exactly, so it walks cursors on stores that offer them (pagination: true) and degrades to one read where they do not.

For a table in the tens of thousands this is fine. For a large one, scope the query — filter by resource type or workspace — or read your own domain table and call can() per row.

Writing your own

Implement a port — three methods and declared capabilities for a store, four for a cache — then run the matching conformance suite from @tsbouncer/testkit. One package per backend, named @tsbouncer/<backend>, exposing every port it implements:

TypeScript
declare function myStore(): TupleStore;
declare function myCache(): Cache;

storeConformance({
  name: 'my-store',
  skip: ['pagination'],
  create: () => myStore(),
});

cacheConformance({
  name: 'my-cache',
  create: () => myCache(),
});

A store that does not pass is not done, and the same holds for a cache. See Testing.

Next