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
| List | What it controls |
|---|---|
createable | Fields allowed in create (POST) request body |
updatable | Fields allowed in update (PATCH) request body |
protected | Fields blocked from direct writes, only modifiable via named actions |
immutable | Fields allowed on create, blocked on all updates |
Combining lists:
createable+updatable- Most fields go in both (can set on create AND modify later)createableonly - Field is set once, cannot be changed via updateprotected- Don't also list increateableorupdatable(they're mutually exclusive)immutable- Don't also list inupdatable(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,createdBymodifiedAt,modifiedBydeletedAt,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 protectedWhen 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
generateId: falsein database config (client provides IDs)guards: falsein resource definition
How Upsert Works
PUT /resource/:id
├── Record exists? → UPDATE (replace all fields)
└── Record missing? → CREATE with provided IDDatabase 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 onlymodifiedAt,modifiedBy— auto-updateddeletedAt,deletedBy— set on soft delete- Any column declared via
q.scope()(or any firewall predicate usingequals: '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 Case | Why upsert? |
|---|---|
| External API sync | External system controls the ID |
| Webhook handlers | Events come with their own IDs |
| Data migration | Preserve IDs from source system |
| Idempotent updates | Safe to retry (no duplicate creates) |
| Bulk upsert | Create or update in one operation |
ID Generation Options
generateId | Upsert Available? | Notes |
|---|---|---|
'uuid' | No | Server generates UUID |
'cuid' | No | Server generates CUID |
'nanoid' | No | Server generates nanoid |
'short' | No | Server generates 6-char alphanumeric ID (e.g. a3F9xK) |
'prefixed' | No | Server generates prefixed ID (e.g. room_abc123) |
'serial' | No | Database auto-increments |
false | Yes (if guards: false) | Client provides ID |
Compile-Time Validation
The Quickback compiler validates your guards configuration and will error if:
- Field in both
createableandprotected- A field cannot be both client-writable on create and action-only - Field in both
updatableandprotected- A field cannot be both client-writable on update and action-only - Field in both
updatableandimmutable- Contradictory: immutable fields cannot be updated - Field in
protecteddoesn't exist in schema - Referenced field must exist in the table - Field in
createable/updatable/immutabledoesn't exist in schema - All referenced fields must exist
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.
Masking - Field Redaction
Hide sensitive data from unauthorized users with automatic field masking. Secure PII and confidential fields while maintaining access for authorized roles.