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
| Option | Type | Description |
|---|---|---|
compatibilityDate | string | Emits Cloudflare compatibility_date in wrangler.toml. |
placement.mode | "smart" | "standard" | Emits Cloudflare's [placement] block. Mutually exclusive with placement.region. |
placement.region | string | Placement 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.enabled | boolean | Emits top-level Workers Logs persistence under [observability]. |
observability.headSamplingRate | number | Top-level Workers Logs sample rate from 0 to 1. |
observability.logs.enabled | boolean | Emits [observability.logs]. |
observability.logs.invocationLogs | boolean | Enables or suppresses invocation logs. |
observability.logs.destinations | string[] | Named log export destinations configured in Cloudflare. |
observability.logs.headSamplingRate | number | Workers Logs sample rate from 0 to 1. |
observability.logs.persist | boolean | Keeps exported logs in the Cloudflare dashboard. |
observability.traces.enabled | boolean | Emits [observability.traces]. |
observability.traces.destinations | string[] | Named trace export destinations configured in Cloudflare. |
observability.traces.headSamplingRate | number | Workers Traces sample rate from 0 to 1. |
observability.traces.persist | boolean | Keeps 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:
| Provider | Description |
|---|---|
cloudflare-d1 | Cloudflare D1 (SQLite at the edge) |
neon | Neon serverless PostgreSQL (@neondatabase/serverless over HTTP, or postgres.js over Hyperdrive) |
planetscale-postgres | PlanetScale 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).
| Option | Type | Default (D1) | Description |
|---|---|---|---|
generateId | string | false | "uuid" (every provider) | ID generation strategy — see ID Generation Options |
allowClientIds | boolean | false | Accept 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 |
validateIdParams | boolean | true | Check 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 |
namingConvention | string | "snake_case" | Column naming convention |
usePlurals | boolean | false (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 |
splitDatabases | boolean | true | Separate auth and features databases (D1 only) |
queryTimeoutMs | number | — | 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 |
readReplication | boolean | { 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 |
authBinding | string | "AUTH_DB" | Binding name for auth database |
featuresBinding | string | "DB" | Binding name for features database |
databaseId | string | — | D1 database ID for the features database (used in generated wrangler.toml) |
authDatabaseId | string | — | 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 |
featuresDatabaseId | string | — | Explicit features DB ID (falls back to databaseId) |
filesDatabaseId | string | — | D1 database ID for the files metadata database |
webhooksDatabaseId | string | — | D1 database ID for the webhooks database |
auditBinding | string | "AUDIT_DB" | Binding name for the security audit database |
auditDatabaseName | string | "{app}-audit" | D1 database name for audit events |
auditDatabaseId | string | — | 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) |
realtime | boolean | { enabled?, wsTicket? } | false | Enables the Broadcaster Durable Object and optional resource-scoped ws-ticket auth (see below) |
webhookAdminRoles | string[] | ["owner", "admin"] | Org roles allowed to manage webhook endpoints |
Neon Options
| Option | Type | Default | Description |
|---|---|---|---|
connectionMode | "auto" | "http" | "websocket" | "hyperdrive" | "auto" | How the generated runtime connects (hyperdrive enables interactive Postgres transactions on Workers) |
hyperdrive.id | string | — | Required Cloudflare Hyperdrive configuration ID when connectionMode: "hyperdrive" |
hyperdrive.localConnectionString | string | — | Optional local-only Postgres connection used by Wrangler dev |
hyperdrive.cached.id | string | — | 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.localConnectionString | string | — | The reader role's local URL for Wrangler dev; required whenever hyperdrive.localConnectionString is set |
pooled | boolean | true | Use the pooled Neon connection string (recommended for Workers) |
usePlurals | boolean | true | Auth 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:
| Key | Binding affected |
|---|---|
auth | AUTH_DB (also applied to the files worker's auth binding) |
features | DB (or the legacy single-DB binding when splitDatabases: false) |
audit | AUDIT_DB |
webhooks | WEBHOOKS_DB |
files | FILES_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/v1by default and/broadcast/v2undercontract.routes: "v2". The path is the only thingroutesmoves — 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 underauthz.roleswith a direct relationship{ via: "..." }), or{ fga: { relation: "viewer", object: "event:{id}" } }(an FGA relation on the object derived from the scope row). The legacyrole: "attendee"string is the shorthand for{ authzRole }.requestField: the JSON body field the client posts to/broadcast/v1/ws-ticketscopeTable: 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
organizationIdshorthand - 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
| Value | Description | Example |
|---|---|---|
"uuid" (default) | Server generates UUID | 550e8400-e29b-41d4-a716-446655440000 |
"cuid" | Server generates CUID | clh2v8k9g0000l508h5gx8j1a |
"nanoid" | Server generates nanoid (21-char, URL-safe) | V1StGXR8_Z5jdHi6B-myT |
"short" | 6-char alphanumeric (nanoid customAlphabet) | a3F9xK |
"prefixed" | Prefixed ID from table name | room_abc123 |
"serial" | Database auto-increments | 1, 2, 3 |
false | Client 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
generateIdis unset, record routes':idcheck accepts a UUID or any prefixed id the old default minted, and the OpenAPI spec declares noformaton 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:idthen accepts only a UUID and ids getformat: "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
| Provider | Description |
|---|---|
better-auth | Better Auth with plugins (default) |
external | External 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
| Option | Type | Description |
|---|---|---|
session.expiresInDays | number | Session expiration in days |
session.updateAgeInDays | number | Session refresh interval in days |
session.freshAgeInHours | number | How recent a sign-in must be for change-password / delete-account. Default 0.25 (15 minutes); 0 = always fresh |
emailAndPassword.revokeSessionsOnPasswordReset | boolean | Terminate every other session when a password is reset. Default true |
operatorMfa | boolean | Require a passkey or two-factor session for appmanager / sysadmin. Requires the passkey or twoFactor plugin. See Requiring a second factor |
rateLimit.enabled | boolean | Enable rate limiting |
rateLimit.window | number | Rate limit window in seconds |
rateLimit.max | number | Max 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. |
socialProviders | object | Social login providers (google, github, discord) |
debugLogs | boolean | Enable 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:
| Plugin | Description |
|---|---|
organization | Multi-tenant organizations |
admin | Admin panel access |
apiKey | API key authentication |
anonymous | Anonymous sessions |
upgradeAnonymous | Convert anonymous to full accounts |
twoFactor | Two-factor authentication |
passkey | WebAuthn passkey login |
magicLink | Email magic link login |
emailOtp | Email one-time password |
deviceAuthorization | Device auth flow (CLI tools) |
jwt | JWT token support |
openAPI | OpenAPI 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
| Name | Description |
|---|---|
cloudflare-kv | Key-value storage (Cloudflare KV) |
cloudflare-r2 | Object storage (Cloudflare R2) |
memory | In-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
| Option | Type | Description |
|---|---|---|
binding | string | R2 bucket binding name |
maxFileSize | string | Maximum file size (e.g. "10mb") |
allowedTypes | string[] | Allowed MIME types |
publicDomain | string | Public domain for file URLs |
userScopedBuckets | string[] | Buckets stored under users/{userId}/{bucket}/{path} instead of being org-scoped (default: ["avatars"]) |