Quickback Docs

Batch Operations

Bulk create, update, upsert, and delete records with optional fail-fast transactions.

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

EndpointMethodAuto-enabled When
/{resource}/batchPOSTcreate exists
/{resource}/batchPATCHupdate exists
/{resource}/batchDELETEdelete exists
/{resource}/batchPUTupsert exists

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

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

Batch Create

POST /api/v1/{resource}/batch

With allowClientIds: true each record MAY carry a strategy-shaped id; records without one get server-minted ids as usual.

Request

{
  "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)

{
  "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": "jobId",
          "columns": ["jobId", "candidateId"],
          "fields": { "jobId": "Already taken", "candidateId": "Already taken" }
        },
        "hint": "A record with this \"jobId\" + \"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

{
  "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)

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

{
  "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

{
  "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):

{
  "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).

If the bulk statement itself fails, the error is classified like every other write — a constraint hit reports its DB_* code (for example DB_FOREIGN_KEY_VIOLATION, 400), anything else DB_DELETE_FAILED (500); see Database errors. With failFast: true that error is the response, at its own status. Otherwise it is repeated in errors for every id that passed the existence and access checks, and the response is 207. The raw database message reaches details.reason only when EXPOSE_ERROR_STACK is set.

Batch Upsert

PUT /api/v1/{resource}/batch

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

When create and update access policies are configured, batch upsert requires create access for the route. Existing records also receive the update access check, including any record-level rule. A caller who can create but cannot update may insert new IDs; existing IDs fail their update check.

Note: Batch upsert requires both generateId: false (user-provided IDs) in your database configuration and guards: false on the table. Explicitly enabling crud.batchUpsert without both settings is a compile error. Use crud.batchUpdate for guarded updates.

Request

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

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

Fail-Fast Failure Response (400)

{
  "detail": "Batch stopped at first error (fail-fast mode)",
  "layer": "validation",
  "code": "BATCH_FAIL_FAST_STOPPED",
  "details": {
    "failedAt": 1,
    "applied": true,
    "reason": "Duplicate value",
    "errorDetails": { "column": "email", "columns": ["email"], "fields": { "email": "Already taken" } }
  }
}

details.reason here is the failing record's error message, and details.errorDetails its details. A database fallback (DB_INSERT_FAILED / DB_UPDATE_FAILED) has no details unless EXPOSE_ERROR_STACK is set — see Errors.

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 over websocket (connectionMode: 'websocket', the default on Node/Bun runtimes) and over hyperdrive (postgres.js on a Cloudflare Hyperdrive binding — the only Neon mode with interactive transactions on Workers). 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.

PlanetScale Postgres is Hyperdrive-only and therefore always transactional — it shares the postgres-js-hyperdrive transport with Neon's hyperdrive mode.

Per-Provider Matrix

EndpointProviderfailFastmeta.transactionalBehavior
PATCH /batch (update)tx-supporting (postgres, neon over websocket/hyperdrive, planetscale-postgres)truetruedb.transaction() wraps the per-record loop. Any failure rolls back ALL writes in the batch.
PATCH /batch (update)(any)falsefalsePer-record loop, partial success. Each record commits independently.
PATCH /batch (update)cloudflare-d1, neon over http (Cloudflare default)truefalseFail-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/hyperdrive, planetscale-postgres)truetrueBulk insert + per-record update share one transaction. Any failure rolls back both.
PUT /batch (upsert)cloudflare-d1, neon over http (Cloudflare default)truefalseBulk insert commits; per-record update loop is fail-fast without rollback.
POST /batch (create)(any)(any)trueSingle bulk INSERT — atomic at the DB level.
DELETE /batch(any)(any)trueSingle bulk UPDATE (soft) or DELETE (hard) — atomic at the DB level.
Changeset (application/vnd.quickback.changeset+json)tx-supporting (postgres, neon over websocket/hyperdrive, planetscale-postgres)n/atrueRoot op + every child op share one transaction; any op failure rolls back the whole changeset.
Changesetcloudflare-d1, neon over http (Cloudflare default)n/afalseDeterministic 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

meta is part of the success envelope (200 / 207). A fail-fast failure is a 400 problem document instead, with no meta — read details.applied there:

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

if (res.ok) {
  // body.meta.transactional: true when the batch ran in one transaction.
} else if (body.code === 'BATCH_FAIL_FAST_STOPPED') {
  if (body.details.applied) {
    // Records before body.details.failedAt were committed and stay committed.
    // Reconcile before resending, or they apply twice.
  } else {
    // Nothing was written: fix records[body.details.failedAt] and resend.
  }
}

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:

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

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

On this page