Quickback Docs

Guards - Field Modification Rules

Control which fields can be modified during CREATE vs UPDATE operations. Protect sensitive fields and enforce data integrity rules.

Control which fields can be modified in CREATE vs UPDATE operations.

Basic Usage

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

export const applications = sqliteTable('applications', {
  id: text('id').primaryKey(),
  candidateId: text('candidate_id').notNull(),
  jobId: text('job_id').notNull(),
  stage: text('stage').notNull().default('applied'),
  appliedAt: text('applied_at').notNull(),
  notes: text('notes'),
  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(applications, {
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  guards: {
    createable: ["candidateId", "jobId", "notes"],
    updatable: ["notes"],
    protected: {
      stage: ["advance-stage", "reject"],
    },
    immutable: ["appliedAt", "candidateId", "jobId"],
  },
  create: { /* ... */ },
  update: { /* ... */ },
  delete: { /* ... */ },
});

Configuration Options

guards: {
  // Fields allowed on CREATE
  createable?: string[];

  // Fields allowed on UPDATE/PATCH
  updatable?: string[];

  // Fields only modifiable via specific actions
  protected?: Record<string, string[]>;

  // Fields set on CREATE, blocked on every generic update.
  // Actions can still write them — see the note below.
  immutable?: string[];
}

How It Works

ListWhat it controls
createableFields allowed in create (POST) request body
updatableFields allowed in update (PATCH) request body
protectedFields blocked from direct writes; writable only by the actions named for them
immutableFields allowed on create, then never written again — by CRUD, batch, changeset, or actions

Guards bind actions too. createable and updatable shape the CRUD request bodies. immutable and protected are additionally enforced against your action handlers at compile time: the compiler scans each action bound to the table for db.update(<table>).set({ … }) / db.insert(<table>).values({ … }) and refuses an immutable column in any update, or a protected column written by an action that is not in its list — with an error naming the action, the column, and the fix. Every offending action in the project is listed in that one error, so a single compile surfaces all of them. The two tiers are therefore distinct: immutable is written once at create and never again; if a column needs an action to change it, it is protected, mapped to that action.

The scan sees object-literal payloads only. A spread (.set({ ...input })) or a variable payload (.set(patch)) is not analysed, so keep guarded columns out of such payloads yourself — that is the documented residual.

Combining lists:

  • createable + updatable - Most fields go in both (can set on create AND modify later)
  • createable only - Field is set once, cannot be changed via update
  • protected - Don't also list in createable or updatable (they're mutually exclusive)
  • immutable - Don't also list in updatable (contradiction)

Workflow-owned fields have one writer. If a status or state column is managed by transitions or named actions, put it only in protected, mapped to those transition/action names. Never also put it in createable or updatable: the compiler rejects either contradiction, and a direct POST or PATCH path would bypass the workflow. Give the column its initial value with a schema default. Records then begin in that state, and only the declared transitions/actions may write the field.

guards: {
  createable: ["candidateId", "jobId", "notes", "source"],
  updatable: ["notes"],
  // "candidateId", "jobId" are only in createable = set once, can't change via update
  protected: {
    stage: ["advance-stage", "reject"],  // NOT in createable/updatable
  },
  immutable: ["appliedAt"],     // NOT in updatable
}

If a field is not listed anywhere, it cannot be set by the client.

System-Managed Fields (Always Protected)

GUARDS_CONFIG.systemManaged rejects any field in this set from client input — even with guards: false. The set always includes the audit fields:

  • createdAt, createdBy
  • modifiedAt, modifiedBy
  • deletedAt, deletedBy

It also extends with scope columns — any firewall predicate whose equals references ctx.* adds its field to systemManaged. Most commonly this comes from q.scope():

columns: {
  organizationId: q.scope('organization'),  // → adds 'organizationId' to systemManaged
}

The same applies to a hand-rolled firewall:

firewall: [
  { field: 'tenantId',    equals: 'ctx.activeOrgId' },        // → systemManaged
  { field: 'workspaceId', equals: 'ctx.activeWorkspaceId' },  // → systemManaged
  { field: 'status',      equals: 'active' },                 // literal, NOT added
]

The semantic rule is "if the firewall says this column's value comes from the request context, the client cannot submit it." The value is auto-populated on create / upsert from ctx.

Changeset ops widen the set per-surface

On changeset ops, an owned child's systemManaged set additionally includes its spine FK and every inherit column — the changeset supplies those structurally (the FK from the parent op's id, inherit columns from the firewall-verified parent row), so a client-supplied value is a pointer-carrying GUARD_SYSTEM_MANAGED rejection (never silently stripped or stamped-over).

The child's plain-CRUD guards are unaffected: a junction that keeps its FK in createable for its raw POST route stays correct — the same field arriving in a changeset op is rejected because that surface stamps it. Two surfaces, one declared rule each.

Example

guards: {
  createable: ["candidateId", "jobId", "notes"],
  updatable: ["notes"],
  protected: {
    stage: ["advance-stage", "reject"],     // Only these actions can modify stage
  },
  immutable: ["appliedAt", "candidateId", "jobId"],
}

Disabling Guards

guards: false  // Only system fields protected

When guards: false is set, all user-defined fields become writable via direct create/update/upsert operations. This is useful for:

  • External sync scenarios where you need full field control
  • Batch upsert operations where field restrictions would be limiting
  • Simple tables where field-level protection isn't needed

Upsert with External IDs

When you disable guards AND use client-provided IDs, you unlock upsert operations. This is designed for syncing data from external systems.

Requirements for Upsert

  1. generateId: false in database config (client provides IDs)
  2. guards: false in resource definition

How Upsert Works

PUT /resource/:id
├── Record exists? → UPDATE (replace all fields)
└── Record missing? → CREATE with provided ID

Database Config

// quickback.config.ts
export default {
  database: {
    generateId: false,  // Client provides IDs (enables upsert)
    // Other options: 'uuid' | 'cuid' | 'nanoid' | 'short' | 'prefixed' | 'serial'
  }
};

Table Definition

// features/integrations/ats-imports.ts
import { q, defineTable } from '@quickback/compiler';

export const atsImports = q.table('atsImports', {
  id:             q.id(),
  externalId:     q.text().required(),
  payload:        q.text().optional(),
  organizationId: q.scope('organization'),
  ...q.audit(),
  ...q.softDelete(),
});

export default defineTable(atsImports, {
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  guards: false,       // Disables field restrictions
  upsert: {
    access: { roles: ['hiring-manager', 'sync-service'] }
  },
});

What's Still Protected with guards: false

Even with guards: false, system-managed fields are ALWAYS protected. This includes:

  • createdAt, createdBy — set on INSERT only
  • modifiedAt, modifiedBy — auto-updated
  • deletedAt, deletedBy — set on soft delete
  • Any column declared via q.scope() (or any firewall predicate using equals: 'ctx.*') — auto-populated from request context

So guards: false only opens up user-defined business fields. Tenant scope and audit fields stay locked down regardless.

Ownership Auto-Population

When upsert creates a new record, ownership fields are auto-set from context:

// Client sends: PUT /ats-imports/ext-123 { candidateName: "Jane Doe" }
// Server creates:
{
  id: "ext-123",           // Client-provided
  candidateName: "Jane Doe", // Client-provided
  organizationId: ctx.activeOrgId,  // Auto-set from firewall
  createdAt: now,          // Auto-set
  createdBy: ctx.userId,   // Auto-set
  modifiedAt: now,         // Auto-set
  modifiedBy: ctx.userId,  // Auto-set
}

Use Cases for Upsert/External IDs

Use CaseWhy upsert?
External API syncExternal system controls the ID
Webhook handlersEvents come with their own IDs
Data migrationPreserve IDs from source system
Idempotent updatesSafe to retry (no duplicate creates)
Bulk upsertCreate or update in one operation

ID Generation Options

generateIdUpsert Available?Notes
'uuid'NoServer generates UUID
'cuid'NoServer generates CUID
'nanoid'NoServer generates nanoid
'short'NoServer generates 6-char alphanumeric ID (e.g. a3F9xK)
'prefixed'NoServer generates prefixed ID (e.g. room_abc123)
'serial'NoDatabase auto-increments
falseYes (if guards: false)Client provides ID

Need client-generated ids without giving up server minting or guards? Set allowClientIds: true instead — creates accept an optional strategy-shaped id (for optimistic updates), guards stay on, and the id is exempt from createable. See Client-supplied IDs. Upsert still requires generateId: false + guards: false.

Compile-Time Validation

The Quickback compiler validates your guards configuration and will error if:

  1. Field in both createable and protected - A field cannot be both client-writable on create and action-only
  2. Field in both updatable and protected - A field cannot be both client-writable on update and action-only
  3. Field in both updatable and immutable - Contradictory: immutable fields cannot be updated
  4. Field in protected doesn't exist in schema - Referenced field must exist in the table
  5. Field in createable/updatable/immutable doesn't exist in schema - All referenced fields must exist

Transition/action-managed status fields belong only in protected, use a schema default for their initial value, and may be written only by the named transitions/actions.

On this page