Quickback Docs

Providers

Runtime, database, auth, and storage provider options for quickback.config.ts.

Providers configure which services your compiled backend targets.

Runtime Providers

The Quickback Stack is Cloudflare-only. cloudflare is the single supported runtime.

import { defineRuntime } from "@quickback/compiler";

providers: {
  runtime: defineRuntime("cloudflare"),
}

Runtime Options

OptionTypeDescription
compatibilityDatestringEmits Cloudflare compatibility_date in wrangler.toml.
placement.mode"smart" | "standard"Emits Cloudflare's [placement] block. Mutually exclusive with placement.region.
placement.regionstringPlacement hint, "<cloud>:<region>" (e.g. "aws:us-east-2"): runs the Worker next to a single-region database instead of next to the user. Set it to your Postgres region. Any other placement key is a compile error. Named environments emit it per target; override with environments.<name>.worker.placement.
observability.enabledbooleanEmits top-level Workers Logs persistence under [observability].
observability.headSamplingRatenumberTop-level Workers Logs sample rate from 0 to 1.
observability.logs.enabledbooleanEmits [observability.logs].
observability.logs.invocationLogsbooleanEnables or suppresses invocation logs.
observability.logs.destinationsstring[]Named log export destinations configured in Cloudflare.
observability.logs.headSamplingRatenumberWorkers Logs sample rate from 0 to 1.
observability.logs.persistbooleanKeeps exported logs in the Cloudflare dashboard.
observability.traces.enabledbooleanEmits [observability.traces].
observability.traces.destinationsstring[]Named trace export destinations configured in Cloudflare.
observability.traces.headSamplingRatenumberWorkers Traces sample rate from 0 to 1.
observability.traces.persistbooleanKeeps exported traces in the Cloudflare dashboard.
routes{ pattern, custom_domain? }[]Custom domain routes for the generated Worker.
providers: {
  runtime: defineRuntime("cloudflare", {
    compatibilityDate: "2026-05-13",
    placement: { mode: "smart" },
    observability: {
      enabled: true,
      logs: {
        enabled: true,
        invocationLogs: false,
        destinations: ["logs-prod"],
        headSamplingRate: 0.6,
      },
      traces: {
        enabled: true,
        destinations: ["otel-prod"],
        headSamplingRate: 0.05,
        persist: false,
      },
    },
  }),
}

Database Providers

Three provider names are supported — one SQLite target and one Postgres implementation on two hosts:

ProviderDescription
cloudflare-d1Cloudflare D1 (SQLite at the edge)
neonNeon serverless PostgreSQL (@neondatabase/serverless over HTTP, or postgres.js over Hyperdrive)
planetscale-postgresPlanetScale Postgres over a Cloudflare Hyperdrive binding — the same Postgres emitters as neon (Hyperdrive only)

planetscale — without the suffix — is the MySQL name and is not a registered provider; MySQL is not supported.

One database provider per app: auth tables and feature tables always share the selected database. On either Postgres provider, Better Auth uses its PostgreSQL/Drizzle adapter in the same Postgres database as your features; on cloudflare-d1, both use D1. A per-service map (database: { auth: ..., features: ... }) is not part of the config type (quickback check flags it) and is rejected at compile time — mixed database providers are not supported. On D1, auth still gets its own database by default (splitDatabases: true); authBinding and featuresBinding name the two bindings.

import { defineDatabase } from "@quickback/compiler";

providers: {
  database: defineDatabase("cloudflare-d1", {
    generateId: "uuid",              // default; "cuid" | "nanoid" | "short" | "prefixed" | "serial" | false
    allowClientIds: false,           // true: POST bodies MAY carry a strategy-shaped id
    validateIdParams: true,          // false: /:id is a plain string again (see the hazard below)
    namingConvention: "snake_case",   // "camelCase" | "snake_case"
    usePlurals: false,                // Auth table names: "users" vs "user"
  }),
}

Database Options

Options from splitDatabases down apply to cloudflare-d1 only — Neon uses a single Postgres database with no bindings or split mode (see Neon options below).

OptionTypeDefault (D1)Description
generateIdstring | false"uuid" (every provider)ID generation strategy — see ID Generation Options
allowClientIdsbooleanfalseAccept an optional client-supplied id on create, shaped like the generateId strategy — see Client-supplied IDs. Ignored under generateId: false (ids are already client-supplied and required) and "serial" / numeric PKs
validateIdParamsbooleantrueCheck a record route's :id against the table's id format and answer 400 INVALID_ID before any database work — see Malformed ids. Set false if any table holds rows whose ids don't match the current generateId shape (the strategy changed after rows were written, imported data, ids hand-minted by actions): with the check on, those rows answer 400 instead of the row. Tables whose format the compiler can't know (generateId: false, an authored id default) are never checked
namingConventionstring"snake_case"Column naming convention
usePluralsbooleanfalse (true on Neon / PlanetScale Postgres)Pluralize auth table names (e.g. user vs users). The default differs by provider — porting a D1 project to Postgres, set usePlurals: false explicitly to keep its table names
splitDatabasesbooleantrueSeparate auth and features databases (D1 only)
queryTimeoutMsnumber—Per-statement deadline for every D1 query the generated code runs (CRUD, actions, the auth adapter, cron/queue/email runners, files, audit). Unset = no deadline. D1 only — the Postgres drivers have their own timeout options. Past the deadline a read answers 503 DB_TIMEOUT and a write 500 DB_WRITE_OUTCOME_UNKNOWN; see Backend stalls and Database errors. The statement is not cancelled on the backend
readReplicationboolean | { header?: string }—Run each request's feature db on a D1 session so regional read replicas can serve it. GET/HEAD continue the client's session from the bookmark header (default x-d1-bookmark) or start first-unconstrained; other methods start first-primary. The response echoes the bookmark. D1 only; auth/audit/webhooks and cron/queue/email runners stay on the primary. Enable replication on the database itself in the Cloudflare dashboard — see Read replication
authBindingstring"AUTH_DB"Binding name for auth database
featuresBindingstring"DB"Binding name for features database
databaseIdstring—D1 database ID for the features database (used in generated wrangler.toml)
authDatabaseIdstring—D1 database ID for the auth database
authMigrations'managed' | 'external''managed''external' when another project owns the auth D1 (a sibling worker sharing one auth database): no db:migrate:auth:* script is emitted and db:migrate:* / deploy skip it. Deleting the step from package.json by hand does not stick — compiler-owned scripts converge on every compile
featuresDatabaseIdstring—Explicit features DB ID (falls back to databaseId)
filesDatabaseIdstring—D1 database ID for the files metadata database
webhooksDatabaseIdstring—D1 database ID for the webhooks database
auditBindingstring"AUDIT_DB"Binding name for the security audit database
auditDatabaseNamestring"{app}-audit"D1 database name for audit events
auditDatabaseIdstring—D1 database ID for audit events. Optional at compile time — a placeholder is emitted and quickback deploy / the Start broker create the database and splice the real id back
experimentalRemote{ auth?, features?, audit?, webhooks?, files? }{}Per-binding experimental_remote = true flag for wrangler dev (see below)
realtimeboolean | { enabled?, wsTicket? }falseEnables the Broadcaster Durable Object and optional resource-scoped ws-ticket auth (see below)
webhookAdminRolesstring[]["owner", "admin"]Org roles allowed to manage webhook endpoints

Neon Options

OptionTypeDefaultDescription
connectionMode"auto" | "http" | "websocket" | "hyperdrive""auto"How the generated runtime connects (hyperdrive enables interactive Postgres transactions on Workers)
hyperdrive.idstring—Required Cloudflare Hyperdrive configuration ID when connectionMode: "hyperdrive"
hyperdrive.localConnectionStringstring—Optional local-only Postgres connection used by Wrangler dev
hyperdrive.cached.idstring—Second Hyperdrive config with query caching on, bound as HYPERDRIVE_CACHED and connecting as the read-only quickback_reader role. Required when any resource declares read: { cache: "hyperdrive" } — see Cached public reads
hyperdrive.cached.localConnectionStringstring—The reader role's local URL for Wrangler dev; required whenever hyperdrive.localConnectionString is set
pooledbooleantrueUse the pooled Neon connection string (recommended for Workers)
usePluralsbooleantrueAuth table names (users, members, …). The D1 default is false — a project moving from D1 must set usePlurals: false to keep user, member, …

The Worker connects through a single DATABASE_URL secret — auth and feature queries share it. See Neon for connection modes, RLS, and the migration workflow.

PlanetScale Postgres Options

Same options, one transport: connectionMode accepts "auto" and "hyperdrive" only (omitted is "hyperdrive"), hyperdrive.id is required, and there is no DATABASE_URL Worker secret — the runtime reads env.HYPERDRIVE.connectionString. pooled does not apply; Hyperdrive is the pool. See PlanetScale Postgres for role bootstrap, the role.branch_id connection username, and the mandatory cache-disabled Hyperdrive config.

Audit Database

Cross-tenant unsafe actions (see Actions → Unsafe mode) write mandatory audit events. Those events land in a dedicated D1 database — separate from your features DB — so audit history stays isolated from application data and can't be tampered with through a normal CRUD route.

You don't need to do anything to enable it — the compiler wires up the binding automatically if your project declares any unsafe, PUBLIC, or hard-delete action. Leave auditDatabaseId unset until deploy: compile emits a placeholder, and quickback deploy (or the Start Cloudflare card) creates the D1 and writes the id back. Do not put an empty string in the config.

To override defaults:

providers: {
  database: defineDatabase("cloudflare-d1", {
    auditBinding: "MY_AUDIT_DB",            // default: AUDIT_DB
    auditDatabaseName: "my-app-audit-prod", // default: {app}-audit
    auditDatabaseId: "c3b8866b-cc4f-4e51-8382-0af244584d93",
  }),
}

The binding name must match what your Worker reads at runtime. If you don't declare any unsafe, PUBLIC, or hard-delete actions, no audit DB is provisioned and these options have no effect.

Setting Database IDs for Deployment

When deploying to production, set your D1 database IDs so the compiler generates a ready-to-deploy wrangler.toml:

providers: {
  database: defineDatabase("cloudflare-d1", {
    authDatabaseId: "fe6c77c4-2131-40be-a8fc-3649eef2a38c",
    databaseId: "bba22981-cb6e-43d6-bf32-4193513c60b1",
    filesDatabaseId: "c3b8866b-cc4f-4e51-8382-0af244584d93",
  }),
}

If these are omitted, the compiler uses local-dev placeholders and you'll need to manually edit wrangler.toml before deploying.

Sharing deployed D1 in dev (experimental_remote)

Wrangler can read or write the deployed D1 for a specific binding while wrangler dev runs locally. A common workflow: share the production auth DB in dev so real sessions and users work against the running local Worker, while feature writes stay sandboxed in the local SQLite shadow.

Quickback regenerates wrangler.toml on every compile, so flags like experimental_remote = true need to live in quickback.config.ts:

providers: {
  database: defineDatabase("cloudflare-d1", {
    authDatabaseId: "fe6c77c4-2131-40be-a8fc-3649eef2a38c",
    experimentalRemote: { auth: true },
  }),
}

Keys map to logical bindings, not the runtime binding string:

KeyBinding affected
authAUTH_DB (also applied to the files worker's auth binding)
featuresDB (or the legacy single-DB binding when splitDatabases: false)
auditAUDIT_DB
webhooksWEBHOOKS_DB
filesFILES_DB (also applied to the files worker)

Only true flips the flag on; false or omitted leaves the binding local. Off by default — only enable against databases you intend to share.

experimental_remote is a Wrangler dev feature. A flagged binding hits the deployed D1 from wrangler dev, so any write goes to production. Only enable for bindings you've decided are safe to read/write from local dev.

Realtime Broadcast Config

Set realtime: true to enable the generated Broadcaster Durable Object with the default organization-scoped routing model:

providers: {
  database: defineDatabase("cloudflare-d1", {
    realtime: true,
  }),
}

For resource-scoped tickets, use the object form. The mint gate is a discriminated access union — exactly one arm:

providers: {
  database: defineDatabase("cloudflare-d1", {
    realtime: {
      wsTicket: {
        access: { authzRole: "attendee" },   // or { roles: ["member+"] } or { fga: { relation, object } }
        requestField: "eventId",
        scopeTable: "events",
      },
    },
  }),
}

The broadcast surface mounts at /broadcast/v1 by default and /broadcast/v2 under contract.routes: "v2". The path is the only thing routes moves — frame format is unaffected.

wsTicket tells the compiler how to authorize /broadcast/v1/ws-ticket and what scope to stamp into the short-lived websocket ticket:

  • access: the mint gate — one of { roles: ["member+"] } (org-membership roles with hierarchy expansion; also verifies the scope row is in the caller's active org), { authzRole: "attendee" } (an authz role declared under authz.roles with a direct relationship { via: "..." }), or { fga: { relation: "viewer", object: "event:{id}" } } (an FGA relation on the object derived from the scope row). The legacy role: "attendee" string is the shorthand for { authzRole }.
  • requestField: the JSON body field the client posts to /broadcast/v1/ws-ticket
  • scopeTable: the parent resource table that defines the broadcast scope key

Instead of access, wsTicket may name a namespace (namespace: "eventScope") to authorize through that namespace's full multi-lane via — including a namespace declared by a feature area's _area.ts (the one root-config surface allowed to reference an area-declared name; it attaches the area's complete gate and is listed in the authz manifest under the owning area). See Namespace-backed tickets.

In other words: the declared gate proves access, and the scopeTable row defines the channel. For an attendee flow, guests (or RSVP) would be the relationship evidence and events would be the scopeTable; see Realtime for the full per-arm semantics.

Quickback keeps Better Auth as the front door, then mints its own 60-second websocket ticket after the authz check succeeds. The generated createRealtime() helper and Broadcaster share the same targeting model:

  • string target: historical organizationId shorthand
  • object target: { scopeKey?, organizationId?, userId? }

See Realtime for the client-side connection flow and helper examples.

usePlurals only affects auth tables generated by Better Auth (e.g. user vs users, session vs sessions). Feature table names come directly from your Drizzle schema definitions — whatever you pass to sqliteTable() or pgTable() is used as-is. The namingConvention setting applies to both auth and feature column names.

ID Generation Options

ValueDescriptionExample
"uuid" (default)Server generates UUID550e8400-e29b-41d4-a716-446655440000
"cuid"Server generates CUIDclh2v8k9g0000l508h5gx8j1a
"nanoid"Server generates nanoid (21-char, URL-safe)V1StGXR8_Z5jdHi6B-myT
"short"6-char alphanumeric (nanoid customAlphabet)a3F9xK
"prefixed"Prefixed ID from table nameroom_abc123
"serial"Database auto-increments1, 2, 3
falseClient provides ID (enables PUT/upsert)Any string

Leave generateId unset and every provider mints UUIDs, in the CRUD route and in the schema default an action's db.insert() falls back on alike.

Upgrading: the default changed from prefixed to uuid

Before this release an unset generateId minted prefixed ids on D1 (job_550e8400e29b41d4a716446655440000), and on every provider the CRUD route fell back to prefixed. Unset now means "uuid" everywhere. Existing rows keep their ids, so a table that was on the old default holds both shapes:

  • Old rows stay reachable. While generateId is unset, record routes' :id check accepts a UUID or any prefixed id the old default minted, and the OpenAPI spec declares no format on ids, so a strict client doesn't reject old ids.
  • New rows are UUIDs, including client-supplied ids under allowClientIds.
  • To keep minting prefixed ids, set generateId: "prefixed" explicitly.
  • Once every row is a UUID (or on a new project), set generateId: "uuid" explicitly. That is a promise about every row, so :id then accepts only a UUID and ids get format: "uuid" in OpenAPI.

Code that parses the <prefix>_ part of an id breaks on new rows either way.

"short" produces IDs with ~56.8 billion possible values (62^6). That's fine for small/scoped tables like rooms, invites, or share codes — but at roughly 238,000 rows you hit a 50% chance of collision (birthday bound). Don't use it for high-volume records.

"cuid", "nanoid", and "short" mint with an npm package: @paralleldrive/cuid2 for "cuid", and nanoid for the other two. The compiler adds the package to the generated package.json and imports it wherever it mints an id, so don't add it yourself, and don't import it in a table file (table files can't import it). Every server-side mint produces the shape in the table: the CRUD route, and the schema default that a db.insert() in an action falls back on. Older compilers backed the schema-side createId() with a crypto.randomUUID() polyfill, so a cuid table can hold UUID rows; record routes' :id check accepts both.

Auth Providers

ProviderDescription
better-authBetter Auth with plugins (default)
externalExternal auth via Cloudflare service binding
import { defineAuth } from "@quickback/compiler";

providers: {
  auth: defineAuth("better-auth", {
    session: {
      expiresInDays: 7,
      updateAgeInDays: 1,
    },
    rateLimit: {
      enabled: true,
      window: 60,
      max: 100,
    },
  }),
}

Better Auth Options

OptionTypeDescription
session.expiresInDaysnumberSession expiration in days
session.updateAgeInDaysnumberSession refresh interval in days
session.freshAgeInHoursnumberHow recent a sign-in must be for change-password / delete-account. Default 0.25 (15 minutes); 0 = always fresh
emailAndPassword.revokeSessionsOnPasswordResetbooleanTerminate every other session when a password is reset. Default true
operatorMfabooleanRequire a passkey or two-factor session for appmanager / sysadmin. Requires the passkey or twoFactor plugin. See Requiring a second factor
rateLimit.enabledbooleanEnable rate limiting
rateLimit.windownumberRate limit window in seconds
rateLimit.maxnumberMax requests per window
atomicAuthRoutes("emailOtpSignIn")[]Runs the exact email-OTP sign-in handler and before-email-otp-activate.ts admission hook in one Neon Hyperdrive transaction. Contract v2 only.
socialProvidersobjectSocial login providers (google, github, discord)
debugLogsbooleanEnable auth debug logging

Auth table naming (usePlurals, namingConvention) is inherited from your database provider config — you don't need to set it separately on the auth provider. usePlurals controls whether auth tables are named user/session or users/sessions. It does not affect feature tables, which use whatever name you define in your schema.

Better Auth Plugins

When features: ["organizations"] is set in your config, the compiler automatically enables organization-related plugins. Additional plugins can be configured:

PluginDescription
organizationMulti-tenant organizations
adminAdmin panel access
apiKeyAPI key authentication
anonymousAnonymous sessions
upgradeAnonymousConvert anonymous to full accounts
twoFactorTwo-factor authentication
passkeyWebAuthn passkey login
magicLinkEmail magic link login
emailOtpEmail one-time password
deviceAuthorizationDevice auth flow (CLI tools)
jwtJWT token support
openAPIOpenAPI spec generation

emailOtp also accepts an options object. Set expiresIn to a positive integer number of seconds to override Better Auth's OTP lifetime. The setting is transport-neutral and is emitted identically for Cloudflare Email and AWS SES:

plugins: { emailOtp: { expiresIn: 1200 } }

organization accepts Better Auth's own options too. invitationLimit caps pending invitations per organization (Better Auth defaults to 100):

plugins: { organization: { invitationLimit: 10 } }

organizationHooks is compiler-owned and rejected from config; gate or reroute invitations with hooks/before-invitation-create.ts instead.

Storage Providers

import { defineStorage, defineFileStorage } from "@quickback/compiler";

providers: {
  storage: defineStorage("cloudflare-kv", {
    binding: "KV_STORE",
    namespaceId: "7ed0f824a8c54c388713d9fdf63cd004",
  }),
  fileStorage: defineFileStorage("cloudflare-r2", {
    binding: "FILES",
    maxFileSize: "10mb",
    allowedTypes: ["image/png", "image/jpeg", "application/pdf"],
    publicDomain: "files.example.com",
  }),
}

The KV namespaceId can be set on the storage provider config, or as kvId on the auth provider config (since KV is primarily used for auth sessions). The compiler checks both locations.

Storage Types

NameDescription
cloudflare-kvKey-value storage (Cloudflare KV)
cloudflare-r2Object storage (Cloudflare R2)
memoryIn-memory storage (development only)

These three names are the complete registry. Any other name — kv, r2, redis — fails the compile with Unknown storage provider.

File Storage Options

OptionTypeDescription
bindingstringR2 bucket binding name
maxFileSizestringMaximum file size (e.g. "10mb")
allowedTypesstring[]Allowed MIME types
publicDomainstringPublic domain for file URLs
userScopedBucketsstring[]Buckets stored under users/{userId}/{bucket}/{path} instead of being org-scoped (default: ["avatars"])

On this page