Directives

Every @ directive in .forge — which are enforced at write time, which are semantic-only markers, and their argument syntax.

Directives are @-prefixed annotations on a field or a model. Some are enforced at write time — a violation is rejected with an HTTP status. The rest are semantic-only markers: parsed and carried through, but not yet enforced by the generated code.

Argument syntax#

Directive arguments accept numbers, bare identifiers, and quoted string literals:

age: u32 @min(13) @max(150)
status: string @default(pending)
status: string @default("pending")     // equivalent — both stored as a string
phone: string @pattern("^[0-9]+$")

Quoted strings support the escapes \", \\, \n, \t, \r. An unterminated or multiline string is a lex error.

Enforced directives#

The generated code checks these and rejects a bad write.

DirectiveArgsApplies toEffect
@min(n) or (>n)u32 u64 i32 i64 f64 decimalValue must be ≥ the minimum, else 422. >n makes it exclusive
@max(n) or (<n)u32 u64 i32 i64 f64 decimalValue must be ≤ the maximum, else 422. <n makes it exclusive. Not a string-length check — use @length for strings
@length(count) or (min, max)stringString length must be in range, else 422
@emailnonestringMust be a valid email, else 422
@urlnonestringMust be a valid URL, else 422
@pattern / @regex(regex)stringValue must match the regex, else 422
@utf8nonestring(N) only, never an identityWiden the inline slot to four bytes per character; without it a non-ASCII value is 422
@on_delete(restrict|cascade|set_null)FK fieldOn-delete referential policy

Field constraints — @min @max @length @email @url#

The generated validate_<model> enforces these at the top of insert/update; a violation is a field-constraint error (HTTP 422). A nullable field is validated only when Some.

Each numeric bound is compared in its field's own domain, so a decimal bound stays exact and a 64-bit integer bound never rounds. A bound written as 0.01 on a decimal field is the exact value one cent — it never round-trips through a binary float.

Bounds may be negative (@min(-273)) and fractional (@min(0.01)).

Product {
  price: decimal @min(0.01)      // at least one cent, exactly
  rate: f64 @min(>0) @max(<1)    // strictly between zero and one
  celsius: i32 @min(-273)
}

The exclusive forms >n and <n apply to continuous types only (f64, decimal). On an integer field >0 and >=1 are the same set, so the operator adds a spelling without adding meaning and is rejected — write the inclusive bound instead. A fractional bound on an integer field is likewise an error rather than a silent truncation.

User {
  age: u32 @min(13) @max(150)
  name: string @length(1, 100)
  email: string @email
  website: string @url
}

@pattern / @regex#

The generated validate_<model> compiles a per-field regex and rejects a non-matching string with a field-constraint error (HTTP 422). A nullable field is validated only when Some.

User {
  handle: string @pattern("^[a-z0-9_]{3,20}$")
}

@utf8#

Only meaningful on an inline string(N). That type reserves one byte per character, so a non-ASCII value is rejected with a 422; @utf8 widens the reservation to four bytes per character and changes nothing else. N still counts characters either way.

Article {
  id: +uuid
  slug: string(64)              // ASCII only
  title: string(60) @utf8       // any Unicode, four bytes reserved per character
}

@utf8 on a bare string — or on any non-string field — is a schema error rather than a no-op: a bare string is already UTF-8 and lives in the variable-length column, so there is nothing there to widen, and silently accepting it would read as "this field is now multi-byte".

It is also a schema error on a model's identity. A key's value is restricted to the characters that survive a URL path segment unescaped, which is a strict subset of ASCII — so widening the slot would reserve four bytes per character to hold characters the write path rejects anyway.

Note that the length directives go the other way on an inline string: the width in the type is already the bound, so @max and the upper-bound spellings of @length are schema errors there. See scalar types.

@on_delete#

Declares what happens to a child when its referenced parent is deleted. Enforced in the generated Database::delete_<parent> wrapper (which the REST DELETE route goes through):

  • restrict (the default when @on_delete is absent) — refuses to delete a parent that still has a live child referencing it → 409.
  • cascade — recursively deletes every referencing child; each child's own @on_delete rules fire, and a cycle is bounded by a max depth.
  • set_null — nulls each referencing child's FK. Valid only on an optional FK (?Target); set_null on a required *Target is a hard codegen error.
Post {
  author: *User @on_delete(cascade)      // deleting a user deletes their posts
  category: ?Category @on_delete(set_null)
}

Semantic-only markers#

These parse and are carried through, but the generated code does not enforce them. Use them to document intent; do not rely on them for integrity.

DirectiveArgsApplies toIntended meaning
@default(value)anyDefault on insert (marker only — a caller still supplies the value)
@computednoneanyRead-only computed field
@fulltextnonestringFull-text index intent
@materializednoneanyMaterialized field
@relations(*) or (fields)component refComponent relation inclusion

Markers are documentation, not behavior

@default, @computed, @fulltext, and @materialized are parsed and preserved but have no runtime effect yet — for example @default(0) does not populate a value; the caller must still supply one. The enforced set is the table above (@min/@max/@length/ @email/@url, @pattern/@regex, @utf8, and @on_delete).

Model-level directives#

These appear on their own line inside a model block, not attached to a field.

DirectiveArgsMeaning
@index(field1, field2, ...)Composite index over ≥ 2 fields
@projection(name: col, col, ...)A named partial-read projection
@soft_deletenoneEnable soft delete for the model
Order {
  id: +uuid
  user_id: uuid
  created_at: timestamp
  @index(user_id, created_at)
  @projection(card: id, created_at)
  @soft_delete
}

See indexes & projections for @index and @projection in depth.

`@relations` is component-only

@relations(*) / @relations(a, b) is valid only on a component-reference field (tsx://, jsx://, api://). Using it on a scalar field is a parse error.

Search documentation

Find pages across the ForgeDB docs