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(),
});

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, never modified after
  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, only modifiable via named actions
immutableFields allowed on create, blocked on all updates

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)
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.

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
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

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

On this page