npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@tsfga/kysely

v0.6.0

Published

Kysely/PostgreSQL adapter for @tsfga/core

Downloads

1,027

Readme

@tsfga/kysely

Kysely/PostgreSQL adapter for @tsfga/core.

Part of the tsfga monorepo. Implements the TupleStore interface from @tsfga/core using Kysely for PostgreSQL.

Identifiers must be canonical UUIDs

This store holds object and subject ids that are exactly 8-4-4-4-12 lower-case hexadecimal digits, hyphenated, and nothing else. user:alice is an ordinary subject in OpenFGA and this store refuses it, permanently.

It is the first thing to know about it, and it is a declared design limit rather than a bug awaiting a fix. object_id and subject_id are uuid columns; KyselyTupleStore declares CANONICAL_UUID_IDS as its TupleStore.idDomain, and core refuses anything outside it with IdDomainError — at the request boundary, before any query, on check, checkMany, listObjects, listSubjects, addTuple, removeTuple and contextual tuples.

The domain is deliberately narrower than PostgreSQL's own uuid input grammar, which accepts a UUID uppercased, hyphenless, braced, or hyphenated oddly and stores every one of them as the same value. OpenFGA treats each spelling as a distinct id. Admitting more than the canonical spelling would let a grant written for one answer true for another, so only the canonical spelling is admitted.

Nothing about the version or variant digits is checked. The nil UUID 00000000-0000-0000-0000-000000000000 is an ordinary id here — no id value is reserved.

Every refusal is in the refusing direction: a refused request is one no grant was computed for, and the read paths raise rather than answering false, which is what upstream does (HTTP 400, for every id it cannot represent). If your ids are not UUIDs, this adapter is not the one to use — @tsfga/core is database-agnostic and a store declaring OPAQUE_IDS has none of these restrictions.

See the core README's id-domain section and tests/conformance/id-domain.test.ts, which pins the divergence against a live OpenFGA.

Installation

npm install @tsfga/kysely @tsfga/core kysely pg

@tsfga/core, kysely and pg are peer dependencies. This version accepts @tsfga/core >=0.7.0 <0.8.0 — the adapter implements the TupleStore interface as that release shapes it. The range carries an explicit ceiling rather than a caret: below 1.0.0 a core minor may change that interface, so each minor is admitted only once the adapter has been tested against it.

The floor moved to 0.6.0 rather than the ceiling widening, because core 0.6.0 changed TupleStore itself — RelationConfig holds one directlyAssignable list of structured type restrictions, CheckTuplesQuery carries a ref set per part instead of the three include* booleans, and listDirectSubjects is gone. This adapter does not work with earlier cores, and earlier adapters do not work with this core.

Quick start

import { createTsfga } from "@tsfga/core";
import { KyselyTupleStore, type DB } from "@tsfga/kysely";
import { Kysely, PostgresDialect } from "kysely";
import pg from "pg";

const db = new Kysely<DB>({
  dialect: new PostgresDialect({
    pool: new pg.Pool({ connectionString: "..." }),
  }),
});

const store = new KyselyTupleStore(db);
const fga = createTsfga(store);

// Now use fga.check(), fga.addTuple(), etc.

Migrations

The package bundles its migrations behind a static Kysely MigrationProvider, exported from @tsfga/kysely/migrations. It holds the migrations in code (no filesystem scanning), so it works with bundlers and on any runtime. Provision or upgrade the tsfga schema with Kysely's Migrator:

import { Migrator } from "kysely/migration";
import { migrationProvider } from "@tsfga/kysely/migrations";

const migrator = new Migrator({ db, provider: migrationProvider });
const { error, results } = await migrator.migrateToLatest();
if (error) throw error;

Re-running migrateToLatest() on an already-migrated database is a no-op — the Migrator tracks applied migrations in Kysely's standard migration tables.

The raw migration map is also exported as migrations for tools that need direct access.

005-type-restrictions is destructive

This migration replaces directly_assignable_types and allows_userset_subjects on tsfga.relation_configs with a single directly_assignable (jsonb, NOT NULL) holding OpenFGA type restrictions as objects, one per admitted assignment:

[{"type": "user"},
 {"type": "user", "wildcard": true},
 {"type": "team", "relation": "member"},
 {"type": "user", "condition": "weekday_only"}]

The adapter validates this shape on every read and raises InvalidStoredDataError on anything else — a bare string entry included, which is what the column held before this migration.

Existing relation configs are not converted, and cannot be. allows_userset_subjects = true does not record which usersets the model intended, and NULL does not record which types — so any automatic conversion would invent a model nobody authored, in the granting direction. After migrating, rewrite your relation configs from your authorization model. Tuples are untouched.

Consumers on @tsfga/core 0.5.x and @tsfga/kysely 0.4.x should plan this as a coordinated deploy: the new adapter cannot read the old columns, and the old adapter cannot read the new one.

006-wildcard-subject needs PostgreSQL 15

This migration gives the typed wildcard subject a column of its own — tsfga.tuples.subject_wildcard boolean, with subject_id NULL on those rows — so no id value is reserved.

It fixes a grant to everybody. subject_id is a uuid column and "*" is not a UUID, so the adapter used to store the wildcard as the nil UUID. A tuple written for a real subject whose id was 00000000-0000-0000-0000-000000000000 landed in the wildcard's slot: it read back as "*", granted every subject of its type on any relation admitting type:*, and stopped matching the subject it was written for. OpenFGA reserves no id. Neither does this, now — user:00000000-0000-0000-0000-000000000000 names that one subject.

PostgreSQL 15 or later. idx_tuples_unique is recreated with NULLS NOT DISTINCT, which is what makes a second wildcard row on one key a duplicate while leaving a real nil-UUID subject free to sit beside it. That clause landed in PostgreSQL 15. The floor is claimed by feature inspection rather than by a CI matrix — CI runs PostgreSQL 18, and a floor nothing exercises is a claim, so this says which it is. The alternative is an expression index on COALESCE(subject_id::text, '*'), which needs no version floor and costs 25 MB against 18 MB at 242 000 rows.

Rolling 006 back refuses rather than merging. A real subject whose id is the nil UUID is legal under this migration and is the wildcard under 005, so restoring the old encoding would fold such a row into the wildcard's slot and grant its relation to every subject of the type — a grant nobody authorized. down counts those rows and names them; delete or rewrite them deliberately first.

If you are on a pre-release 006-subject-id-text or 007-object-id-text, those two migrations are deleted rather than superseded. Kysely's migrator refuses to run against a database that applied them (corrupted migrations: previously executed migration … is missing), and re-provisioning is the answer. No published @tsfga/kysely ever carried them — 0.5.0 stops at 005.

Transactions

KyselyTupleStore takes a Kysely<DB> it does not own, and Kysely declares Transaction<DB> as a subtype of Kysely<DB>. So a store — and a whole TsfgaClient built over it — can be scoped to a transaction with no extra API:

await db.transaction().execute(async (trx) => {
  const fga = createTsfga(new KyselyTupleStore(trx));
  await fga.addTuple(/* ... */);
  if (!(await fga.check(/* ... */))) throw new Error("rolled back");
});

Every store method then runs inside trx.

Preserving an invariant across concurrent writers

For a rule like "an organization always keeps at least one administrator", the read and the write must be in the same transaction and the transaction must be isolated enough that a concurrent writer cannot invalidate what was read.

Use SERIALIZABLE. It preserves the invariant with no extra API surface — the losing transaction aborts with 40001 and you retry it.

It holds only if every writer that can invalidate the read is also SERIALIZABLE. PostgreSQL detects the conflict from the predicate locks the serializable readers take, so a concurrent READ COMMITTED writer — another service, a migration, a psql session, or one path in your own application that forgot the isolation level — is not part of that bookkeeping and is never aborted. This is a property of the whole set of writers on the table, not of the transaction below.

await db.transaction().setIsolationLevel("serializable").execute(
  async (trx) => {
    const fga = createTsfga(new KyselyTupleStore(trx));
    const admins = await fga.listSubjects("organization", orgId, "admin");
    if (admins.length <= 1) throw new Error("last administrator");
    await fga.removeTuple(/* ... */);
  },
);

Do not reach for SELECT … FOR UPDATE instead. Probed against PostgreSQL 18: row locks are taken on rows that exist, so a concurrent INSERT of a new administrator is not blocked at either isolation level. FOR UPDATE is therefore adequate for "at least one X" only if you count from the locking read itself, and useless for any "at most N X" invariant. That is why the adapter exposes no lock option: a TupleStore that cannot lock would have to ignore one silently, which is a fail-open mode the core's clamping cannot protect against. OpenFGA reaches the same conclusion — it uses SELECT … FOR UPDATE strictly inside its Postgres datastore, never on its storage interface.

Subject IDs and wildcards

object_id and subject_id are uuid columns, so ids must be canonical UUIDs — see the top of this README.

The public wildcard subject "*" ("all subjects") is not an id and is not stored as one. tsfga.tuples.subject_wildcard is a boolean and subject_id is NULL on those rows, so no id value is reserved: a grant to user:00000000-0000-0000-0000-000000000000 names that one subject, exactly as it does upstream. @tsfga/core still spells the wildcard subjectId: "*"; the adapter maps it in both directions and raises InvalidStoredDataError on a row that carries neither shape or both.

Schema

Migrations create a tsfga schema with three tables:

| Table | Description | |---|---| | tsfga.tuples | Relationship tuples with optional conditions | | tsfga.relation_configs | Relation definitions (implied_by, computed_userset, etc.) | | tsfga.condition_definitions | Named CEL condition expressions |

tsfga.tuples carries four indexes, each earning its place on a query the adapter actually issues:

| Index | Columns | Serves | |---|---|---| | idx_tuples_unique | (object_type, object_id, relation, subject_type, subject_id, COALESCE(subject_relation, '')), unique, NULLS NOT DISTINCT | The insert's conflict target; also every probe, via its leading columns. NULLS NOT DISTINCT is what makes a second wildcard row a duplicate | | idx_tuples_object | (object_type, object_id) | findTuplesByRelation | | idx_tuples_userset | (object_type, object_id, relation) where subject_relation IS NOT NULL, partial | The userset scan | | idx_tuples_subject | (subject_type, subject_id) | Reverse lookups by subject |

idx_tuples_object and idx_tuples_userset are prefixes of idx_tuples_unique and could in principle be dropped, but both are far narrower than it — a tenth of its size — so they fit more entries per page and measurably win the scans they serve.

How a write lands

insertTuple inserts and reports: the conflict clause does nothing on a duplicate and the method returns false, so the stored row keeps the condition and the context it already had. It used to update the row in place. TsfgaClient.addTuple turns the false into DuplicateTupleError; the way to change a live grant's condition is removeTuple then addTuple.

How a check reads

Every node of a check calls findCheckTuples once. The adapter serves it as a single query: one (object_type, object_id, relation) predicate with an OR over just the subject predicates the caller asked for — the subject's own direct tuple, the type:* wildcard tuple, the userset rows, or any subset.

One round-trip per node instead of up to three, and one connection held instead of up to three. The latter is usually the bigger effect: branches of a node resolve concurrently up to maxBreadth, so three reads per node meant a wide node could ask the pool for three times as many connections as it has branches.

Size the pool for the fanout, not for the call

maxBreadth bounds the branches of one node, not the call, so concurrent reads compound with every further level of dispatch. Measured with an instrumented store counting simultaneous reads, at the shipped defaults (maxBreadth 10, maxConcurrentChecks 50):

| call | grants one dispatch away | two | three | |---|---|---|---| | check | 10 | 100 | 1000 | | listObjects | 100 | 1000 | 10000 | | checkMany | 500 | 5000 | 50000 |

Each row is the one above it times a factor the call adds — maxBreadth again for listObjects, which checks candidates concurrently at the same bound, and maxConcurrentChecks for checkMany — and each column is the previous one times maxBreadth, because a userset or tuple-to-userset row dispatches to another object whose own branches then fan out again. The exponent is the model's dispatch depth, which maxDepth caps at 25.

A pool smaller than the peak does not break anything: the excess reads queue. But they queue holding the branches that are waiting on them, so a pool sized for the first column against a model shaped like the third turns concurrency into a queue and the latency win into its opposite. Lower maxBreadth if that is the trade you want — it is the same knob on both.

The reachability prune adds config reads, not width. Core asks whether the subject's type can reach a node before resolving it, and answers from the relation configs — which means reading configs the resolution itself would never have asked for. Every read goes through the scope's config cache, so each objectType#relation costs at most one round trip per scope, and the walks are serialized within a scope: they lengthen a scope's read sequence without widening it. The width bound is still maxBreadth per node.

Measured against theopenlane, the largest model in the suite at 225 relation configs, one resolution scope's walk read 11 distinct configs at the smallest, 18 at the median and 192 at the largest. So a single check() against a large, densely cross-referenced model can turn ~10 config round trips into up to ~190 on its first node, and none afterwards. listObjects and checkMany amortise that across the whole call, which is where it is cheapest; a one-shot check() on a big model is where it is dearest — about +18 % wall clock on the theopenlane conformance files against a warm database.

A userset subject is cheaper per node, not dearer: both the direct and the wildcard probe are excluded rather than narrowed, so its node reads are strictly a subset of a concrete subject's. It adds one relation-config read per scope, for the subject's own relation, cached like every other.

Inside a transaction the whole question dissolves: every store call runs on the transaction's one connection, so the peak is 1 and the queries serialize there instead of at the pool. The concurrency knobs then buy nothing — which is worth knowing, because scoping a client to a transaction is a documented and otherwise unremarkable thing to do.

Which plan PostgreSQL picks depends on the disjuncts. Asking for one part gives the same plan as before this was merged. A direct probe and a wildcard probe differ only in subject_id — an equality against the id for one, IS NULL for the other — so both reach five columns of idx_tuples_unique and the pair is combined under it.

The wildcard probe spells that IS NULL out even though subject_wildcard alone would be equivalent, and the difference is not cosmetic. The check constraint ties the two together and the planner does not know it: measured on PostgreSQL 18 with one object carrying 5000 subjects, the bare boolean has nothing indexed to descend on, falls to a sequential scan at 77 buffers and discards 5000 rows, while the IS NULL conjunct extends the index condition to five columns and costs 3.

Mixing a probe with the userset scan is the interesting case, and the plan is not fixed: the two disjuncts share nothing past relation, so PostgreSQL either combines idx_tuples_unique with the partial idx_tuples_userset under a BitmapOr, or descends the three-column prefix and filters. Which one it picks turns on how many subjects sit on the relation and on the relative selectivity of the two disjuncts, so both plans are reachable on ordinary data. The filter plan reads rows it then discards — still cheaper than the round-trip it saves, but not free on objects with many subjects on one relation.

Parts the caller excludes are omitted from the SQL, so they cost nothing to skip. In the filter plan this narrows the Filter, not the Index Cond — the saving is in rows examined, not in index descent.

License

MIT