Constraints, defaults & permissions

A field carries more than a type. Schemic’s $-prefixed methods attach SurrealQL clauses that SurrealDB enforces on every write, regardless of whether the write came through your app. This guide covers the four you reach for most: assertions, defaults, read-only, and permissions.

All of these are clauses on a DEFINE FIELD; see from schema to DDL for how they compose.

Assert a constraint

Use .$assert(expr) to emit an ASSERT clause. The expression is raw SurrealQL written with the surql tag, and $value refers to the field’s value:

schema.ts TypeScript
export const Product = defineTable("product", {
  id: s.string(),
  price: s.decimal().$assert(surql`$value > 0`),
  sku: s.string().$assert(surql`string::len($value) = 12`),
});

produces:

Generated DDL SurrealQL
DEFINE TABLE product TYPE NORMAL SCHEMAFULL;
DEFINE FIELD price ON TABLE product TYPE decimal ASSERT $value > 0;
DEFINE FIELD sku ON TABLE product TYPE string ASSERT string::len($value) = 12;

For common rules, the string formats write the assertion for you — s.email() emits ASSERT string::is_email($value), s.url() emits ASSERT string::is_url($value), and so on. Reach for .$assert when no format fits.

Zod’s own constraint helpers are also available as $-methods that translate to SurrealQL: .$min, .$max, .$length, .$regex, .$gt, .$gte, .$lt, .$lte.

Set a default

.$default(expr) emits a DEFAULT clause: a value SurrealDB applies when the field is absent on insert. It is usually a surql expression:

TypeScript
createdAt: s.datetime().$default(surql`time::now()`),
status: s.string().$default("draft"),

A field with a $default becomes optional in the create shape, because the database fills it — so you omit it when calling encode.

Two related clauses:

  • .$defaultAlways() emits DEFAULT ALWAYS, reapplying the default on update as well as insert.
  • .$value(expr) emits a VALUE clause that always computes the stored value from the expression, overriding any input.

Make a field read-only

.$readonly() emits READONLY: SurrealDB allows the field on create but rejects any later change to it. Common for createdAt:

TypeScript
createdAt: s.datetime().$default(surql`time::now()`).$readonly(),

Restrict access with permissions

.$permissions(spec) emits a field-level PERMISSIONS clause, controlling which record-level operations may touch the field. Pass an object keyed by operation, each value a surql condition:

schema.ts TypeScript
export const User = defineTable("user", {
  id: s.string(),
  email: s.email(),
  ssn: s.string().$permissions({
    select: surql`$auth.admin = true`,
    update: false,
  }),
});

produces:

Generated DDL SurrealQL
DEFINE TABLE user TYPE NORMAL SCHEMAFULL;
DEFINE FIELD email ON TABLE user TYPE string ASSERT string::is_email($value);
DEFINE FIELD ssn ON TABLE user TYPE string PERMISSIONS FOR select WHERE $auth.admin = true, FOR update NONE;

A surql condition becomes FOR <op> WHERE <condition>; true becomes full access for that operation, false becomes NONE. Table-level permissions work the same way via .permissions(...) on the table — see the definers reference.

Verify it

Generate a migration and read the emitted clauses, or preview against a running database:

Shell
npx schemic diff --live

Where to go next