# Batch Operations

Batch operations let you create, update, upsert, or delete multiple records in a single request. They are **auto-enabled** when the corresponding write operation exists in your definition.

## Available Endpoints

| Endpoint | Method | Auto-enabled When |
|----------|--------|-------------------|
| `/{resource}/batch` | `POST` | `create` exists |
| `/{resource}/batch` | `PATCH` | `update` exists |
| `/{resource}/batch` | `DELETE` | `delete` exists |
| `/{resource}/batch` | `PUT` | `upsert` exists |

To disable a batch operation, set it to `false` in your definition:

```typescript
create: {
  access: { roles: ['hiring-manager'] },
  batch: false,  // Disable batch create
}
```

## Batch Create

```
POST /api/v1/{resource}/batch
```

### Request

```json
{
  "records": [
    { "candidateId": "cand_101", "jobId": "job_201", "stage": "applied", "notes": "Strong frontend background" },
    { "candidateId": "cand_101", "jobId": "job_202", "stage": "applied", "notes": "Also interested in backend role" },
    { "candidateId": "cand_102", "jobId": "job_201", "stage": "applied", "notes": "Referred by employee" }
  ],
  "options": {
    "failFast": false
  }
}
```

### Response (201 or 207)

```json
{
  "success": [
    { "id": "app_abc1", "candidateId": "cand_101", "jobId": "job_201", "stage": "applied", "createdAt": "2025-01-15T10:00:00Z" },
    { "id": "app_abc3", "candidateId": "cand_102", "jobId": "job_201", "stage": "applied", "createdAt": "2025-01-15T10:00:00Z" }
  ],
  "errors": [
    {
      "index": 1,
      "record": { "candidateId": "cand_101", "jobId": "job_202", "stage": "applied", "notes": "Also interested in backend role" },
      "error": {
        "error": "Duplicate value",
        "layer": "database",
        "code": "DB_UNIQUE_VIOLATION",
        "details": { "column": "candidateId" },
        "hint": "A record with this \"candidateId\" already exists"
      }
    }
  ],
  "meta": {
    "total": 3,
    "succeeded": 2,
    "failed": 1,
    "failFast": false,
    "transactional": true
  }
}
```

- **201** — All records created successfully
- **207** — Partial success (some errors, some successes)

`POST /batch` uses a single bulk `INSERT`, so `meta.transactional` is `true` on every provider — the database guarantees the insert is atomic regardless of the `failFast` flag.

Fields applied automatically to each record:
- ID generation (UUID, prefixed, etc.)
- Ownership fields (`organizationId`, `ownerId`, `createdBy`)
- Audit fields (`createdAt`, `modifiedAt`)
- Default values and computed fields

## Batch Update

```
PATCH /api/v1/{resource}/batch
```

Every record **must include an `id` field**.

### Request

```json
{
  "records": [
    { "id": "app_abc1", "notes": "Passed phone screen, schedule onsite" },
    { "id": "app_abc2", "notes": "Moved to technical interview round" },
    { "id": "app_xyz9", "notes": "Hiring manager approved offer" }
  ],
  "options": {
    "failFast": false
  }
}
```

### Response (200 or 207)

```json
{
  "success": [
    { "id": "app_abc1", "notes": "Passed phone screen, schedule onsite", "modifiedAt": "2025-01-15T11:00:00Z" },
    { "id": "app_abc2", "notes": "Moved to technical interview round", "modifiedAt": "2025-01-15T11:00:00Z" }
  ],
  "errors": [
    {
      "index": 2,
      "record": { "id": "app_xyz9", "notes": "Hiring manager approved offer" },
      "error": {
        "error": "Record not found or not accessible",
        "layer": "firewall",
        "code": "FIREWALL_NOT_FOUND",
        "details": { "id": "app_xyz9" },
        "hint": "Check the record ID and your organization membership"
      }
    }
  ],
  "meta": {
    "total": 3,
    "succeeded": 2,
    "failed": 1,
    "failFast": false,
    "transactional": false
  }
}
```

Records that don't exist or aren't accessible through the firewall return
`FIREWALL_NOT_FOUND` — or the opaque `NOT_FOUND` when the resource sets
`firewallErrorMode: 'hide'`. Guard rules (immutable, protected, not-updatable
fields) are checked per record.

### Missing IDs

If any records are missing the `id` field, the entire request is rejected with
a Problem Details document:

```json
{
  "type": "https://quickback.dev/problems/batch-missing-ids",
  "title": "Batch missing ids",
  "status": 400,
  "detail": "Records missing required ID field",
  "layer": "validation",
  "code": "BATCH_MISSING_IDS",
  "details": { "indices": [0, 2] },
  "hint": "All records must include an ID field for batch update/upsert operations."
}
```

## Batch Delete

```
DELETE /api/v1/{resource}/batch
```

### Request

```json
{
  "ids": ["app_abc1", "app_abc2", "app_abc3"],
  "options": {
    "failFast": false
  }
}
```

Note: Batch delete uses an `ids` array (not `records`).

### Response (200 or 207)

**Soft delete** (default):

```json
{
  "success": [
    { "id": "app_abc1", "candidateId": "cand_101", "deletedAt": "2025-01-15T12:00:00Z" },
    { "id": "app_abc2", "candidateId": "cand_103", "deletedAt": "2025-01-15T12:00:00Z" }
  ],
  "errors": [],
  "meta": {
    "total": 2,
    "succeeded": 2,
    "failed": 0,
    "failFast": false,
    "transactional": true
  }
}
```

`DELETE /batch` issues a single bulk `UPDATE … WHERE id IN (...)` (soft delete) or `DELETE … WHERE id IN (...)` (hard delete), so `meta.transactional` is `true` on every provider regardless of `failFast`.

**Hard delete**: Returns objects with only the `id` field (record data is deleted).

## Batch Upsert

```
PUT /api/v1/{resource}/batch
```

Inserts new records or updates existing ones. Every record must include an `id` field.

**Note:** Batch upsert is only available when `generateId` is set to `false` (user-provided IDs) in your configuration.

### Request

```json
{
  "records": [
    { "id": "app_001", "candidateId": "cand_101", "jobId": "job_201", "stage": "screening", "notes": "Updated after phone screen" },
    { "id": "app_002", "candidateId": "cand_104", "jobId": "job_203", "stage": "applied", "notes": "New application from referral" }
  ],
  "options": {
    "failFast": false
  }
}
```

### Response (201 or 207)

The compiler checks which IDs already exist and splits the batch into creates and updates. Create records get ownership and default fields; update records get only `modifiedAt`. Under `failFast: true`, both the bulk insert and the per-record updates run inside the same transaction — a failed update rolls back the inserts too on tx-supporting providers.

## Fail-Fast Mode

By default, batch operations use **partial success** mode — each record is processed independently, and failures don't affect other records.

Set `"failFast": true` to stop on the first error:

```json
{
  "records": [...],
  "options": {
    "failFast": true
  }
}
```

### Fail-Fast Failure Response (400)

```json
{
  "error": "Batch failed at index 1",
  "layer": "validation",
  "code": "BATCH_FAILFAST_STOPPED",
  "details": {
    "failedAt": 1,
    "reason": "Database insert failed",
    "errorDetails": { "reason": "UNIQUE constraint failed" }
  }
}
```

## Transactional Semantics

The behavior of `failFast: true` depends on whether your database provider —
and, for Neon, its **connection mode** — supports interactive transactions.
The response's `meta.transactional` flag tells you which guarantee you
actually got — inspect it in clients that care about rollback.

Neon is transactional **only over websocket** (`connectionMode: 'websocket'`,
the default on Node/Bun runtimes). Over HTTP — the default on Cloudflare
Workers — the `drizzle-orm/neon-http` driver has no `db.transaction()` (it
throws at runtime), so Neon-over-HTTP batches behave exactly like D1:
fail-fast per-record loop, `meta.transactional: false`.

### Per-Provider Matrix

| Endpoint | Provider | `failFast` | `meta.transactional` | Behavior |
|----------|----------|-----------|----------------------|----------|
| `PATCH /batch` (update) | tx-supporting (postgres, neon over websocket) | `true` | `true` | `db.transaction()` wraps the per-record loop. Any failure rolls back ALL writes in the batch. |
| `PATCH /batch` (update) | (any) | `false` | `false` | Per-record loop, partial success. Each record commits independently. |
| `PATCH /batch` (update) | cloudflare-d1, neon over http (Cloudflare default) | `true` | `false` | Fail-fast loop without rollback — the driver has no `db.transaction`. Records before the failure stay committed. |
| `PUT /batch` (upsert) | tx-supporting (postgres, neon over websocket) | `true` | `true` | Bulk insert + per-record update share one transaction. Any failure rolls back both. |
| `PUT /batch` (upsert) | cloudflare-d1, neon over http (Cloudflare default) | `true` | `false` | Bulk insert commits; per-record update loop is fail-fast without rollback. |
| `POST /batch` (create) | (any) | (any) | `true` | Single bulk `INSERT` — atomic at the DB level. |
| `DELETE /batch` | (any) | (any) | `true` | Single bulk `UPDATE` (soft) or `DELETE` (hard) — atomic at the DB level. |
| [Changeset](/define/changesets) (`application/vnd.quickback.changeset+json`) | tx-supporting (postgres, neon over websocket/hyperdrive) | n/a | `true` | Root op + every child op share one transaction; any op failure rolls back the whole changeset. |
| [Changeset](/define/changesets) | cloudflare-d1, neon over http (Cloudflare default) | n/a | `false` | Deterministic parent-first fail-fast — the first op failure stops the changeset; already-applied ops stay committed (a consistent, independently-authorized prefix). |

### Reading `meta.transactional` in clients

```typescript
const res = await fetch('/api/v1/jobs/batch', {
  method: 'PATCH',
  body: JSON.stringify({ records, options: { failFast: true } }),
});
const body = await res.json();

if (!body.meta.transactional && body.errors.length > 0) {
  // Some records succeeded before the failure and remain committed.
  // Compensate via the success array if needed.
}
```

### Embeddings and Realtime

When a resource has `embeddings` or `realtime` configured, queue messages
and broadcasts are emitted **after** the transaction commits. A
rolled-back batch produces no orphan embedding jobs.

## Batch Size Limit

The default maximum batch size is **100 records**. Requests exceeding the limit are rejected:

```json
{
  "type": "https://quickback.dev/problems/batch-size-exceeded",
  "title": "Batch size exceeded",
  "status": 400,
  "detail": "Batch size limit exceeded",
  "layer": "validation",
  "code": "BATCH_SIZE_EXCEEDED",
  "details": { "max": 100, "actual": 250 },
  "hint": "Maximum 100 records allowed per batch. Split into multiple requests."
}
```

## Configuration

Batch operations inherit access control from their corresponding write operation. You can override per-batch settings:

```typescript title="quickback/features/applications/applications.ts"
import { feature, q } from "@quickback/compiler";

export default feature("applications", {
  columns: {
    id:             q.id(),
    candidateName:  q.text({ maxLength: 200 }).required(),
    status:         q.enum(['applied', 'screening', 'offer']).default('applied').required(),
    organizationId: q.scope("organization"),
    ...q.audit(),
    ...q.softDelete(),
  },

  create: {
    access: { roles: ['hiring-manager', 'recruiter'] },
    batch: {
      access: { roles: ['hiring-manager'] },  // More restrictive than single create
      maxBatchSize: 50,                       // Default: 100
      allowFailFast: true,                    // Default: true
    },
  },
  update: {
    access: { roles: ['hiring-manager', 'recruiter'] },
    batch: {
      maxBatchSize: 100,
      allowFailFast: true,
    },
  },
  delete: {
    access: { roles: ['hiring-manager'] },
    mode: 'soft',
    batch: {
      access: { roles: ['hiring-manager'] },
      maxBatchSize: 100,
      allowFailFast: true,
    },
  },

  // Disable only batch upsert
  upsert: {
    access: { roles: ['hiring-manager'] },
    batch: false,
  },
});
```

## Security

All security pillars apply to batch operations:

1. **Authentication** — Required for all batch endpoints (401 if missing)
2. **Firewall** — Applied per-record for update/delete/upsert (not applicable to create)
3. **Access** — Role-based check using the batch operation's access config
4. **Guards** — Per-record validation (createable/updatable/immutable fields)
5. **Masking** — Applied to all records in the success array before response

## See Also

- [CRUD Endpoints](/api/crud) — Single-record operations
- [Error Responses](/api/errors) — Error format reference
- [Guards](/define/guards) — Field-level write protection
