Quickback Docs
Quickback for Hono API

Neon

Use Neon serverless PostgreSQL with Quickback. Connection modes, Worker-scoped RLS, the set_config claim model, and the deploy workflow.

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)
  • 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

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

ModeBest forHow it works
HTTP (default)Cloudflare Workers — batch writesStateless HTTP queries via @neondatabase/serverless
HyperdriveCloudflare Workers — interactive transactionspostgres.js over a Cloudflare Hyperdrive binding (drizzle-orm/postgres-js)
WebSocket (deprecated)Node.js, BunPersistent WebSocket (drizzle-orm/neon-serverless) — superseded by Hyperdrive
AutoMixed environmentsDetects 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).

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 providers.database.environments. A logical dev / prod pair on a project named attend-v2 deploys through Wrangler as attend-v2-dev and attend-v2-prod:

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: {
          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: {
          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.
  • 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 source of a generated top-level custom route is currently rejected for named targets: providers.runtime.config.routes, domain, CMS/Account/Admin domains, auth.domain, api.domain, and hostname-mounted app domains and aliases. Quickback will add explicit per-environment route overrides before opening that gate.

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 are currently restricted to Neon with connectionMode: "hyperdrive" and Better Auth; 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:

WhereFile / storeRead by
Migrations (local/CI).envdrizzle-kit
wrangler dev (local Worker).dev.varsWrangler
Production Workerwrangler secret putThe deployed Worker

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

# 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:

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:

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:

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:

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. 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:

-- 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:

-- 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 (the Supabase target uses auth.uid() instead — same pattern, different function).

firewall: [
  { field: 'organizationId', equals: 'ctx.activeOrgId' },
]
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).

Delegated principal RLS (contract v2)

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:

FunctionPurpose
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:

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:

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

User-scoped:

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

Public tables:

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

FunctionPurpose
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:

// 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:

// 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:

Modemeta.transactionalBehavior
Neon HTTP (Cloudflare default)falseFail-fast per-record loop, no rollback — drizzle-orm/neon-http has no interactive transactions, so this matches D1's semantics honestly
Neon Hyperdrive (Cloudflare)truedb.transaction() wraps the loop; any failure rolls back every write in the batch
Neon WebSocket (Node/Bun)truedb.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

connectionMode: 'hyperdrive' runs the feature database over a Cloudflare 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:

export default defineAction({
  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 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:

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).
  • WebSocket mode (deprecated) rejects webhooks, cross-tenant unsafe actions, and encrypted/sealed columns. Use connectionMode: 'hyperdrive' 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:

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:

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:

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:

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.

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

export const interviewScores = pgTable('interview_scores', {
  id: text('id').primaryKey(),
  interviewId: text('interview_id').notNull(),
  rating: integer('rating').notNull(),
});

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

FactorNeonD1
SQL dialectPostgreSQLSQLite
Searchtsvector, language dictionaries, GIN indexesFTS5 in custom SQL; generated Quickback ?search uses LIKE
Structured dataNative JSONB, arrays, rich Postgres typesJSON text with JSON path/operators; relational or JSON arrays
Security modelRLS + application-layerApplication-layer only
TransactionsHTTP batches; interactive transactions with HyperdriveAtomic statements/batches within D1's SQLite execution model
OperationsNeon HTTP is lightweight; Hyperdrive is optional for pooling/interactive transactionsNo connection strings or pooling
Best forPostgres-native, data-intensive, or high-concurrency applicationsCloudflare-native CRUD and conventional application workloads

See also

  • D1 — SQLite at the edge
  • Providers — All database provider options

On this page