# Kysely

Source: https://permdock.com/docs/adapters/kysely

permdock/kysely compiles portable conditions into Kysely expression-builder callbacks with toWhere, fails closed when nothing is granted, and documents the Kysera @kysera/rls dual-mode prior art.

`permdock/kysely` is the Kysely interpreter for PermDock's portable [conditions](/docs/concepts/conditions). `toWhere` returns a callback for Kysely's expression builder, so the permission filter composes with the rest of a typed query. Kysely has no RLS support of its own; RLS for Kysely users comes from `permdock rls generate --target sql` and a transaction helper that sets the session role and claims.

## Purpose [#purpose]

Kysely users write queries by hand, which makes them the group most likely to forget the permission filter on one endpoint. `toWhere` makes the filter a one-liner derived from the same policy that guards the mutation. The only prior art for a dual-mode schema in the Kysely world is Kysera's `@kysera/rls`: one `defineRLSSchema` drives app-side query injection (`filter` / `allow` / `deny` / `validate`) and `@kysera/rls/native` emits `ENABLE RLS` / `CREATE POLICY` for policies that carry raw `using` / `withCheck` SQL, with `syncContextToPostgres()` mirroring context into `app.*` GUCs and `createPolicyTester()` for DB-less tests ([Kysera multi-tenancy](https://kysera.dev/docs/guides/multi-tenancy)). It generates in one direction only and only for raw-SQL policies; PermDock generates from portable conditions and imports back ([Postgres RLS](/docs/standards/postgres-rls)).

## API [#api]

```ts
import { toWhere, withSubject } from "permdock/kysely";

const rows = await db
  .selectFrom("posts")
  .selectAll()
  .where(toWhere(permdock.where(permissions.post.read), "posts"))
  .where("published", "=", true)
  .execute();

// column mapping when schema fields differ from column names
toWhere(condition, "posts", { columns: { authorId: "author_id" } });

// RLS instead of app-side filtering: run the query under the caller's role and claims
await withSubject(db, permdock, async (trx) => {
  return trx.selectFrom("posts").selectAll().execute(); // policies from `permdock rls generate --target sql` apply
});
```

* `toWhere(condition, table, options?)` returns `(eb) => Expression<SqlBool>`; fields map to `table.column` references, operators map to Kysely binary operators and `eb.and` / `eb.or` / `eb.not`, `in` / `notIn` to `in` / `not in`, `isNull` to `is null`, `contains` to `@>` with a one-element array for fields in `options.listFields` and to `like` (with `%`, `_` and `\` escaped) for strings. `kysely` is an optional peer; the callback is typed structurally so tests need no install.
* `memberOf` and NULL handling match the [Drizzle rules](/docs/adapters/drizzle): frozen memberships become equality / `in`, and `options.memberships` becomes `eb.exists(eb.selectFrom('<table> as m')...)` with the same expiry, active-tenant and resource-column predicates. Without `eb.exists` / `eb.selectFrom` the join fails closed (`eb.lit(false)`).
* `where(expression)` accepts the callback directly: `db.selectFrom('post').where(toWhere(permdock.where(permissions.post.read), 'post'))`.
* No grant returns `(eb) => eb.lit(false)`, so the query yields no rows (fail closed).
* `principal.<field>` and `context.<key>` values become bound parameters via `eb.val`.
* `options.relations` (`{ tables?, closure? }`) lets a graph grant's `related` node compile to one subquery, over the closure table when named or a bounded recursive walk otherwise ([relationships](/docs/concepts/relationships#snapshots-clients-and-where)). Without it the node throws `non-portable-condition`.
* `checkRow(db, table, condition, key, options?)` takes `key` as an expression-builder callback (`(eb) => eb('id', '=', id)`), selects the filter as a boolean for the matching row and returns `{ found: false }` or `{ found: true, granted }`. More than one matching row throws.
* `withSubject(db, permdock, fn, options?)` opens a transaction, runs the preamble below and then `fn(trx)`, so the generated policies see `permdock.subject`. The same function ships in `permdock/drizzle` and `permdock/prisma` ([RLS adapter](/docs/adapters/rls)).

The preamble is one `select set_config(…, true), …` statement, so it costs one round trip. Its settings, in order:

1. `role`: `authenticated`, or `anon` when the subject has no principal, the same as `set local role`. `options.role` accepts only `authenticated`, `anon` or `false`. `false` keeps the connection's role, for a login role RLS already applies to.
2. The claims. `supabase` and `neon` set `request.jwt.claims` (`sub`, `role`, the tenant claim). `guc` sets `<prefix>.user_id` and one `<prefix>.<claim>` setting per claim.

`options.dialect` (default `supabase`), `options.gucPrefix` (default `app`) and `options.tenantClaim` (default `tenant_id`) must match the `rls` block in `permdock.config.ts`; `withSubject` does not read the config. `options.claims` adds claims from the verified token, such as `user_role` or `memberships` for `jwt`-mode helpers. `sub`, `role` and the tenant claim always come from the subject. `options.sql` passes Kysely's `sql` tag on runtimes without `require`.

Operator mapping:

| Portable operator | Kysely |
| --- | --- |
| `eq`, `ne`, `gt`, `gte`, `lt`, `lte` | `eb(col, '=' / '!=' / '>' / '>=' / '<' / '<=', eb.val(v))` |
| `in`, `notIn` | `eb(col, 'in', values)` / `eb(col, 'not in', values)` |
| `isNull` | `eb(col, 'is', null)` |
| `contains` | `eb(col, '@>', eb.val([v]))` for `options.listFields`, `like` with escaped wildcards for text |
| `and`, `or`, `not` | `eb.and([...])`, `eb.or([...])`, `eb.not(...)` |

## Request lifecycle [#request-lifecycle]

1. The request-scoped `PermDock` is created from the policy and subject.
2. `permdock.where(permission)` flattens grants into a portable tree (allows ORed, each ANDed with higher-priority deny negations); unconditional allow yields `true`, no grant yields `false`.
3. `toWhere` compiles the tree into an expression-builder callback bound to the table name and column map.
4. Kysely composes the callback with the rest of the query; subject values travel as parameters.
5. With `withSubject`, steps 2 to 4 are skipped and the database enforces the generated policies; the in-process `PermDock` is still used for `assert` before writes and for `on('decision')`.

## What it validates [#what-it-validates]

* Column existence is checked by Kysely's types when the database interface is typed; `toWhere` is generic over the `DB` type so an unknown column is a type error.
* Closure grants: `permdock.where` reports `{ portable: false }`; `toWhere` throws `PermDockValidationError` naming the grant.
* `withSubject` refuses any role other than `authenticated` or `anon`, and any setting prefix or claim name outside `[a-z_][a-z0-9_]*`. It throws before opening the transaction. A connection user that is not a member of the role fails at the `role` setting, so `fn` never runs.
* Rows returned from the database are trusted and not schema-validated.

## How denials surface [#how-denials-surface]

* App-side: `eb.lit(false)` and an empty result. Pair with `assert(permissions.post.list)` to answer `403` rather than an empty list when the caller lacks the collection permission.
* Database-side (`withSubject`): `USING` policies filter silently; `WITH CHECK` violations raise `42501`, which Kysely surfaces as a driver error; `permdock rls verify` classifies both as `filtered` and `rejected`.
* Non-portable grant: `PermDockValidationError` at compile time, so it fails in tests.
* Missing GRANT on the table also raises `42501` before any policy runs; `permdock rls generate` emits grants alongside policies to avoid this masquerade.

## Example app [#example-app]

None. Kysely is covered by `tests/integration/src/orm-parity.test.ts` (`ormParity`: `filter` in memory against `toWhere` on a testcontainers Postgres, with and without a memberships table), `orm-graph.test.ts` (relationship grants) and `with-subject.test.ts` (`withSubject` under the generated policies, compared with `can()`).

## Why [#why]

* **No `createPolicyTester` twin.** Kysera's database-less tester evaluates policies without a query. In PermDock, `describePolicy` from `permdock/testing` already does that: it runs the policy matrix, rows included, against the in-memory evaluator, which is the evaluator `toWhere` is proven against. `ormParity` then runs the same conditions through Kysely against SQLite or Postgres. A third tester would be a second in-memory evaluator to keep in step ([testing](/docs/adapters/testing)).

## Related standards [#related-standards]

* [Postgres RLS](/docs/standards/postgres-rls): `USING` versus `WITH CHECK`, `42501`, grants, Kysera's dual-mode design and the `set_config` patterns.
* [Conditions](/docs/concepts/conditions): operator set.
* [RLS adapter](/docs/adapters/rls): `generate --target sql` and the transaction preamble `withSubject` uses.
