Quickback Docs

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

EndpointGate
GET /api/v1/{resource}read.access
GET /api/v1/{resource}/:idread.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:

  1. Auth gate — 401 if unauthenticated
  2. Pre-record access — role-only check (skips access.record predicates)
  3. Firewall queryWHERE 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' 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.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.accessread.access
crud.list.pageSizeread.pageSize
crud.list.maxPageSizeread.maxPageSize
crud.list.fields: [...]Move to a named view under read.views
crud.get.accessread.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.
  • 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.
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 owns graph
  • Access — Roles, record conditions, and combinators
  • Firewall — Tenant scoping and firewallErrorMode
  • Views API — Calling views from clients

On this page