D1 Database
Cloudflare D1 is managed, serverless SQLite on Cloudflare's network. It is Quickback's zero-configuration default for Cloudflare deployments and a strong fit for CRUD-heavy SaaS products, content catalogs, internal tools, and workflows that benefit from simple operations.
What is D1?
D1 is Cloudflare's serverless SQL database built on SQLite:
- Cloudflare-native — queried directly from your Worker with no public connection string
- SQLite SQL — including D1's supported FTS5, JSON, and math extensions
- Zero connection management — no pool or database server to operate
- Managed durability — built-in disaster recovery, with global read replication available through D1 Sessions when a workload needs it
Multi-Database Pattern
Quickback generates separate D1 databases for different concerns:
| Database | Binding | Purpose |
|---|---|---|
AUTH_DB | AUTH_DB | Better Auth tables (user, session, account, organization, member) |
DB | DB | Your application data |
FILES_DB | FILES_DB | File metadata for R2 uploads |
WEBHOOKS_DB | WEBHOOKS_DB | Webhook delivery tracking |
This separation provides:
- Independent scaling - Auth traffic doesn't affect app queries
- Isolation - Auth schema changes don't touch your data
- Clarity - Clear ownership of each database
SQLite foreign keys cannot cross those databases. Feature columns must never
.references() Better Auth tables (user / users / organization /
member). Store a Better Auth id as plain text (userId: q.text().required()).
A .references(() => users.id) on a feature table makes deploy fail with
Critical command failed: Generate features database migrations. See
Schema → References.
The scoped feature db never includes those tables — query them from an
action with c.get("authDb"), not from a trigger. On D1 the membership table
is member, not organization_membership. See
Scoped DB.
Drizzle ORM Integration
Quickback uses Drizzle ORM for type-safe database access:
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
import { defineTable } from "@quickback/compiler";
export const posts = sqliteTable("posts", {
id: text("id").primaryKey(),
title: text("title").notNull(),
content: text("content"),
ownerId: text("owner_id").notNull(),
// ── quickback:audit (compiler-managed — edits are validated, not merged) ──
createdAt: text("created_at").notNull().default('1970-01-01T00:00:00.000Z').$defaultFn(() => new Date().toISOString()),
modifiedAt: text("modified_at").notNull().default('1970-01-01T00:00:00.000Z').$defaultFn(() => new Date().toISOString()).$onUpdate(() => new Date().toISOString()),
createdBy: text("created_by"),
modifiedBy: text("modified_by"),
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
export default defineTable(posts, {
read: { access: { roles: ["member"] } },
});The six managed columns must be declared literally when you author a table in
raw Drizzle — ...q.audit() / ...q.softDelete() are q-DSL spreads and are not
expanded in Drizzle source. Because this table declares no crud.delete, it
soft-deletes, so deletedAt / deletedBy are required as well. The equivalent
feature() form is shorter; see Schema.
The compiler generates Drizzle queries based on your security rules:
// Generated query with firewall applied
const result = await db
.select()
.from(posts)
.where(eq(posts.ownerId, userId)); // Firewall injects ownershipMigrations
Quickback generates migrations automatically at compile time based on your schema changes:
# Compile your project (generates migrations)
quickback compile
# Apply migrations locally
wrangler d1 migrations apply DB --local
# Apply to production D1
wrangler d1 migrations apply DB --remoteMigration files are generated in quickback/drizzle/ and version-controlled with your code. You never need to manually generate migrations—just define your schema and compile.
Generation runs locally: quickback compile invokes your project's own
drizzle-kit against migration state that never leaves your machine. Note that
generating a migration is not the same as applying one — _journal.json records
which migration files exist, while D1's own d1_migrations table records which
have actually run against a given database. wrangler d1 migrations apply is
what moves a migration from the first list to the second.
wrangler.toml Bindings
The compiler generates D1 bindings in wrangler.toml automatically. To get production-ready IDs in the output, set them in your quickback.config.ts:
providers: {
database: defineDatabase("cloudflare-d1", {
databaseId: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", // Features DB → binding "DB"
authDatabaseId: "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", // Auth DB → binding "AUTH_DB"
filesDatabaseId: "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz", // Files DB → binding "FILES_DB"
}),
}This generates:
[[d1_databases]]
binding = "AUTH_DB"
database_name = "my-app-auth"
database_id = "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy"
[[d1_databases]]
binding = "DB"
database_name = "my-app-features"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
[[d1_databases]]
binding = "FILES_DB"
database_name = "my-app-files"
database_id = "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz"If database IDs are omitted, the compiler uses local-dev placeholders — you'll need to replace them manually before deploying.
Create databases via Wrangler:
wrangler d1 create my-app-features
wrangler d1 create my-app-auth
wrangler d1 create my-app-filesquickback deploy creates all three databases, writes the real ids
back into your config, and applies migrations in one command. Reach for the manual
wrangler d1 create dance only when you need to control provisioning yourself.
Named Cloudflare deployment environments
The bindings above describe a single deploy target. A project that needs
isolated dev / staging / production Workers — each with its own D1
databases — declares them under the top-level environments block instead of
hand-maintaining a second wrangler.dev.toml:
export default defineConfig({
name: "my-app",
providers: {
runtime: defineRuntime("cloudflare"),
database: defineDatabase("cloudflare-d1", {}),
auth: defineAuth("better-auth"),
},
environments: {
dev: {
// The Wrangler worker name. Required — never derived (see below).
name: "my-app-dev",
domain: "api-dev.example.com",
worker: {
env: "dev",
workersDev: true,
previewUrls: false,
vars: { BETTER_AUTH_URL: "https://api-dev.example.com" },
requiredSecrets: ["BETTER_AUTH_SECRET"],
bindings: {
d1: {
auth: { databaseId: "…", databaseName: "my-app-auth-dev" },
features: { databaseId: "…", databaseName: "my-app-features-dev" },
},
},
},
},
production: {
// An EXISTING project must restate the name it already deploys under.
name: "my-app",
domain: "api.example.com",
worker: {
env: "production",
workersDev: true,
previewUrls: false,
vars: { BETTER_AUTH_URL: "https://api.example.com" },
requiredSecrets: ["BETTER_AUTH_SECRET"],
bindings: {
d1: {
auth: { databaseId: "…", databaseName: "my-app-auth-prod" },
features: { databaseId: "…", databaseName: "my-app-features-prod" },
},
},
},
},
},
});Each entry emits one [env.<name>] stanza. Wrangler does not inherit
bindings or vars into a named environment, so Quickback repeats the full set
per target: [[env.<name>.d1_databases]] for every role the primary config
declares, plus vars, required secrets, KV, rate-limits, Cloudflare Email,
queue producers/consumers, R2 buckets, and the [assets] block. Binding names
and migrations_dir inherit from the primary config so runtime code reads by
binding name unchanged across environments; only the stateful identifiers —
database_id, queue names, bucket names (and optionally database_name) —
are per-environment.
Queues and R2 buckets per target
Queue names and R2 bucket names are stateful, so repeating the primary value
would point a dev deploy at the production queue and the production bucket.
Each target names its own under worker.bindings:
bindings: {
d1: {
auth: { databaseId: "…", databaseName: "my-app-auth-dev" },
features: { databaseId: "…", databaseName: "my-app-features-dev" },
// `files` too, when file storage is `managed: true`
},
queues: {
// One producer per binding the primary config emits — matched by binding.
producers: [{ binding: "WEBHOOKS_QUEUE", queue: "my-app-webhooks-queue-dev" }],
// Consumers have no binding name, so list them in the order the primary
// config emits them (the compile error prints that order).
consumers: [
{
queue: "my-app-webhooks-queue-dev",
maxBatchSize: 10,
deadLetterQueue: "my-app-webhooks-dlq-dev",
},
{ queue: "my-app-webhooks-dlq-dev", maxBatchSize: 1 },
],
},
// One entry per bucket the primary config emits — matched by binding.
r2Buckets: [{ binding: "R2_BUCKET", bucketName: "my-app-dev" }],
}While environments is present, the primary top-level binding blocks are
suppressed and there is no bare deploy script — every deployment names its
target:
npm run deploy:dev # wrangler deploy --env dev
npm run deploy:production # wrangler deploy --env production
npm run db:migrate:dev:auth:remote # …apply my-app-auth-dev --remote --env dev
npm run db:migrate:dev:remote # fan-out over every declared roleEvery migration script carries --env. With the top-level blocks suppressed,
a bare database name has no scope to resolve in and wrangler fails with
Couldn't find a D1 DB with the name or binding '<name>' — so a hand-run
wrangler d1 migrations apply needs the flag too.
The contract fails closed:
- Better Auth is required, and a logical
devtarget must exist (it is whatnpm run devselects). - Every target declares its own
name— the Wrangler worker name — and no two share one. Nothing is derived. The name is the deployed script's identity: Durable Object namespaces and the[[migrations]]ledger belong to it, so a derived default would let an existing project adoptenvironmentsand silently deploy production as a new script, with every Durable Object starting empty. Migrating an existing project? Set the production target'snameto the name you already deploy under. - Every D1 role the primary config declares —
auth,features, plusaudit,webhooks, andfileswhen enabled — needs its owndatabaseIdper environment. A missing role is a compile error, not a Worker that boots without a database. - Every generated queue and R2 bucket needs a per-environment entry too. A missing one (the DLQ is the easy one to forget) is a compile error.
- The same
database_id, queue name, or bucket name cannot appear in two environments; sharing a database would race the migration ledger, and sharing a queue or bucket would hand a dev deploy production state. requiredSecretsandrateLimitsmust match the generated runtime inventory exactly, so adding a feature tells you which override changed.workersDev: trueandpreviewUrls: falsemust be explicit on each target.- Every custom domain the config generates must be claimed per target via
domains({ api: "api-dev.example.com" }, or the baredomainshorthand for a single-hostname project). Quickback emits no top-levelroutesblock here — Wrangler inherits that key, so a shared one would rebind production's hostname on a dev deploy. The unifiedquickback.{baseDomain}host is inferred per target asquickback-<env>.{baseDomain}. See Named Environments.
Set secrets per target: wrangler secret put BETTER_AUTH_SECRET --env dev.
environments may live at the top level (preferred) or under
providers.database.environments (the original placement, still accepted).
Declaring it in both places is a compile error. The same block powers
Neon Hyperdrive projects,
which swap bindings.d1 for bindings.hyperdrive and add a Neon branch per
target.
Accessing Data via API
All data access in Quickback goes through the generated API endpoints—never direct database queries. This ensures security rules (firewall, access, guards, masking) are always enforced.
CRUD Operations
# List posts (firewall automatically filters by ownership)
GET /api/v1/posts
# Get a single post
GET /api/v1/posts/:id
# Create a post (guards validate allowed fields)
POST /api/v1/posts
{ "title": "Hello World", "content": "..." }
# Update a post (guards validate updatable fields)
PATCH /api/v1/posts/:id
{ "title": "Updated Title" }
# Delete a post
DELETE /api/v1/posts/:idFiltering and Pagination
# Filter by field
GET /api/v1/posts?status=published
# Pagination
GET /api/v1/posts?limit=10&offset=20
# Sort
GET /api/v1/posts?sort=createdAt&order=descWhy No Direct Database Access?
Direct database queries bypass Quickback's security layers:
- Firewall - Data isolation by user/org/team
- Access - Role-based permissions
- Guards - Field-level create/update restrictions
- Masking - Sensitive data redaction
Always use the API endpoints. For custom business logic, use Actions.
Local Development
D1 works locally with Wrangler:
# Start local dev server with D1
wrangler dev
# D1 data persists in .wrangler/state/Local D1 uses SQLite files in .wrangler/state/v3/d1/, which you can inspect with any SQLite client.
Security Architecture
For all Quickback-generated code, D1's application-layer security provides equivalent protection to the database-level RLS Quickback emits on Neon. The key difference is where enforcement happens — and this matters if you write custom routes.
How Security Works
| Component | Enforcement | Notes |
|---|---|---|
| CRUD endpoints | ✅ Firewall auto-applied | All generated routes enforce security |
| Actions | ✅ Firewall auto-applied | Both standalone and record-based |
| Manual routes | ⚠️ Must apply firewall | Use withFirewall helper |
Why D1 is Secure
- No external database access - D1 can only be queried through your Worker. There's no connection string or external endpoint.
- Generated code enforces rules - All CRUD and Action endpoints automatically apply firewall, access, guards, and masking.
- Single entry point - Every request flows through your API where security is enforced.
Unlike Neon, where PostgreSQL RLS provides database-level enforcement (protecting data even if application code has bugs), D1's security comes from architecture: the database is inaccessible except through your Worker, and all generated routes apply the four security pillars. The trade-off is that custom routes you write outside Quickback must manually apply security — there is no database-level safety net.
Comparison with Neon RLS
| Scenario | Neon | D1 |
|---|---|---|
| CRUD endpoints | ✅ Secure (RLS + App) | ✅ Secure (App) |
| Actions | ✅ Secure (RLS + App) | ✅ Secure (App) |
| Manual routes | ✅ RLS still protects | ⚠️ Must apply firewall |
| External DB access | ⚠️ Possible with credentials | ✅ Not possible |
| Dashboard queries | Via the Neon console | ⚠️ Admin only (audit logged) |
Writing Manual Routes
If you write custom routes outside of Quickback compilation (e.g., custom reports, integrations), use the generated withFirewall helper to ensure security:
import { withFirewall } from '../features/invoices/resource';
app.get('/reports/monthly', async (c) => {
return withFirewall(c, async (ctx, firewall) => {
const results = await db.select()
.from(invoices)
.where(firewall);
return c.json(results);
});
});The withFirewall helper:
- Validates authentication
- Builds the correct WHERE conditions for the current user/org
- Returns 401 if not authenticated
Best Practices
- Use generated endpoints - Prefer CRUD and Actions over manual routes
- Always apply firewall - When writing manual routes, always use
withFirewall - Avoid raw SQL - Raw SQL bypasses application security; use Drizzle ORM
- Review custom code - Manual routes should be code-reviewed for security
Choose by workload, not by a limitations checklist
D1 is more capable than a simple LIKE-only store. Cloudflare documents
FTS5 full-text search and the JSON
extension, and
D1 supports JSON path extraction, mutation, -> / ->> operators, and array
expansion through
json_each.
SQLite also supports CTEs and window functions. The important distinction is
whether your application wants SQLite's model or PostgreSQL's ecosystem.
| Need | D1 approach | Choose Neon/Postgres when… |
|---|---|---|
| Generated Quickback search | The generated ?search API uses allowlisted LIKE semantics; custom actions can use FTS5 | Search is built around PostgreSQL tsvector, language dictionaries, GIN indexes, or an existing Postgres search design |
| Structured JSON | JSON is stored as text with D1's JSON functions and path operators | You need native jsonb, GIN indexing, or extensive JSONB query/update operators |
| Booleans and arrays | Drizzle maps booleans to SQLite integers; arrays can be modeled relationally or stored/queryable as JSON | Native PostgreSQL booleans/arrays are part of the schema contract |
| Reporting and aggregation | SQLite CTEs, window functions, joins, and aggregates cover conventional reports | You need Postgres extensions, materialized views, specialized indexes, or a Postgres analytics ecosystem |
| IDs | q.id() uses Quickback's provider-driven generation; SQLite INTEGER PRIMARY KEY is also available through Drizzle interop | Native UUID/identity types and database-side defaults are a design requirement |
| Write profile | A D1 database processes queries serially and is excellent for modest, short writes | The app needs sustained concurrent writes or interactive multi-step transactions |
The practical rule is simple:
- Start with D1 for conventional application data, low operational overhead, and Cloudflare-native deployment.
- Start with Neon/Postgres when advanced SQL and data types are part of the product architecture—not as a workaround after launch. Neon HTTP remains the lightweight Cloudflare path; add Hyperdrive only when the application needs interactive transactions or connection pooling.
Keep Cloudflare's current D1 platform limits in capacity planning. Quickback supports both providers, so choosing Postgres for an advanced application is a first-class architecture choice.