Read - Unified Read Pipeline
The unified read configuration that owns collection reads, single-record reads, and named view projections. Replaces crud.list and crud.get.
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
// 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.
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:
read: {
access: {
or: [
{ roles: ['admin'] },
{ roles: ['member'], record: { ownerId: { equals: '$ctx.userId' } } },
],
},
}Configuration Options
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:
- Auth gate — 401 if unauthenticated
- Pre-record access — role-only check (skips
access.recordpredicates) - Firewall query —
WHEREclause filters by ownership - Post-record access — full check including record-level predicates
- 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' 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 for the full feature reference.
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.defaultViewis set, bareGET /api/v1/jobsresolves 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:
// 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:
// 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 contract-v2 ?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. ownsrelation 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 declaredfkcolumn.
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) still returns
the root row only — read the aggregate back with ?include=<relation>.
See Also
- Views — Full reference for named projections
- Changesets — Atomic writes over the
ownsgraph - Access — Roles, record conditions, and combinators
- Firewall — Tenant scoping and
firewallErrorMode - Views API — Calling views from clients
Rate Limit - Per-Operation Request Caps
Per-resource, per-operation rate limiting backed by Cloudflare's native Rate Limiting binding. Defaults apply to every resource automatically; override or opt out per-resource or project-wide.
Views - Column Level Security
Named field projections with per-view access control. Views live under the read pipeline (read.views) and implement column-level security.