Stores
The one mandatory primitive, what each adapter is for, and what is not verified.
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.
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:
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.
| package | implements |
|---|---|
@tsbouncer/in-memory | TupleStore (memoryStore) and Cache (memoryCache) |
@tsbouncer/json-file | TupleStore (jsonStore) |
@tsbouncer/kysely, @tsbouncer/drizzle, @tsbouncer/prisma | TupleStore |
@tsbouncer/redis | TupleStore (redisStore) and Cache (redisCache) |
@tsbouncer/defaults | neither — it chooses: in-memory, or a JSON file when given a path |
Capabilities are declared, not sniffed
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
| package | takes | tested against |
|---|---|---|
@tsbouncer/in-memory | nothing — process-local | in-process |
@tsbouncer/json-file | a file path | on disk, atomic |
@tsbouncer/kysely | your Kysely instance | SQLite in CI, PostgreSQL locally |
@tsbouncer/drizzle | your db and table object | SQLite (sync) and libsql (async) |
@tsbouncer/prisma | your PrismaClient | SQLite via Prisma 7 |
@tsbouncer/redis | your Redis client | local 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.
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:
import { createTupleTableSql } from '@tsbouncer/kysely';
raw.exec(createTupleTableSql('sqlite')); // 'postgres' and 'mysql' are also availableIt 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.
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.
resourcesis 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
checkwould decide on incomplete data, so the engine never setslimiton 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:
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
- Recipe: testing — the conformance suite and the golden dataset.
- API: client — running inside a transaction.