# Neon

Neon provides serverless PostgreSQL for Cloudflare Workers. Choose it when the
application is designed around PostgreSQL: native JSONB and arrays, `tsvector`
with GIN indexes, PostGIS or other extensions, sustained concurrent writes, or
database-level Row Level Security alongside the generated Hono API. D1 also
supports JSON functions and FTS5; Neon is the choice when the **PostgreSQL
implementation and ecosystem** are the requirement.

## Why Neon

- **Full PostgreSQL** — Advanced queries, joins, PostGIS, full-text search
- **Database-level security** — RLS policies enforce access even if the API is bypassed
- **Serverless** — HTTP connection mode (`@neondatabase/serverless`) for Cloudflare Workers
- **Worker-scoped RLS** — the Worker verifies each caller, then writes the trusted user/org/team context into transaction-local Postgres settings that RLS policies read (see [How Worker-scoped RLS works](#how-worker-scoped-rls-works))
- **Better Auth integration** — Works seamlessly with the Quickback auth provider

## One database for auth and features

Quickback uses a single database provider per app. On Neon, Better Auth tables
and feature tables live in the same Postgres database: Better Auth runs on its
PostgreSQL/Drizzle adapter, and everything connects through one `DATABASE_URL`.
There is no `AUTH_DATABASE_URL`, and split-database mode is a D1-only option.

In Hyperdrive mode the same one-database rule holds, but the Worker connection
comes exclusively from `env.HYPERDRIVE.connectionString`: feature queries and
Better Auth share the generated database helpers. `DATABASE_URL` is then a
compiler/migration input, not a deployed Worker secret.

Mixing providers — for example D1 for auth with Neon for features — is not
supported. The per-service config form is rejected at compile time:

```
providers.database service-map form is not supported in v2.
Configure one database provider; auth and feature data use the same database.
```

## Configuration

```typescript title="quickback/quickback.config.ts"
import { defineConfig, defineRuntime, defineDatabase, defineAuth } from "@quickback/compiler";

export default defineConfig({
  name: "my-app",
  providers: {
    runtime: defineRuntime("cloudflare"),
    database: defineDatabase("neon", {
      connectionMode: "auto",  // detects runtime
      pooled: true,
    }),
    auth: defineAuth("better-auth"),
  },
});
```

## Connection modes

| Mode | Best for | How it works |
|------|----------|--------------|
| **HTTP** (default) | Cloudflare Workers — batch writes | Stateless HTTP queries via `@neondatabase/serverless` |
| **Hyperdrive** | Cloudflare Workers — interactive transactions | `postgres.js` over a Cloudflare Hyperdrive binding (`drizzle-orm/postgres-js`) |
| **WebSocket** *(deprecated)* | Node.js, Bun | Persistent WebSocket (`drizzle-orm/neon-serverless`) — superseded by Hyperdrive |
| **Auto** | Mixed environments | Detects runtime: `http` on `cloudflare`, `websocket` on Node/Bun |

On the Cloudflare runtime, `auto` resolves to **HTTP** — batch-only, no binding,
and the mode the rest of this page assumes. Opt a project up to
`connectionMode: 'hyperdrive'` when its actions need real interactive
transactions (see [Interactive transactions](#interactive-transactions)).

> **HTTP** is the lightweight default: atomic predetermined batches
> (`db.batch([...])`), no binding. It does **not** support interactive
> `db.transaction()` — requesting one is a fail-closed compile error.
>
> **Hyperdrive** is the full-featured path: everything HTTP does **plus** real
> interactive transactions, and it keeps webhooks, cross-tenant `unsafe` actions,
> and `.encrypted()` / `.sealed()` columns.
>
> **WebSocket** is deprecated — Hyperdrive supersedes it. The old WebSocket client
> was unscoped and fail-closed on webhooks / `unsafe` / encryption; new projects
> that need transactions should use `connectionMode: 'hyperdrive'`.


## Named Cloudflare deployment environments

Neon Hyperdrive projects can declare isolated Worker targets under
`environments`. A logical `dev` / `prod` pair on a project named `attend-v2`
deploys through Wrangler as `attend-v2-dev` and `attend-v2-prod`:

> `environments` reads from either the **top-level** `environments` slot (the
> preferred placement, and the one the config scaffold advertises) or the
> original `providers.database.environments` slot shown below. Both are accepted
> and behave identically; declaring the block in **both** places is a compile
> error, because there would be no single source of truth.


```typescript title="quickback/quickback.config.ts"
export default defineConfig({
  name: "attend-v2",
  email: { provider: "cloudflare", from: "hello@attend.vip" },
  bindings: {
    secrets: [
      { name: "EVENT_PASS_HMAC_PEPPER_V1", required: true },
    ],
  },
  providers: {
    runtime: defineRuntime("cloudflare"),
    database: {
      name: "neon",
      config: {
        connectionMode: "hyperdrive",
        hyperdrive: { id: "ffffffffffffffffffffffffffffffff" },
      },
      environments: {
        dev: {
          // Wrangler worker name — required on every target, never derived.
          name: "attend-v2-dev",
          branch: "dev",
          worker: {
            env: "dev",
            workersDev: true,
            previewUrls: false,
            vars: {
              ENVIRONMENT: "dev",
              BETTER_AUTH_URL: "https://attend-v2-dev.example.workers.dev",
            },
            requiredSecrets: [
              "BETTER_AUTH_SECRET",
              "EVENT_PASS_HMAC_PEPPER_V1",
            ],
            bindings: {
              hyperdrive: {
                id: "11111111111111111111111111111111",
                localConnectionString: "postgresql://quickback:dev-only@127.0.0.1:5432/attend_v2",
              },
              kv: { id: "22222222222222222222222222222222" },
              rateLimits: {
                RL_200_60: "61001200",
                RL_1000_60: "61002000",
              },
              sendEmail: { name: "EMAIL" },
            },
          },
        },
        prod: {
          name: "attend-v2",
          branch: "production",
          worker: {
            env: "prod",
            workersDev: true,
            previewUrls: false,
            vars: {
              ENVIRONMENT: "prod",
              BETTER_AUTH_URL: "https://attend-v2-prod.example.workers.dev",
            },
            requiredSecrets: [
              "BETTER_AUTH_SECRET",
              "EVENT_PASS_HMAC_PEPPER_V1",
            ],
            bindings: {
              hyperdrive: { id: "33333333333333333333333333333333" },
              kv: { id: "44444444444444444444444444444444" },
              rateLimits: {
                RL_200_60: "62001200",
                RL_1000_60: "62002000",
              },
              sendEmail: { name: "EMAIL" },
            },
          },
        },
      },
    },
    auth: defineAuth("better-auth"),
  },
});
```

Wrangler bindings and variables are non-inheritable. Quickback therefore emits
the generated vars, required-secret declaration, Hyperdrive, KV, rate-limit,
and Cloudflare Email blocks inside **every** `[env.<name>]` target. The base
Worker carries no deployable stateful binding and has `workers_dev = false` /
`preview_urls = false`, so a bare deploy cannot accidentally point at shared or
placeholder state.

The environment contract is intentionally exact and fails closed:

- `workersDev: true` and `previewUrls: false` must be explicit for each target.
- Each target declares its own `name` (the Wrangler worker name) and no two
  share one. It is the deployed script's identity — Durable Object state and
  the migration ledger belong to it — so it is never derived. An existing
  project must restate the name it already deploys under.
- The auth provider must currently be Better Auth. External auth needs a
  non-inheritable service binding, so it remains rejected until named targets
  can require an explicit per-environment service-binding override.
- Hyperdrive and KV IDs must be real 32-character Cloudflare resource IDs and
  cannot be reused between environments.
- Named environments must include a logical `dev` target. It is selected by
  `npm run dev` and must own a `bindings.hyperdrive.localConnectionString`.
  Quickback emits that
  development-only URL only inside its named Hyperdrive block and rejects it
  on every other target, so `prod` always resolves its deployed connection
  from the remote Hyperdrive ID.
- `rateLimits` must contain exactly the binding names generated by the current
  feature set, with account-unique integer namespace IDs. Add or remove a rate
  limit and the next compile tells you which override changed.
- `requiredSecrets` must exactly match the generated runtime inventory:
  `BETTER_AUTH_SECRET`, a custom `auth.jwt.secretEnv` when configured,
  `ENCRYPTION_KEK` when envelope-encrypted columns are present, and every
  custom secret declared with `required: true`. It also includes the shared
  `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` credentials when generated
  Better Auth code uses SES, SNS, or AWS-backed anonymous-account upgrades;
  every enabled social provider's `AUTH_<PROVIDER>_CLIENT_ID` and
  `AUTH_<PROVIDER>_CLIENT_SECRET`; plus the configured R2
  account/access/secret env names when presigned uploads are enabled. Values
  remain out of source control; set them with
  `wrangler secret put <NAME> --env dev` (and again for `prod`).
- Include `sendEmail: { name: "EMAIL" }` when the generated runtime uses
  Cloudflare Email Service; omit it when email is not generated.
- Every queue and R2 bucket the config generates needs a per-target entry
  under `bindings.queues` / `bindings.r2Buckets`, with names unique across
  targets — see [Queues and R2 buckets per target](/platform/database/d1#queues-and-r2-buckets-per-target).
- Custom domains and routes are owned **per target**. Quickback emits no
  top-level `routes` block for a named-environment project — Wrangler inherits
  that key, so a shared block would let `deploy -e dev` rebind the production
  hostname — and every generated source (`providers.runtime.config.routes`,
  `domain`, CMS/Account/Admin domains, `auth.domain`, `api.domain`, app domains
  and their aliases, and the derived `webhook.{baseDomain}` route) must be
  claimed by each environment through `domains`. The unified
  `quickback.{baseDomain}` host is inferred per target as
  `quickback-<env>.{baseDomain}`, overridable with `domains.primary`. See
  [Named Environments](/configure/domains#named-environments) for the key-by-key
  mapping, the single-hostname `domain` shorthand, and the `domains: 'none'`
  workers.dev-only opt-out.

Generated `npm run dev` selects the `dev` target (`wrangler dev --env dev`).
Quickback omits the bare `deploy` script for named-environment projects and
generates explicit `npm run deploy:dev` / `npm run deploy:prod` scripts. Each
maps to its configured Wrangler environment. Apply that branch's migrations
first; migrations intentionally remain a separate target-owned step.

Projects that omit `environments` retain the existing `wrangler.toml` and
package scripts byte-for-byte. Named environments require Better Auth, plus
either Neon with `connectionMode: "hyperdrive"` (this page) or Cloudflare D1
(see [Named deployment environments](/platform/database/d1#named-cloudflare-deployment-environments)).
Quickback rejects other providers or partial binding sets instead of emitting a
Worker that can deploy but fail at runtime.

## Environment variables

HTTP mode has three connection points, each read by a different tool:

| Where | File / store | Read by |
|-------|--------------|---------|
| Migrations (local/CI) | `.env` | `drizzle-kit` |
| `wrangler dev` (local Worker) | `.dev.vars` | Wrangler |
| Production Worker | `wrangler secret put` | The deployed Worker |

The compiler emits `.env.neon.example` and `.dev.vars.example` as templates,
and gitignores `.dev.vars` so local secrets are never committed.

```bash
# Migrations read .env — copy the template and fill it in:
cp .env.neon.example .env
# .env holds:
#   DATABASE_URL           — pooled quickback (runtime) role URL
#   DATABASE_MIGRATION_URL — unpooled quickback_admin role URL (optional)

# wrangler dev reads .dev.vars (NOT .env) — copy the template:
cp .dev.vars.example .dev.vars   # gitignored; holds DATABASE_URL + BETTER_AUTH_SECRET

# Production: secrets live in Worker secrets, never in files:
npx wrangler secret put DATABASE_URL       # pooled quickback role URL
npx wrangler secret put BETTER_AUTH_SECRET
```

`DATABASE_URL` is the only connection string the Worker needs at runtime — auth
and feature queries share it. `DATABASE_MIGRATION_URL` is used **only** by
`drizzle-kit`: the generated `drizzle.config.ts` reads
`process.env.DATABASE_MIGRATION_URL ?? process.env.DATABASE_URL`, so migrations
run as the privileged `quickback_admin` role when it is set and fall back to
`DATABASE_URL` when it is not. Never deploy `DATABASE_MIGRATION_URL` to the
Worker.

### Hyperdrive runtime credentials

With `connectionMode: "hyperdrive"`, do not deploy `DATABASE_URL` to the
Worker. Configure the `HYPERDRIVE` binding with query caching disabled and keep
`DATABASE_URL` / `DATABASE_MIGRATION_URL` only in the compiler or CI
environment for Drizzle schema generation and migrations. Better Auth and
feature queries both use Hyperdrive at runtime, eliminating a second direct
Neon connection lane.

For local `wrangler dev` without named environments, set
`providers.database.config.hyperdrive.localConnectionString` to a local or
development-only Postgres URL. With named environments, put it on the target
selected by the generated local dev script instead:

```typescript
environments: {
  dev: {
    worker: {
      bindings: {
        hyperdrive: {
          id: "11111111111111111111111111111111",
          localConnectionString: "postgresql://quickback:dev-only@127.0.0.1:5432/attend_v2",
        },
      },
    },
  },
}
```

Quickback requires the logical `dev` target, rejects the top-level local
override when named environments exist, and rejects `localConnectionString`
on staging or production targets. Wrangler uses this field only for local
development; deployed Workers obtain their
connection string from the Hyperdrive configuration identified by `id`.

> The generated setup uses three Postgres roles: `quickback_owner` (NOLOGIN object
> owner for the app/auth schemas), `quickback_admin` (migration/setup actor — use
> its unpooled URL for `DATABASE_MIGRATION_URL`), and `quickback` (the Worker
> runtime role, the **only** deployed DB role — use its pooled URL for
> `DATABASE_URL`). The first generated migration bootstraps these roles as
> NOLOGIN; enable login and set passwords before use. Full walkthrough in the
> generated `docs/neon-setup.md`.
>
> Run the **first** `db:migrate` with your database owner credential (Neon's
> `neondb_owner`) — that bootstrap run also grants `quickback_admin` everything
> the Drizzle journal needs (`CREATE` on the database, plus ownership of the
> `drizzle` schema and `drizzle.__drizzle_migrations`). Routine migrates **after**
> bootstrap — applying new schema deltas — then run entirely as `quickback_admin`
> via `DATABASE_MIGRATION_URL`, no owner credential required. This includes the
> pinned runner's `CREATE SCHEMA IF NOT EXISTS drizzle` preamble, which needs
> database-level `CREATE` even as a no-op and previously forced every migrate
> back onto the owner credential. (A change to the **security/RLS config** appends
> a new `quickback_rls` migration that re-runs the role bootstrap, so re-point
> `DATABASE_MIGRATION_URL` at the owner credential for that one apply.)
>
> Each security migration first removes direct `PUBLIC` and `quickback` runtime
> privileges from the Drizzle schema, journal table, journal sequences, and
> database-level `CREATE`, then restores the migration role's capability. The
> audit schema/table likewise revoke prior direct grants before restoring only
> runtime schema usage + table `INSERT` and admin forensic access. Reapplying a
> migration therefore converges privilege drift instead of merely adding grants.
>
> The role bootstrap's ownership sweep re-owns tables, views, materialized
> views, and standalone sequences to `quickback_owner`, but skips sequences
> linked to a `serial`/identity column — Postgres forbids re-owning those
> independently (their ownership follows the table's automatically), so serial
> columns never break a security migration.
>
> The `audit` schema is **latched**: once a project has emitted it, every later
> compile keeps emitting it even if no unsafe / `PUBLIC` / hard-delete action
> remains — the compiler never generates `DROP SCHEMA audit`, and the audit
> subsystem's `CREATE` statements are hardened to `IF NOT EXISTS` so
> re-application can never collide. `audit.events` is an append-only forensic
> sink; purging it is a deliberate operator action, never a generated migration.


## Better Auth schema generation

`src/auth/schema.ts` is a compiler-owned artifact derived with the Better Auth
CLI during `quickback compile`. The generated `build` script is deliberately
read-only with respect to generated source:

```bash
quickback compile     # regenerates src/auth/schema.ts and the project output
npm run build        # runs tsc only; does not rewrite generated files
```

For schema-generator diagnostics, the explicit `auth:schema` command expands
to:

```bash
npx better-auth generate --config ./src/lib/auth.ts --output ./src/auth/schema.ts --yes \
  && node scripts/quickback-qualify-auth-schema.mjs
```

The `better-auth` executable comes from the `auth` devDependency in the
generated `package.json` — that's the Better Auth CLI package (the
`better-auth` runtime package ships no bin).

The qualifier script adjusts the CLI output for Postgres schema placement.
`auth:schema` produces the intermediate Better Auth output and may remove
Quickback's generated-file envelope. Do not commit that intermediate file;
rerun `quickback compile` to restore the canonical project bytes. Don't
hand-edit `src/auth/schema.ts`.

Recompiling also replaces previously generated `package.json` lifecycle
commands with their current definitions. An existing project whose `build`
script still runs `auth:schema` therefore converges to the TypeScript-only
build, while unrelated custom scripts remain intact.

## Migrations

Neon uses PostgreSQL migrations, distinct from D1's SQLite migrations. One
Drizzle journal covers auth tables, feature tables, indexes, FK/cascade
semantics, and the RLS layer:

```bash
quickback compile      # generates migrations under quickback/drizzle/
npm run db:migrate     # applies schema AND the RLS layer, in order
```

The RLS layer (helper functions, policies, triggers) rides the same
journaled migration set as your schema: the compiler appends a
content-addressed `<idx>_quickback_rls_<hash>` migration, so one
`db:migrate` applies everything in order. The RLS migration is idempotent
(`DROP POLICY IF EXISTS` + re-create) and only a *changed* security config
appends a new entry — recompiles with unchanged security append nothing. No
manual SQL application step exists.

Quickback also dependency-orders Drizzle's standalone constraint replacements.
When a composite unique or primary key and its consuming foreign key are both
replaced, the generated migration drops the dependent foreign key first, drops
and recreates the referenced key, then recreates the foreign key. This avoids
Postgres `2BP01` dependency failures without `CASCADE` and without requiring a
hand-edited migration. Already-correct migrations remain byte-identical.

Cross-schema and composite relationships that cannot live in one generated
Drizzle schema may be declared with
[`compiler.migrations.foreignKeys`](/configure#neon-foreign-key-migrations).
Quickback appends them to that same content-addressed migration after the
schema tables and target unique keys exist. Declarations are deterministic and
replay-safe: an existing constraint must match exactly, while existing orphan
rows fail the `VALIDATE CONSTRAINT` step. Foreign keys enforce integrity only;
keep authorization and business state transitions in the API action layer.

## How Worker-scoped RLS works

Quickback does **not** send browser or mobile clients directly to Neon, and it
does **not** use Neon Authorize / a JWKS endpoint. Clients authenticate with the
Cloudflare Worker; the Worker verifies the Better Auth session or API key, runs
the access checks, and then performs the database call as a trusted proxy.

For every feature-table query, the generated database client opens a
claims-scoped transaction (an implicit Neon HTTP batch or a Hyperdrive
transaction) whose **first** statement writes the verified caller context into
transaction-local Postgres settings:

```sql
-- Injected by the Worker as the batch's first statement (is_local = true):
SELECT
  set_config('quickback.service_role',      '',        true),
  set_config('request.jwt.claim.sub',       'usr_123', true),
  set_config('request.jwt.claim.org_id',    'org_abc', true),
  set_config('request.jwt.claim.team_id',   '',        true),
  set_config('quickback.principal.type',       '',     true),
  set_config('quickback.principal.actor_type', '',     true),
  set_config('quickback.principal.actor_id',   '',     true),
  set_config('quickback.principal.session_id', '',     true),
  set_config('quickback.principal.family_id',  '',     true),
  set_config('quickback.principal.claims',     '{}',   true);
```

RLS policies read those settings through Quickback-defined helper functions —
there is no external JWT verification and no persisted session state in the
database. `auth.user_id()` is a compiler-generated shim, not a Neon Authorize
function:

```sql
-- Generated by Quickback (granted to the quickback runtime role):
CREATE OR REPLACE FUNCTION auth.user_id()
RETURNS TEXT AS $$
  SELECT NULLIF(current_setting('request.jwt.claim.sub', true), '')
$$ LANGUAGE SQL STABLE SET search_path = public;
```

Because the settings are transaction-local (`set_config(..., true)`), they
apply only to the current batch and never leak between requests. If the Worker
never stamps a user id, `auth.user_id()` returns `NULL` and the restrictive
`deny_anon` policy blocks the query unless a verified delegated principal is
present — fail closed. Account requests explicitly clear all principal fields;
delegated requests explicitly clear the account/org/team fields.

An API key's organization metadata is only a candidate scope. Before the
middleware stamps it into `AppContext`, the generated Neon membership lookup
opens a request-scoped database handle with the verified API-key owner as
`userId` and the candidate organization as `orgId`, then requires the member
row to match both values. A missing or RLS-denied row clears organization and
role authority.

## Row Level Security

Quickback emits RLS policies from your `firewall` and `access` config. Neon's
policies read the transaction-local claims described above via `auth.user_id()`
and the `get_active_org_id()` / `has_any_role()` helpers.

```typescript
firewall: [
  { field: 'organizationId', equals: 'ctx.activeOrgId' },
]
```

```sql
CREATE POLICY "documents_select" ON documents FOR SELECT
USING (
  organization_id = public.get_active_org_id()
  AND (public.is_admin() OR user_id = auth.user_id())
);
```

Every feature table also gets a restrictive `deny_anon` policy (blocks queries
with neither a stamped user id nor a delegated principal) and a `service_role`
policy (admits Worker-internal contexts — see
[Background jobs and cross-tenant actions](#background-jobs-and-cross-tenant-actions)).

### Delegated principal RLS

Delegated principals require Cloudflare + Neon Hyperdrive. Their proof hook
returns only a digest and receives no database handle. Quickback then runs one
lookup under an internal lookup-only principal whose RLS policy admits only the
configured table row matching `credentialDigest`, plus any configured active
status condition. The generated unique lookup index is content-addressed: a
changed table, digest column, or definition drops the prior logical index and
creates the exact replacement. Names are hash-suffixed to avoid collisions and
remain inside PostgreSQL's 63-byte identifier limit.

After the lookup, the normal request database handle carries the resolved
principal type, actor, optional session/family, and JSON claims in the same
transaction-local preamble shown above. RLS reads them with:

| Function | Purpose |
|----------|---------|
| `get_principal_type()` | Exact configured type such as `event_delegate` |
| `get_principal_actor_id()` | Stable delegated actor id |
| `get_principal_session_id()` | Optional session id |
| `get_principal_family_id()` | Optional session-family id |
| `get_principal_claim(name)` | One configured string claim such as `eventId` or `personId` |

Generated request types are a closed union of the configured principal names,
literal actor types, exact session shape, and configured claim keys. The Neon
database context uses the same resolved union plus separate compiler-internal
`__credential_lookup__:<type>` variants. Lookup-only authority therefore
cannot appear in an action `AppContext`, and a delegated action context cannot
carry account/org/team/membership fields.

`resource.databaseAccess` emits operation-specific, RLS-only policies for
authored actions and support tables. It does not mount generic routes or
OpenAPI operations. Every authorization path must include at least one
SQL-lowerable record equality; any unbounded OR arm or principal/role-only leaf
fails compilation. This lets an organization account lane and a disjoint
event/person delegated lane share a table without weakening either:

```typescript
databaseAccess: {
  select: {
    or: [
      {
        roles: ["AUTHENTICATED"],
        record: { organizationId: { equals: "$ctx.activeOrgId" } },
      },
      {
        principals: ["event_delegate"],
        record: {
          eventId: { equals: "$ctx.principal.claims.eventId" },
          personId: { equals: "$ctx.principal.claims.personId" },
        },
      },
    ],
  },
}
```

Postgres combines the record comparisons inside each arm with AND. Restrictive
per-operation principal fences also cap every permissive policy, so a public,
firewall-exception, or old permissive policy cannot admit the wrong principal
type. The action still owns all application behavior; these policies only
constrain which rows its caller-scoped `db` or transaction may touch.

### Firewall patterns

**Organization-scoped:**

```sql
CREATE POLICY "projects_select" ON projects FOR SELECT
USING (organization_id = public.get_active_org_id());
```

**User-scoped:**

```sql
CREATE POLICY "preferences_select" ON preferences FOR SELECT
USING (user_id = auth.user_id());
```

**Public tables:**

```sql
CREATE POLICY "categories_deny_anon" ON categories FOR ALL TO anon USING (false);
CREATE POLICY "categories_all" ON categories FOR ALL TO authenticated USING (true) WITH CHECK (true);
```

### Helper functions

| Function | Purpose |
|----------|---------|
| `auth.user_id()` | Reads the verified user id from the transaction-local `request.jwt.claim.sub` setting |
| `get_active_org_id()` | Reads the request-scoped org from the transaction-local `request.jwt.claim.org_id` setting |
| `get_active_team_id()` | Reads the request-scoped team from `request.jwt.claim.team_id` (teams mode) |
| `has_any_role(roles[])` | Checks if the user has any specified role in the request org |
| `has_org_role(role)` | Checks for a single specific role |
| `is_org_member()` | Checks org membership in the request org |
| `is_owner(owner_id)` | Checks record ownership against `auth.user_id()` |
| `is_admin()` | Checks the `auth.admins` table (admin mode) |

All helpers are TEXT-typed to match Better Auth's prefixed ids (`org_abc123`).
The request org/team scope is passed by the Worker **per transaction**, not read
from a persisted mirror. There is no compiler-owned `user_sessions` table and no
session hook that syncs the active organization into the database — switching to
"no org" simply means the Worker stamps an empty `request.jwt.claim.org_id`, and
SQL-side access is revoked immediately. Fail closed.

The only compiler-owned support tables the RLS layer reads live in
`src/db/rls-support.schema.ts`: a `profiles` table (populated by the
`sync_profile_display_name()` trigger and protected by the `profiles_*`
policies) and, when admin/sysadmin helpers are emitted, an `admins` table (read
by `is_admin()` / `is_sysadmin()`). Better Auth's own `sessions` table may still
carry an `active_organization_id`, but Quickback RLS never reads it.

RLS coverage includes Better Auth's own tables — including those the compiler
force-enables (organization, admin, `apikeys`; plus the OAuth/agent table
families when those surfaces are on, locked to service-role only).

## Background jobs and cross-tenant actions

Some code runs with no HTTP request and therefore no caller claims: queue
consumers, cron schedules, and cross-tenant `unsafe` actions. These acquire the
**service-role handle** `createServiceDb(env)` from the generated `src/db`
index. Instead of caller claims, it sets `quickback.service_role = 'true'` (with
all request claims cleared) — the same marker the Better Auth adapter's
transactions use. Every feature table carries a `<table>_service_role` RLS
policy that admits exactly that context, so background work can reach rows
across tenants while the request-scoped `createDb()` client stays caller-scoped.

The service-role marker is only ever set by Worker-internal chokepoints
(`createServiceDb`, the Better Auth adapter) — it is never derived from request
input. Unsafe actions keep their mandatory audit contract: cross-tenant writes
still land audit events (the `audit` Postgres schema), unchanged from D1.

## Reads through a user-anchored relationship

Some resources are readable not because you own them or belong to their org, but
because you are reachable from them through a **chain of tables** anchored on the
caller's user id. A conference attendee, for example, can read their own
registrations even though they are not an org member — the grant travels
`person_account_links.user_id = ctx.userId → people → event_people →
registrations`.

Declare the chain once as an `authz.relationships` entry using the `hops` form
(each hop names the joined `table`, the column it is joined `on`, and the `prev`
column on the previous table that points at it). The terminal hop's `table` is
the **anchor** — the table carrying the caller's user id:

```typescript
// quickback.config.ts
authz: {
  relationships: {
    attendeeReachable: {
      from: 'person_account_links',            // the anchor table
      subject: { column: 'userId', equals: 'ctx.userId' },
      resource: { column: 'eventPersonId' },   // the column on the resource this constrains
      hops: [
        { table: 'event_people', on: 'id', prev: 'eventPersonId' },
        { table: 'people',       on: 'id', prev: 'personId' },
        { table: 'person_account_links', on: 'personId', prev: 'id' },
      ],
    },
  },
},
```

A resource opts into the grant by referencing the relationship from its
`firewall` — typically as one arm of an `any:` group so org members keep their
own path in:

```typescript
// features/registrations/registrations.ts
firewall: [
  {
    any: [
      { field: 'organizationId', equals: 'ctx.activeOrgId' }, // org-member grant
      { field: 'eventPersonId',  via: 'attendeeReachable' },  // reachable-attendee grant
    ],
  },
],
```

### The anchor must be non-forgeable (fail closed on both targets)

The reachability chain terminates in `anchor.user_id = ctx.userId`, so whoever
can **write** an anchor row can mint themselves reachability to a victim's data.
Quickback therefore refuses to compile a user-anchored multi-hop relationship
unless the anchor table is:

- **caller-read-own on the subject column** — its firewall scopes reads to
  `ctx.userId` (e.g. `firewall: [{ field: 'userId', equals: 'ctx.userId' }]`), and
- **service-role-write-only** — it exposes no caller-reachable `create` / `update`
  / `delete` endpoint, so the link can only be created by Worker-internal code.

This check is **target-independent**: it fires identically on Neon and D1,
because the reachability join runs in the app layer on both. A forgeable anchor
is a compile error everywhere, not a Neon-only concern.

### How the read is served

On **Neon**, a via-relationship reachability predicate cannot be expressed as an
RLS policy — the correlated multi-hop join reaches across intermediate tables
that each `FORCE ROW LEVEL SECURITY`, so caller-claims RLS would deny the attendee
to *zero* rows before the app-layer WHERE ever runs. Quickback resolves this by
routing that resource's **read** handlers through the service-role handle
(`createServiceDb(c.env)`) instead of the caller-claims client. RLS stops denying,
and the compiled `buildFirewallConditions(...)` reachability WHERE — the exact
same clause D1 uses — becomes the **sole** row filter.

This is the D1 trust model, applied to Neon for exactly these reads: the
application layer is the firewall, and RLS is a coarse tenant backstop that steps
aside where it cannot express the grant. It is surgical and fail-closed:

- Only the affected resource's reads swap handles. **Org / owner / team reads keep
  caller claims + the RLS backstop** — they resolve to policies RLS *can* express.
- The swap is gated on a non-empty firewall carrying the reachability predicate;
  a service-handle read with no WHERE is a compile error (it would return every
  tenant's rows). Fail closed.
- It applies to the **collection `GET /`, `GET /:id`, and views** alike. **Writes
  are unchanged** — creates/updates/deletes keep caller claims and the RLS
  backstop.

The **same config compiles identically on D1**, where there is no RLS to bypass:
the read handlers keep `c.get('db')` and the app-layer reachability WHERE was
already the whole story.

## Webhooks on Neon

Webhooks are supported on Neon over HTTP. The store (`webhook_events`,
`webhook_endpoints`, `webhook_deliveries`) lives in a dedicated `webhooks`
Postgres schema of the **same** database — following the `audit` schema
precedent — so there is no separate `WEBHOOKS_DB` binding.

- Set `webhooksBinding` to enable webhooks. On Neon its value is a pure enable
  flag (no D1 binding is emitted); the queue names stay provider-neutral.
- The D1-only keys `webhooksDatabaseId` and `webhooksDatabaseName` are rejected
  at compile time — they name a D1 database that cannot exist on Neon.
- The webhook store is Worker-internal (endpoint signing secrets live there):
  every table in the `webhooks` schema has RLS enabled with a single
  service-role-only policy, so only `createWebhooksDb()` (which wraps
  `createServiceDb`) can touch it. Route handlers gate access at the app layer
  (`requireOrgMember` / endpoint-ownership checks) before querying.
- Webhooks require `connectionMode: 'http'`.

## Encryption on Neon

The encryption pillar — `.encrypted()` (envelope) and `.sealed()` /
`q.vaultRef()` (sealed) columns — is supported on Neon over HTTP and
Hyperdrive. The
`org_data_keys` and seal-vault tables fold into the single journaled migration
set, and the encryption RLS section rides the journaled RLS migration:
`org_data_keys` gets org-scoped RLS (the envelope DEK path needs caller claims),
and the vault spine is locked to service-role-only. The seal chokepoints
acquire `createServiceDb`.

The deprecated Node/Bun WebSocket mode is a fail-closed compile error for both
tiers because it cannot provide the request-scoped and service-role handles the
key tables require.

## Batch transactions

Whether a batch handler (`PATCH /batch`, `DELETE /batch`, …) is truly atomic
depends on the connection mode, and the response advertises the guarantee via
`meta.transactional`:

| Mode | `meta.transactional` | Behavior |
|------|----------------------|----------|
| Neon HTTP (Cloudflare default) | `false` | Fail-fast per-record loop, no rollback — `drizzle-orm/neon-http` has no interactive transactions, so this matches D1's semantics honestly |
| Neon Hyperdrive (Cloudflare) | `true` | `db.transaction()` wraps the loop; any failure rolls back every write in the batch |
| Neon WebSocket (Node/Bun) | `true` | `db.transaction()` wraps the loop; any failure rolls back every write in the batch |

`drizzle-orm/neon-http` throws "No transactions support in neon-http driver" at
runtime, so the HTTP path deliberately treats batches like D1 (fail-fast,
non-transactional) rather than emitting code that would 500 on every failed
batch. Honest advertisement over runtime surprises. For true atomic batches and
interactive transactions on Cloudflare, use
[`connectionMode: 'hyperdrive'`](#interactive-transactions).

## Interactive transactions

`connectionMode: 'hyperdrive'` runs the feature database over a Cloudflare
[Hyperdrive](https://developers.cloudflare.com/hyperdrive/) binding using
`postgres.js` (`drizzle-orm/postgres-js`), which supports **real interactive
transactions** — read current state, lock it, decide in TypeScript, then write
dependent rows, all committed atomically:

```ts title="quickback/features/registrations/actions/advance.ts"
import { z } from "zod";
import { eq } from "drizzle-orm";
import {
  defineAction,
  ActionError,
  registrations,
  registrationEvents,
} from "../.quickback/define-action";

export default defineAction({
  description: "Advance a registration inside one interactive transaction.",
  access: { roles: ["admin"] },
  input: z.object({ registrationId: z.string() }),
  async execute({ db, input }) {
    return db.transaction(async (tx) => {                 // tx is fully typed
      const [reg] = await tx.select()
        .from(registrations)
        .where(eq(registrations.id, input.registrationId))
        .for("update");                                   // row lock held to commit
      if (!reg) throw new ActionError("Not found", "NOT_FOUND", 404); // → rollback
      const [updated] = await tx.update(registrations)
        .set({ status: nextStatus(reg) })
        .where(eq(registrations.id, reg.id))
        .returning();
      await tx.insert(registrationEvents).values({ registrationId: updated.id });
      return updated;                                     // atomic commit
    });
  },
});
```

Note the `tx.select()…for("update")` form: Drizzle's relational query API
(`tx.query.<table>.findFirst`) is [blocked on the scoped
`db`/`tx`](/define/actions/scoped-db#scoped-relational-query-safety)
because the tenant firewall can't be injected into relational queries. The
select builder gives you the same row lock with scope conditions applied
automatically.

Configure it on the database provider:

```ts
providers: {
  database: {
    provider: "neon",
    config: { connectionMode: "hyperdrive", hyperdrive: { id: "<binding-id>" } },
  },
}
```

The compiler emits the `[[hyperdrive]]` wrangler binding pointing at your Neon
**direct** connection string. Its request-scoped postgres.js client uses
`max: 5`, disables prepared statements, and skips type-fetch round trips — the
small client pool bounds origin connections per Worker isolate while
Hyperdrive owns the shared connection pool.

> Postgres authorizes `SELECT … FOR UPDATE` as both a read and a write: the row
> must pass its `SELECT` **and** `UPDATE` policies. This matters for delegated
> principals because Quickback emits a restrictive update fence for every table.
> A principal with `databaseAccess.select` but no matching
> `databaseAccess.update` can read an eligible row, but cannot lock it with
> `.for("update")`.
>
> Keep read-only eligibility and reference-table lookups as ordinary scoped
> selects. Lock the mutable grant, capacity, or redemption row that the action is
> authorized to update. If an action legitimately must lock another table,
> declare a narrowly bounded `databaseAccess.update` rule for that principal;
> do not broaden or bypass the generated principal fence.


**Security is preserved, unchanged.** The verified caller claims run as the
first transaction-local statement inside the transaction (the same
`set_config(..., true)` preamble batches use), so RLS enforces inside the
callback and the claims reset at commit/rollback — they never leak across
Hyperdrive's connection pool.

> Hyperdrive query **caching is force-disabled**: its cache keys on query text,
> not the caller's RLS claims, so a cached read would leak across tenants. HTTP
> response ETags provide caching at the correct post-auth layer. Requesting an
> interactive `db.transaction()` under any other `connectionMode` is a fail-closed
> compile error.


## Capability limits (fail closed)

Anything not yet ported to Postgres is a compile-time error on Neon, never
silently-broken output:

- **Managed file storage** (`fileStorage` with `managed: true`) is rejected — its
  metadata store is a D1 database. Use presign-only R2 (the default, without
  `managed`), which is provider-neutral and works on Neon today.
- **The Better Auth `subscriptions` plugin** is rejected until it gets
  provider-aware db acquisition and a Postgres dialect schema.
- **Raw `sqliteTable` interop sources** on a Postgres target are rejected — write
  child tables with `pgTable` (see [below](#raw-drizzle-interop-tables-must-use-pg-core)).
- **WebSocket mode** (deprecated) rejects webhooks, cross-tenant `unsafe`
  actions, and encrypted/sealed columns. Use
  [`connectionMode: 'hyperdrive'`](#interactive-transactions) instead — it is the
  full-capability interactive-transaction path and supersedes WebSocket.

Mixed database providers (e.g. D1 auth + Neon features) remain unsupported.

## Deploying

For an unnamed target, the generated `deploy` script runs migrations, then
ships the Worker:

```bash
npm run deploy   # runs db:migrate (drizzle-kit) then wrangler deploy
```

HTTP mode reads `DATABASE_URL` in the Worker, so set both runtime secrets before
its first deploy:

```bash
npx wrangler secret put DATABASE_URL       # pooled quickback role URL
npx wrangler secret put BETTER_AUTH_SECRET
```

The generated HTTP client fails fast at boot if `DATABASE_URL` is missing.

Hyperdrive mode gets its runtime connection string from the `HYPERDRIVE`
binding and must **not** receive a Worker `DATABASE_URL` secret. Its
`DATABASE_URL` / `DATABASE_MIGRATION_URL` values remain local or CI migration
inputs only. Set `BETTER_AUTH_SECRET` and any other exact generated secret
inventory, then deploy:

```bash
npx wrangler secret put BETTER_AUTH_SECRET
npm run deploy
```

Named Hyperdrive targets intentionally omit the bare `deploy` script and keep
the base Worker non-deployable. Apply the target branch's migrations, set each
secret with `--env dev` or `--env prod`, then use an explicit target:

```bash
npx wrangler secret put BETTER_AUTH_SECRET --env dev
npm run deploy:dev

npx wrangler secret put BETTER_AUTH_SECRET --env prod
npm run deploy:prod
```

> Live runtime smoke testing against a deployed Neon Worker is still being brought
> up; the compile/typecheck/migrate path is verified end-to-end (including
> executing the generated Postgres migrations under pglite), but treat a first
> production deploy as you would any new stack — exercise auth, a CRUD flow, and a
> batch write before relying on it.


## Generated files

```
quickback/drizzle/
├── 0000_<name>.sql               # schema migrations (drizzle-kit)
├── 0001_quickback_rls_<hash>.sql # journaled RLS layer (content-addressed)
└── meta/_journal.json            # one journal covers both
src/
├── db/index.ts                   # createDb / createServiceDb (claim-scoped clients)
├── db/schema.ts                  # Drizzle schema barrel
├── db/rls-support.schema.ts      # profiles (+ admins) — RLS support tables
└── auth/schema.ts                # Better Auth schema (owned by the BA CLI)
.env.neon.example                 # migration connection strings (copy to .env)
.dev.vars.example                 # wrangler dev secrets (copy to .dev.vars)
docs/neon-setup.md                # role bootstrap + deploy walkthrough
```

## Raw Drizzle interop tables must use `pg-core`

Internal child tables authored as raw Drizzle (a table file with **no**
`export default defineTable(...)`) are not dialect-translated by the
compiler — on Neon they must be written with `pgTable` from
`drizzle-orm/pg-core`. A `sqliteTable` interop source on a Postgres target
is a **compile-time error**: the mechanical sqlite→pg rewrite would leave
SQLite-shaped audit columns (`text` ISO strings instead of `timestamptz`,
which the RLS audit triggers stamp) and SQLite-only column options in the
Postgres schema.

```typescript
// quickback/features/interviews/interview_scores.ts
// @quickback-internal — child table, no CRUD routes
import { integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core';

export const interviewScores = pgTable('interview_scores', {
  id: text('id').primaryKey(),
  interviewId: text('interview_id').notNull(),
  rating: integer('rating').notNull(),
  // ── quickback:audit (compiler-managed — edits are validated, not merged) ──
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  modifiedAt: timestamp('modified_at', { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()),
  createdBy: text('created_by'),
  modifiedBy: text('modified_by'),
  deletedAt: timestamp('deleted_at', { withTimezone: true }),
  deletedBy: text('deleted_by'),
});
```

Interop tables are **not** exempt from the visible managed-column rule. The
validator runs over every table file in a feature directory
(`compiler.ts:1210-1230` iterates `feature.tables`, which
`raw-input/organize.ts:394` populates regardless of whether the file has a
resource config), so the six columns must be declared **literally in Drizzle
form** — `...q.audit()` is a q-DSL spread and is not expanded in raw Drizzle
source. A child table with no `crud` config soft-deletes by default, so
`deletedAt` / `deletedBy` are required too.

Tables **with** `defineTable(...)` are unaffected — existing `sqliteTable`
sources with a resource config still compile to the Postgres dialect
automatically.

## When to choose Neon vs D1

| Factor | Neon | D1 |
|--------|------|-----|
| SQL dialect | PostgreSQL | SQLite |
| Search | `tsvector`, language dictionaries, GIN indexes | FTS5 in custom SQL; generated Quickback `?search` uses `LIKE` |
| Structured data | Native JSONB, arrays, rich Postgres types | JSON text with JSON path/operators; relational or JSON arrays |
| Security model | RLS + application-layer | Application-layer only |
| Transactions | HTTP batches; interactive transactions with Hyperdrive | Atomic statements/batches within D1's SQLite execution model |
| Operations | Neon HTTP is lightweight; Hyperdrive is optional for pooling/interactive transactions | No connection strings or pooling |
| Best for | Postgres-native, data-intensive, or high-concurrency applications | Cloudflare-native CRUD and conventional application workloads |

## See also

- [D1](/platform/database/d1) — SQLite at the edge
- [Providers](/configure/providers) — All database provider options
