Quickback Docs

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:

DatabaseBindingPurpose
AUTH_DBAUTH_DBBetter Auth tables (user, session, account, organization, member)
DBDBYour application data
FILES_DBFILES_DBFile metadata for R2 uploads
WEBHOOKS_DBWEBHOOKS_DBWebhook 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:

quickback/features/posts/posts.ts
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 ownership

Migrations

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

Migration 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-files

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

quickback/quickback.config.ts
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:

quickback/quickback.config.ts
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 role

Every 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 dev target must exist (it is what npm run dev selects).
  • 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 adopt environments and silently deploy production as a new script, with every Durable Object starting empty. Migrating an existing project? Set the production target's name to the name you already deploy under.
  • Every D1 role the primary config declares — auth, features, plus audit, webhooks, and files when enabled — needs its own databaseId per 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.
  • requiredSecrets and rateLimits must match the generated runtime inventory exactly, so adding a feature tells you which override changed.
  • workersDev: true and previewUrls: false must 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 bare domain shorthand for a single-hostname project). Quickback emits no top-level routes block here — Wrangler inherits that key, so a shared one would rebind production's hostname on a dev deploy. The unified quickback.{baseDomain} host is inferred per target as quickback-<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/:id

Filtering 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=desc

Why 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

ComponentEnforcementNotes
CRUD endpoints✅ Firewall auto-appliedAll generated routes enforce security
Actions✅ Firewall auto-appliedBoth standalone and record-based
Manual routes⚠️ Must apply firewallUse withFirewall helper

Why D1 is Secure

  1. No external database access - D1 can only be queried through your Worker. There's no connection string or external endpoint.
  2. Generated code enforces rules - All CRUD and Action endpoints automatically apply firewall, access, guards, and masking.
  3. 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

ScenarioNeonD1
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 queriesVia 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

  1. Use generated endpoints - Prefer CRUD and Actions over manual routes
  2. Always apply firewall - When writing manual routes, always use withFirewall
  3. Avoid raw SQL - Raw SQL bypasses application security; use Drizzle ORM
  4. 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.

NeedD1 approachChoose Neon/Postgres when…
Generated Quickback searchThe generated ?search API uses allowlisted LIKE semantics; custom actions can use FTS5Search is built around PostgreSQL tsvector, language dictionaries, GIN indexes, or an existing Postgres search design
Structured JSONJSON is stored as text with D1's JSON functions and path operatorsYou need native jsonb, GIN indexing, or extensive JSONB query/update operators
Booleans and arraysDrizzle maps booleans to SQLite integers; arrays can be modeled relationally or stored/queryable as JSONNative PostgreSQL booleans/arrays are part of the schema contract
Reporting and aggregationSQLite CTEs, window functions, joins, and aggregates cover conventional reportsYou need Postgres extensions, materialized views, specialized indexes, or a Postgres analytics ecosystem
IDsq.id() uses Quickback's provider-driven generation; SQLite INTEGER PRIMARY KEY is also available through Drizzle interopNative UUID/identity types and database-side defaults are a design requirement
Write profileA D1 database processes queries serially and is excellent for modest, short writesThe 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.

On this page