# 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](/define/schema).

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](/define/actions/scoped-db#reaching-better-auth-tables-authdb).

## Drizzle ORM Integration

Quickback uses [Drizzle ORM](https://orm.drizzle.team/) for type-safe database access:

```ts title="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](/define/schema).

The compiler generates Drizzle queries based on your security rules:

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

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

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

```toml
[[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:

```bash
wrangler d1 create my-app-features
wrangler d1 create my-app-auth
wrangler d1 create my-app-files
```

> [`quickback deploy`](/start/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`:

```typescript title="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`:

{/* doc-compile: skip — illustrative excerpt of one environment's `worker.bindings`; the surrounding `defineConfig` is shown in full above. */}
```typescript title="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:

```bash
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](/configure/domains#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](/platform/database/neon#named-cloudflare-deployment-environments),
> 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

```bash
# 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

```bash
# 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](/define/actions).

## Local Development

D1 works locally with Wrangler:

```bash
# 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](/platform/database/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

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

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

```ts
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](https://developers.cloudflare.com/d1/sql-api/sql-statements/), and
D1 supports JSON path extraction, mutation, `->` / `->>` operators, and array
expansion through
[`json_each`](https://developers.cloudflare.com/d1/sql-api/query-json/).
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](https://developers.cloudflare.com/d1/platform/limits/) in capacity
planning. Quickback supports both providers, so choosing Postgres for an
advanced application is a first-class architecture choice.
