# CRUD Endpoints

Quickback automatically generates RESTful CRUD endpoints for each resource you define. This page covers the write side (create, update, delete, upsert) and lists collection-read endpoints. Read-side configuration lives under [`read:`](/define/read).

> **Migration note**: `crud.list` and `crud.get` are rejected at compile time in DSL v2. List-level access moves to [`read.access`](/define/read), and per-field projections move to named views under [`read.views`](/define/read#views-under-read-views). See the [migration table](/define/read#migrating-from-crud-list-crud-get).

## Quick Reference

All endpoints require authentication. Include your session cookie or Bearer token:

```bash
# List records
curl http://localhost:8787/api/v1/jobs \
  -H "Authorization: Bearer <token>"

# Get a single record
curl http://localhost:8787/api/v1/jobs/job_123 \
  -H "Authorization: Bearer <token>"

# Create a record
curl -X POST http://localhost:8787/api/v1/jobs \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"title": "Senior Engineer", "department": "Engineering", "status": "open"}'

# Update a record
curl -X PATCH http://localhost:8787/api/v1/jobs/job_123 \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"title": "Staff Engineer"}'

# Delete a record
curl -X DELETE http://localhost:8787/api/v1/jobs/job_123 \
  -H "Authorization: Bearer <token>"

# List with filters, sorting, and pagination
curl "http://localhost:8787/api/v1/jobs?status=open&sort=-createdAt&limit=25&total=true" \
  -H "Authorization: Bearer <token>"
```

## Endpoint Overview

For a resource named `jobs`, Quickback generates:

| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/jobs` | List all records |
| `GET` | `/jobs/:id` | Get a single record |
| `POST` | `/jobs` | Create a new record |
| `POST` | `/jobs/batch` | Batch create multiple records |
| `PATCH` | `/jobs/:id` | Update a record |
| `PATCH` | `/jobs/batch` | Batch update multiple records |
| `DELETE` | `/jobs/:id` | Delete a record |
| `DELETE` | `/jobs/batch` | Batch delete multiple records |
| `PUT` | `/jobs/:id` | Upsert a record (requires config) |
| `PUT` | `/jobs/batch` | Batch upsert multiple records (requires config) |

## Replace a generated verb with an action

Map `create`, `update`, or `delete` to a named action when the public URL
must stay and the handler has to become real code. See
[Replace a CRUD verb](/define/actions#replace-a-crud-verb).

{/* doc-compile: skip — one-key excerpt; the whole-file form is on the actions page. */}
```ts
create: {
  access: { roles: ["member"] },
  action: "send",
}
```

`POST /api/v1/jobs` then runs `send`'s `execute`. Clients, OpenAPI, and MCP
keep the CRUD path and operation id. Unmapped verbs are unchanged.

## List & Get Records

`GET /{resource}` and `GET /{resource}/:id` are part of the [read pipeline](/define/read). Configuration (access, pagination, view projections) lives under `read:`. Filtering, sorting, and pagination query parameters work the same on both endpoints — see [Query Parameters](/api/query-params).

```typescript
read: {
  access: { roles: ['owner', 'hiring-manager', 'recruiter'] },
  pageSize: 25,
  maxPageSize: 100,
  views: {
    summary: { fields: ['id', 'title', 'status'], access: { roles: ['interviewer'] } },
  },
}
```

Calls a client makes:

```bash
GET /jobs                              # Collection — gated by read.access
GET /jobs/:id                          # Single — gated by read.access (with ID-probe defense)
GET /jobs/views/summary                # Named view projection
```

### Response shapes

**Collection reads** (`GET /jobs`, `GET /jobs/views/:name`) return an envelope:

```json
{
  "data": [ { "id": "job_123", "title": "Senior Engineer", "status": "open" } ],
  "view": null,
  "pagination": {
    "count": 1,
    "page": 1,
    "pageSize": 50,
    "hasMore": false,
    "nextCursor": null,
    "prevCursor": null
  }
}
```

`view` names the projection behind the response and is `null` for the default
collection read. `total` and `totalPages` join `pagination` only when the
request passed `?total=true`. A view that declares aggregations adds a
top-level `aggregations` object; `?include=` adds a top-level
`included` map. See
[Query Parameters](/api/query-params).

**Single-record reads** (`GET /jobs/:id`) return the **bare record** — no
`data` wrapper:

```json
{ "id": "job_123", "title": "Senior Engineer", "status": "open" }
```

### ID-probe defense on `/:id`

Single-record reads run a role-only access check **before** the firewall query, so an unauthorized caller can't tell apart "row exists in another tenant" from "row doesn't exist." See [Access ordering](/define/read#access-ordering-on-id) for details.

| Status | Description |
|--------|-------------|
| `401` | Not authenticated |
| `403` | Authenticated but role doesn't satisfy `read.access` — **or** the role passes and the firewall excluded the record (`FIREWALL_NOT_FOUND`). This is the default: `firewallErrorMode` is `'reveal'`. |
| `404` | Same firewall miss, but on a resource that sets [`firewallErrorMode: 'hide'`](/define/firewall) to make an out-of-scope row indistinguishable from a nonexistent one. |

### FK Label Resolution

Foreign key columns are automatically enriched with `_label` fields containing the referenced table's display value. The name is the FK column with a trailing `Id` stripped, plus `_label` — so `departmentId` gets `department_label`. Enrichment only happens when the referenced table declares a display column. See [Display Column](/define/schema#display-column) for details.

## Create Record

```
POST /jobs
Content-Type: application/json

{
  "title": "Senior Engineer",
  "department": "Engineering",
  "status": "open",
  "salaryMin": 120000,
  "salaryMax": 180000
}
```

Creates a new record. Only fields listed in `guards.createable` are accepted.

### Response

`201 Created`. The body is the **bare created record** — there is no `data`
wrapper:

```json
{
  "id": "job_456",
  "title": "Senior Engineer",
  "department": "Engineering",
  "status": "open",
  "salaryMin": 120000,
  "salaryMax": 180000,
  "createdAt": "2024-01-15T11:00:00Z",
  "createdBy": "user_789"
}
```

### Errors

| Status | Description |
|--------|-------------|
| `400` | Invalid field or missing required field |
| `403` | User lacks permission to create records |

## Update Record

```
PATCH /jobs/:id
Content-Type: application/json

{
  "title": "Staff Engineer"
}
```

Updates an existing record. Only fields listed in `guards.updatable` are accepted.

### Response

`200 OK`. The body is the **bare updated record** (every column, not just the
patched ones) — there is no `data` wrapper:

```json
{
  "id": "job_123",
  "title": "Staff Engineer",
  "modifiedAt": "2024-01-15T12:00:00Z",
  "modifiedBy": "user_789"
}
```

### Errors

| Status | Description |
|--------|-------------|
| `400` | Invalid field or field not updatable |
| `403` | User lacks permission to update this record, or the firewall excluded it (default `firewallErrorMode: 'reveal'`) |
| `404` | Record excluded by the firewall when the resource sets `firewallErrorMode: 'hide'` |
| `412` | `If-Match` revision mismatch |

## Delete Record

```
DELETE /jobs/:id
```

Deletes a record. Behavior depends on the `delete.mode` configuration.

### Soft Delete (default)

Sets `deletedAt` and `deletedBy` fields. Record remains in database but is filtered from queries.

#### Cascade

When a parent is soft-deleted, every child table that holds an FK to it — both **same-feature** children and **cross-feature** children (e.g. `applications` referencing `jobs` from a different feature directory) — receives the same `deletedAt` / `deletedBy` stamp in the same request. The cascade UPDATE always AND-merges the child's own firewall (`build<Child>FirewallConditions(ctx)`), so a delete in one tenant can never reach a child row in another tenant.

If a child column declared `.references(() => parent.id, { onDelete: '...' })`, the cascade respects that intent — only `cascade` and `set null` participate. FKs without `onDelete` are cascaded for backwards-compat with the legacy shape.

### Hard Delete

Permanently removes the record from the database.

### Response

`200 OK`. The body is a fixed acknowledgement — the deleted record is **not**
returned, in either mode:

```json
{ "success": true }
```

### Errors

| Status | Description |
|--------|-------------|
| `403` | User lacks permission to delete this record, or the firewall excluded it (default `firewallErrorMode: 'reveal'`) |
| `404` | Record excluded by the firewall when the resource sets `firewallErrorMode: 'hide'` |
| `412` | `If-Match` revision mismatch |

## Upsert Record (PUT)

```
PUT /jobs/:id
Content-Type: application/json

{
  "title": "Contract Recruiter",
  "department": "Talent Acquisition",
  "status": "open",
  "externalId": "ext-123"
}
```

Creates or updates a record by ID. Requires special configuration:

1. `generateId: false` in database config
2. `guards: false` in resource definition

### Behavior

- If record exists: Updates all provided fields, returns `200` with the bare record
- If record doesn't exist: Creates with the provided ID, returns `201` with the bare record

### Use Cases

- Syncing data from external systems
- Webhook handlers with external IDs
- Idempotent operations (safe to retry)

See [Guards documentation](/define/guards#putupsert-with-external-ids) for setup details.

## Batch Operations

Quickback provides batch endpoints for efficient bulk operations. Batch operations automatically inherit from their corresponding CRUD operations and maintain full security layer consistency.

### Batch Create Records

```
POST /jobs/batch
Content-Type: application/json

{
  "records": [
    { "title": "Frontend Engineer", "department": "Engineering", "status": "open" },
    { "title": "Product Designer", "department": "Design", "status": "draft" },
    { "title": "Data Analyst", "department": "Analytics", "status": "open" }
  ],
  "options": {
    "failFast": false
  }
}
```

Creates multiple records in a single request. Each record follows the same validation rules as single create operations.

#### Request Body

| Field | Type | Description |
|-------|------|-------------|
| `records` | Array | Array of record objects to create |
| `options.failFast` | Boolean | If `true`, stop on first error and roll back on tx-supporting providers (default: `false`) |

#### Response (Partial Success - Default)

```json
{
  "success": [
    { "id": "job_1", "title": "Frontend Engineer", "department": "Engineering", "status": "open" },
    { "id": "job_2", "title": "Product Designer", "department": "Design", "status": "draft" }
  ],
  "errors": [
    {
      "index": 2,
      "record": { "title": "Data Analyst", "department": "Analytics", "status": "open" },
      "error": {
        "error": "Field cannot be set during creation",
        "layer": "guards",
        "code": "GUARD_FIELD_NOT_CREATEABLE",
        "details": { "fields": ["status"] }
      }
    }
  ],
  "meta": {
    "total": 3,
    "succeeded": 2,
    "failed": 1,
    "failFast": false,
    "transactional": true
  }
}
```

`POST /batch` is a single bulk `INSERT`, so `meta.transactional` is `true` on
every provider regardless of `failFast`.

#### HTTP Status Codes

- `201` - All records created successfully
- `207` - Partial success (some records failed)
- `400` - `failFast: true` was set and one or more records failed

#### Batch Size Limit

Default: 100 records per request (configurable via `maxBatchSize`)

### Batch Update Records

```
PATCH /jobs/batch
Content-Type: application/json

{
  "records": [
    { "id": "job_1", "title": "Senior Frontend Engineer" },
    { "id": "job_2", "department": "Product Design" },
    { "id": "job_3", "status": "closed" }
  ],
  "options": {
    "failFast": false
  }
}
```

Updates multiple records in a single request. All records must include an `id` field.

#### Request Body

| Field | Type | Description |
|-------|------|-------------|
| `records` | Array | Array of record objects with `id` and fields to update |
| `options.failFast` | Boolean | If `true`, stop on first error and roll back on tx-supporting providers (default: `false`) |

#### Response

```json
{
  "success": [
    { "id": "job_1", "title": "Senior Frontend Engineer", "modifiedAt": "2024-01-15T14:00:00Z" },
    { "id": "job_2", "department": "Product Design", "modifiedAt": "2024-01-15T14:00:00Z" }
  ],
  "errors": [
    {
      "index": 2,
      "record": { "id": "job_3", "status": "closed" },
      "error": {
        "error": "Not found",
        "layer": "firewall",
        "code": "NOT_FOUND",
        "details": { "id": "job_3" }
      }
    }
  ],
  "meta": {
    "total": 3,
    "succeeded": 2,
    "failed": 1,
    "failFast": false,
    "transactional": false
  }
}
```

#### Features

- **Batch fetching**: Single database query for all IDs (with firewall)
- **Per-record access**: Access checks run with record context
- **Field validation**: Guards apply to each record individually

### Batch Delete Records

```
DELETE /jobs/batch
Content-Type: application/json

{
  "ids": ["job_1", "job_2", "job_3"],
  "options": {
    "failFast": false
  }
}
```

Deletes multiple records in a single request. Supports both soft and hard delete modes.

#### Request Body

| Field | Type | Description |
|-------|------|-------------|
| `ids` | Array | Array of record IDs to delete |
| `options.failFast` | Boolean | If `true`, stop on first error and roll back on tx-supporting providers (default: `false`) |

#### Response (Soft Delete)

```json
{
  "success": [
    { "id": "job_1", "deletedAt": "2024-01-15T15:00:00Z", "deletedBy": "user_789" },
    { "id": "job_2", "deletedAt": "2024-01-15T15:00:00Z", "deletedBy": "user_789" }
  ],
  "errors": [
    {
      "index": 2,
      "id": "job_3",
      "error": {
        "error": "Not found",
        "layer": "firewall",
        "code": "NOT_FOUND",
        "details": { "id": "job_3" }
      }
    }
  ],
  "meta": {
    "total": 3,
    "succeeded": 2,
    "failed": 1,
    "failFast": false,
    "transactional": false
  }
}
```

#### Delete Modes

- **Soft delete** (default): Sets `deletedAt`, `deletedBy`, `modifiedAt`, `modifiedBy` fields
- **Hard delete**: Permanently removes records from database

### Batch Upsert Records

```
PUT /jobs/batch
Content-Type: application/json

{
  "records": [
    { "id": "job_1", "title": "Updated Frontend Engineer", "department": "Engineering" },
    { "id": "new_job", "title": "New Backend Engineer", "department": "Engineering", "status": "draft" }
  ],
  "options": {
    "failFast": false
  }
}
```

Creates or updates multiple records in a single request. Creates if ID doesn't exist, updates if it does.

**Strict Requirements** (same as single PUT):
1. `generateId: false` in database config (user provides IDs)
2. `guards: false` in resource definition (no field restrictions)
3. All records must include an `id` field

**Note**: System-managed fields (`createdAt`, `createdBy`, `modifiedAt`, `modifiedBy`, `deletedAt`, `deletedBy`) are always protected and will be rejected if included in the request, regardless of guards configuration.

#### Request Body

| Field | Type | Description |
|-------|------|-------------|
| `records` | Array | Array of record objects with `id` and all fields |
| `options.failFast` | Boolean | If `true`, stop on first error and roll back on tx-supporting providers (default: `false`) |

#### Response

```json
{
  "success": [
    { "id": "job_1", "title": "Updated Frontend Engineer", "department": "Engineering", "modifiedAt": "2024-01-15T16:00:00Z" },
    { "id": "new_job", "title": "New Backend Engineer", "department": "Engineering", "status": "draft", "createdAt": "2024-01-15T16:00:00Z" }
  ],
  "errors": [],
  "meta": {
    "total": 2,
    "succeeded": 2,
    "failed": 0,
    "failFast": false,
    "transactional": false
  }
}
```

#### How It Works

1. Batch existence check with firewall
2. Split records into CREATE and UPDATE batches
3. Validate new records with `validateCreate()`
4. Validate existing records with `validateUpdate()`
5. Check CREATE access for new records
6. Check UPDATE access for existing records (per-record)
7. Execute bulk insert and individual updates
8. Return combined results

### Batch Operation Features

#### Partial Success Mode (Default)

By default, batch operations use **partial success** mode:
- All records are processed independently
- Failed records go into `errors` array with detailed error information
- Successful records go into `success` array
- HTTP status `207 Multi-Status` if any errors, `201`/`200` if all success

```json
{
  "success": [ /* succeeded records */ ],
  "errors": [
    {
      "index": 2,
      "record": { /* original input */ },
      "error": {
        "error": "Human-readable message",
        "layer": "guards",
        "code": "GUARD_FIELD_NOT_CREATEABLE",
        "details": { "fields": ["status"] },
        "hint": "These fields are set automatically or must be omitted"
      }
    }
  ],
  "meta": {
    "total": 10,
    "succeeded": 8,
    "failed": 2,
    "failFast": false,
    "transactional": false
  }
}
```

`meta` always carries exactly these five keys: `total`, `succeeded`, `failed`,
`failFast`, `transactional`. (There is no `atomic` key.) The per-record
objects inside `errors` keep the internal flat error shape — `error`, `layer`,
`code`, `details`, `hint` — because a 207 body is a success envelope
(`application/json`), not a Problem Details document.

#### Fail-Fast Mode (Opt-in)

Set `options.failFast: true` to stop on the first error. On providers that
support transactions, the loop is wrapped in `db.transaction(...)` so prior
writes roll back. The response's `meta.transactional` field tells you
whether you got real rollback or just early-stop semantics — see
[Batch Operations: Transactional semantics](/api/batch-operations#transactional-semantics).

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

On the first failure under `failFast: true`:
- Processing stops; no further records are attempted
- HTTP status `400 Bad Request`
- A single error is returned

```json
{
  "type": "https://quickback.dev/problems/batch-fail-fast-stopped",
  "title": "Batch fail fast stopped",
  "status": 400,
  "detail": "Batch stopped at first error (fail-fast mode)",
  "layer": "validation",
  "code": "BATCH_FAIL_FAST_STOPPED",
  "details": {
    "failedAt": 2,
    "reason": "Field cannot be set during creation",
    "errorDetails": { "fields": ["status"] }
  },
  "hint": "Records before index 2 were applied and are NOT rolled back. Inspect application state before retrying — re-sending the same batch may double-apply records that already succeeded."
}
```

#### Human-Readable Errors

All batch operation errors include:
- **Layer identification**: Which security layer rejected the request
- **Error code**: Machine-readable code for programmatic handling
- **Clear message**: Human-readable explanation
- **Details**: Contextual information (fields, IDs, reasons)
- **Helpful hints**: Actionable guidance for resolution

#### Performance Optimizations

- **Batch size limits**: Default 100 records (prevents memory exhaustion)
- **Single firewall query**: `WHERE id IN (...)` instead of N queries
- **Bulk operations**: Single INSERT for multiple records (CREATE, UPSERT)
- **O(1) lookups**: Map-based record lookup instead of Array.find()

#### Configuration

Batch operations are **auto-enabled** when corresponding write operations exist:

```typescript
// Auto-enabled - no configuration needed
create: { access: { roles: ['recruiter'] } },
update: { access: { roles: ['recruiter'] } }
// create.batch and update.batch automatically available

// Customize batch operations
create: {
  access: { roles: ['recruiter'] },
  batch: {
    access: { roles: ['hiring-manager'] },  // Different access rules
    maxBatchSize: 50,                       // Lower limit
    allowFailFast: false                    // Disable failFast mode
  }
}

// Disable batch operations
create: {
  access: { roles: ['recruiter'] },
  batch: false  // Explicitly disable
}
```

#### Security Layer Application

Batch operations maintain **full security layer consistency**:

1. **Firewall**: Auto-apply ownership fields, batch fetch with isolation
2. **Access**: Operation-level for CREATE, per-record for UPDATE/DELETE
3. **Guards**: Per-record field validation (same rules as single operations)
4. **Masking**: Applied to success array (respects user permissions)
5. **Audit**: Single timestamp for entire batch for consistency

## Authentication

All endpoints require authentication. Include your auth token in the request header:

```
Authorization: Bearer <your-token>
```

The user's context (userId, roles, organizationId) is extracted from the token and used to:

1. Apply firewall filters (data isolation)
2. Check access permissions
3. Set audit fields (createdBy, modifiedBy)

## Error Responses

Top-level errors are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) Problem
Details documents served as `application/problem+json`. Quickback's contextual
fields (`code`, `layer`, `details`, `hint`, `request`) survive as extension
members:

```json
{
  "type": "https://quickback.dev/problems/access-role-required",
  "title": "Access role required",
  "status": 403,
  "detail": "Insufficient permissions",
  "instance": "/api/v1/jobs/job_123",
  "layer": "access",
  "code": "ACCESS_ROLE_REQUIRED",
  "details": {
    "required": ["hiring-manager"],
    "current": ["interviewer"]
  },
  "hint": "Contact an administrator to grant necessary permissions"
}
```

See [Errors](/api/errors) for the complete reference of error codes by security layer.
