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.
| Directive | Args | Applies to | Effect |
|---|---|---|---|
@min | (n) or (>n) | u32 u64 i32 i64 f64 decimal | Value must be ≥ the minimum, else 422. >n makes it exclusive |
@max | (n) or (<n) | u32 u64 i32 i64 f64 decimal | Value must be ≤ the maximum, else 422. <n makes it exclusive. Not a string-length check — use @length for strings |
@length | (count) or (min, max) | string | String length must be in range, else 422 |
@email | none | string | Must be a valid email, else 422 |
@url | none | string | Must be a valid URL, else 422 |
@pattern / @regex | (regex) | string | Value must match the regex, else 422 |
@utf8 | none | string(N) only, never an identity | Widen the inline slot to four bytes per character; without it a non-ASCII value is 422 |
@on_delete | (restrict|cascade|set_null) | FK field | On-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_deleteis 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_deleterules 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_nullon a required*Targetis 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.
| Directive | Args | Applies to | Intended meaning |
|---|---|---|---|
@default | (value) | any | Default on insert (marker only — a caller still supplies the value) |
@computed | none | any | Read-only computed field |
@fulltext | none | string | Full-text index intent |
@materialized | none | any | Materialized field |
@relations | (*) or (fields) | component ref | Component 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.
| Directive | Args | Meaning |
|---|---|---|
@index | (field1, field2, ...) | Composite index over ≥ 2 fields |
@projection | (name: col, col, ...) | A named partial-read projection |
@soft_delete | none | Enable 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.