# Read - Unified Read Pipeline

The `read` block owns the read side of every resource: the collection-level
`GET /` endpoint, single-record `GET /:id`, and named view projections. It
replaces the legacy `crud.list` and `crud.get` shapes — those are rejected
at compile time in DSL v2.

## Basic Usage

```typescript
// features/customers/customers.ts
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { defineTable } from '@quickback/compiler';

export const customers = sqliteTable('customers', {
  id: text('id').primaryKey(),
  name: text('name').notNull(),
  email: text('email').notNull(),
  ssn: text('ssn'),
  status: text('status').notNull(),
  organizationId: text('organization_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(customers, {
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  masking: {
    ssn: { type: 'ssn', show: { roles: ['admin'] } },
    // Detected as sensitive too; `false` records the reviewed-and-fine answer.
    email: false,
  },
  read: {
    access: { roles: ['member', 'admin'] },
    views: {
      summary: {
        fields: ['id', 'name', 'status'],
        access: { roles: ['member', 'admin'] },
      },
      full: {
        fields: ['id', 'name', 'email', 'ssn', 'status'],
        access: { roles: ['admin'] },
      },
    },
  },
  create: { access: { roles: ['admin'] } },
  update: { access: { roles: ['admin'] } },
  delete: { access: { roles: ['admin'] }, mode: 'soft' },
});
```

## What `read` Owns

| Endpoint | Gate |
|----------|------|
| `GET /api/v1/{resource}` | `read.access` |
| `GET /api/v1/{resource}/:id` | `read.access` |
| `GET /api/v1/{resource}/views/{name}` | `read.views[name].access` (falls back to `read.access`) |

Each `access` block takes a `roles: [...]` array. When the same role expression recurs across reads/views/CRUD, define it once in `authz.roles` and reference it by name — see [Centrally defined access rules](/define/authz-roles#centrally-defined-access-rules).

Single-record `GET /:id` inherits `read.access` automatically — there's no
separate `read.get` block. If you need different access for single records,
use record-level conditions on `read.access`:

```typescript
read: {
  access: {
    or: [
      { roles: ['admin'] },
      { roles: ['member'], record: { ownerId: { equals: '$ctx.userId' } } },
    ],
  },
}
```

## Configuration Options

```typescript
interface ReadConfig {
  // Role/condition gate for collection reads, single-record reads, and the
  // default view fallback.
  access?: Access;

  // Optional read-side firewall override. If omitted, the resource-level
  // `firewall` block is used (the common case).
  firewall?: FirewallConfig;

  // Pagination defaults for collection reads.
  pageSize?: number;       // Default page size (default: 50)
  maxPageSize?: number;    // Hard cap (default: 100)

  // Named field projections. Each view has its own access and field list.
  views?: {
    [viewName: string]: ViewConfig;
  };
}

interface ViewConfig {
  fields: string[];        // Columns returned by this view
  access?: Access;         // Per-view access (falls back to read.access)
  pageSize?: number;
  maxPageSize?: number;
}
```

## Access Ordering on `/:id`

Single-record `GET /:id` evaluates checks in this order to close the
404-vs-403 ID-probe channel:

1. **Auth gate** — 401 if unauthenticated
2. **Pre-record access** — role-only check (skips `access.record` predicates)
3. **Firewall query** — `WHERE` clause filters by ownership
4. **Post-record access** — full check including record-level predicates
5. **Masking** — applied to the response

An unauthorized caller without a matching role gets 403 from step 2 before
the database is touched, so they can't distinguish "row exists in another
tenant" from "row doesn't exist." A caller with the right role but the wrong
tenant gets the firewall's configured response (403 by default; see
[`firewallErrorMode: 'hide'`](/define/firewall) for an opaque
404 instead).

Function-form access is opaque to the pre-check (it always passes there) and
is fully evaluated post-record, so `access: async (ctx, record) => ...`
still works as before.

## Views Under `read.views`

Views are named field projections — Column Level Security. They live under
`read.views` (not at the resource top level). See
[Views](/define/views) for the full feature reference.

```typescript
read: {
  access: { roles: ['member', 'admin'] },
  views: {
    public: {
      fields: ['id', 'title', 'status'],
      access: { roles: ['PUBLIC'] },
    },
    internal: {
      fields: ['id', 'title', 'status', 'salaryMin', 'salaryMax'],
      access: { roles: ['admin'] },
    },
  },
}
```

Named views are emitted at dedicated path routes:

- `GET /api/v1/jobs/views/internal`
- If `read.defaultView` is set, bare `GET /api/v1/jobs` resolves to that view.

## Migrating from `crud.list` / `crud.get`

DSL v2 rejects both at compile time. The migration is mechanical:

| Before (DSL v1) | After (DSL v2) |
|---|---|
| `crud.list.access` | `read.access` |
| `crud.list.pageSize` | `read.pageSize` |
| `crud.list.maxPageSize` | `read.maxPageSize` |
| `crud.list.fields: [...]` | Move to a named view under `read.views` |
| `crud.get.access` | `read.access` (or record-level conditions) |
| `crud.get.fields: [...]` | Move to a named view; clients call `/views/{name}` |
| Top-level `views: {...}` | `read.views: {...}` |

Example before/after:

```typescript
// Before — DSL v1
crud: {
  list: {
    access: { roles: ['member'] },
    pageSize: 25,
  },
  get: {
    access: { roles: ['member'] },
    fields: ['id', 'name', 'status'],   // per-field projection
  },
  create: { access: { roles: ['admin'] } },
}

// After — DSL v2
read: {
  access: { roles: ['member'] },
  pageSize: 25,
  views: {
    summary: {
      fields: ['id', 'name', 'status'],
      access: { roles: ['member'] },
    },
  },
}
create: { access: { roles: ['admin'] } }
```

Clients that previously hit `GET /:id` to retrieve only summary fields now
move that projection to a named collection view such as
`GET /api/v1/jobs/views/summary`. Single-record `GET /:id` always returns the
full masked record.

## Read-Only Resources

A resource that exposes only reads is valid with no write-operation blocks at all:

```typescript
// features/ledger/balance-snapshots.ts
import { q, defineTable } from '@quickback/compiler';

export const balanceSnapshots = q.table('balanceSnapshots', {
  id:             q.id(),
  accountId:      q.text().required(),
  balanceCents:   q.int().required(),
  organizationId: q.scope('organization'),
  ...q.audit(),
  ...q.softDelete(),
});

export default defineTable(balanceSnapshots, {
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  read: { access: { roles: ['member', 'admin'] } },
  // no create/update/delete/upsert — no writes
});
```

## Aggregate Reads

Reading a parent **with** its owned relations rides `?include=`,
which accepts two kinds of tokens in the same `read.include` allowlist:

- **FK columns** (`?include=vendorId`) — forward embeds: the target rows the
  source row points at.
- **`owns` relation names** (`?include=reactions`) — the phase-2 aggregate
  reader: the changeset root's owned **child rows**, pivoting on the root PK
  and matching the child's declared `fk` column.

{/* doc-compile: skip — the fence names a `noteReactions` child table it deliberately does not repeat. */}
```typescript
export default defineTable(notes, {
  owns: { reactions: { table: 'noteReactions', fk: 'noteId' } },
  read: {
    access: { roles: ['member', 'admin'] },
    include: ['reactions'],          // ?include=reactions embeds the children
  },
});
```

Both kinds land in the top-level `included` map keyed by the include token,
then row PK, and both inherit the **target's full security surface** — its
`read.access` (pre-record-evaluable only, compile-enforced), its firewall
(ANDed into the child fetch on the caller-claims handle), and its masking.
`fields[<token>]` sparse projections apply identically. The aggregate-changeset
write surface ([changesets](/define/changesets)) still returns
the **root row only** — read the aggregate back with `?include=<relation>`.

## See Also

- [Views](/define/views) — Full reference for named projections
- [Changesets](/define/changesets) — Atomic writes over the `owns` graph
- [Access](/define/access) — Roles, record conditions, and combinators
- [Firewall](/define/firewall) — Tenant scoping and `firewallErrorMode`
- [Views API](/api/views) — Calling views from clients

## Read & Write Configuration

Reads (`GET /` and `GET /:id`) live under `read:`; mutations live under top-level `create:`, `update:`, `delete:`, and `upsert:`. See [Read](/define/read) for the full read pipeline reference.

```typescript
read: {
  // LIST + GET - GET /resource and GET /resource/:id
  access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] },
  pageSize: 25,        // Default page size
  maxPageSize: 100,    // Client can't exceed this
  views: {              // Per-view field projections
    summary: {
      fields: ['id', 'candidateId', 'jobId', 'stage'],
      access: { roles: ["interviewer"] },
    },
  },
}

create: {
  // CREATE - POST /resource
  access: { roles: ["owner", "hiring-manager", "recruiter"] },
  defaults: {           // Default values for new records
    stage: 'applied',
  },
},

update: {
  // UPDATE - PATCH /resource/:id
  access: {
    or: [
      { roles: ["hiring-manager", "recruiter"] },
      { roles: ["interviewer"], record: { stage: { equals: "interview" } } }
    ]
  },
},

delete: {
  // DELETE - DELETE /resource/:id
  access: { roles: ["owner", "hiring-manager"] },
  mode: "soft",  // 'soft' (default) or 'hard'
},

upsert: {
  // PUT - PUT /resource/:id (only when generateId: false + guards: false)
  access: { roles: ["hiring-manager", "sync-service"] },
}
```

## List Filtering (Query Parameters)

The LIST endpoint automatically supports filtering via query params:

```
GET /jobs?status=open                         # Exact match
GET /jobs?salaryMin.gt=50000                  # Greater than
GET /jobs?salaryMin.gte=50000                 # Greater than or equal
GET /jobs?salaryMax.lt=200000                 # Less than
GET /jobs?salaryMax.lte=200000                # Less than or equal
GET /jobs?status.ne=closed                    # Not equal
GET /jobs?title.like=Engineer                 # Pattern match (LIKE %value%)
GET /jobs?status.in=open,draft                # IN clause
```

| Operator | Query Param | SQL Equivalent |
|----------|-------------|----------------|
| Equals | `?field=value` | `WHERE field = value` |
| Not equals | `?field.ne=value` | `WHERE field != value` |
| Greater than | `?field.gt=value` | `WHERE field > value` |
| Greater or equal | `?field.gte=value` | `WHERE field >= value` |
| Less than | `?field.lt=value` | `WHERE field < value` |
| Less or equal | `?field.lte=value` | `WHERE field <= value` |
| Pattern match | `?field.like=value` | `WHERE field LIKE '%value%'` |
| In list | `?field.in=a,b,c` | `WHERE field IN ('a','b','c')` |

## Sorting & Pagination

```
GET /jobs?sort=createdAt&order=desc   # Sort by field
GET /jobs?limit=25&offset=50          # Pagination
```

- **Default limit**: 50
- **Max limit**: 100 (or `maxPageSize` if configured)
- **Default order**: `asc`

## Delete Modes

```typescript
delete: {
  access: { roles: ["owner", "hiring-manager"] },
  mode: "soft",  // Sets deletedAt/deletedBy, record stays in DB
}

delete: {
  access: { roles: ["owner", "hiring-manager"] },
  mode: "hard",  // Permanent deletion from database
}
```

`mode` on the single-record path is inherited by `DELETE /batch` unless `batch` names its own — so `mode: "hard"` above makes the whole resource hard-delete. That changes the schema: a hard-delete table must **not** declare `deletedAt` / `deletedBy` (via `...q.softDelete()` or literal Drizzle lines), and the firewall drops its `isNull(deletedAt)` predicate. See [Soft-delete fields](/define/schema#soft-delete-fields).

