surformSurrealDB schemas, declared

Mixins

When several tables share the same columns, a mixin defines the group once and each table spreads it: mixin holds member definitions — fields, indexes, and events — and ...name; copies them into the table at compile time.

The mechanism

applied to SurrealDB 3.3.0
schema.surdl
sdl
namespace acme;
database shop;

mixin timestamped {
  created_at: datetime { default: time::now(); };
  updated_at: datetime { default: time::now(); };
};

table post schemafull {
  ...timestamped;
  title: string;
};

table comment schemafull {
  ...timestamped;
  body: string;
};
surform build
sql
DEFINE NAMESPACE OVERWRITE acme;
USE NS acme;
DEFINE DATABASE OVERWRITE shop;
USE NS acme DB shop;
DEFINE TABLE OVERWRITE `post` TYPE NORMAL SCHEMAFULL;
DEFINE FIELD OVERWRITE created_at ON `post` TYPE datetime DEFAULT time::now();
DEFINE FIELD OVERWRITE updated_at ON `post` TYPE datetime DEFAULT time::now();
DEFINE FIELD OVERWRITE title ON `post` TYPE string;
DEFINE TABLE OVERWRITE `comment` TYPE NORMAL SCHEMAFULL;
DEFINE FIELD OVERWRITE created_at ON `comment` TYPE datetime DEFAULT time::now();
DEFINE FIELD OVERWRITE updated_at ON `comment` TYPE datetime DEFAULT time::now();
DEFINE FIELD OVERWRITE body ON `comment` TYPE string;

The database never sees a mixin. At emission each spread expands to the members as if you had written them in the table — in place, at the spread's position. A member is an ordinary nested definition, so everything in the fields page applies to field members, and an index or event member follows its own grammar — on: never appears on a member; the spreading table becomes the on at expansion:

applied to SurrealDB 3.3.0
schema.surdl
sdl
namespace acme;
database shop;

mixin slug_source {
  slug: string;
  index slug_u {
    fields: slug;
    concurrently;
  };
  event slug_touched {
    when: slug != '';
    then: 1;
  };
};

table article schemafull {
  ...slug_source;
  body: string;
};
surform build
sql
DEFINE NAMESPACE OVERWRITE acme;
USE NS acme;
DEFINE DATABASE OVERWRITE shop;
USE NS acme DB shop;
DEFINE TABLE OVERWRITE article TYPE NORMAL SCHEMAFULL;
DEFINE FIELD OVERWRITE slug ON article TYPE string;
DEFINE INDEX OVERWRITE slug_u ON article FIELDS slug CONCURRENTLY;
DEFINE EVENT OVERWRITE slug_touched ON article WHEN slug != '' THEN 1;
DEFINE FIELD OVERWRITE body ON article TYPE string;

Explicit members overshadow

An explicit field, index, or event with the same name as a member replaces that member in that table — completely, wherever the two sit in the block, with no clause merging:

applied to SurrealDB 3.3.0
schema.surdl
sdl
namespace acme;
database shop;

mixin stamp {
  created_at: datetime { default: time::now(); };
  updated_at: datetime;
};

table invoice schemafull {
  ...stamp;
  // the explicit field wins on this table — the mixin's
  // `created_at` (with its default) never lands here:
  created_at: datetime;
  amount: decimal;
};
surform build
sql
DEFINE NAMESPACE OVERWRITE acme;
USE NS acme;
DEFINE DATABASE OVERWRITE shop;
USE NS acme DB shop;
DEFINE TABLE OVERWRITE invoice TYPE NORMAL SCHEMAFULL;
DEFINE FIELD OVERWRITE updated_at ON invoice TYPE datetime;
DEFINE FIELD OVERWRITE created_at ON invoice TYPE datetime;
DEFINE FIELD OVERWRITE amount ON invoice TYPE decimal;

A field x and an index x never collide — fields, indexes, and events override per kind. Other tables spreading the same mixin are unaffected — overriding is per-table.

Conflicts between spreads resolve last-wins

Two spreads carrying the same member name compile to the LATER spread's definition — the earlier one is skipped at expansion, never defined-then-overwritten. A silent winner between two third-party mixins is easy to miss, so the linter flags every such shadow:

real Surform lint findings
this file lints
sdl
namespace acme;
database shop;

mixin short_id {
  id_hint: int;
};

mixin long_id {
  id_hint: string;
};

table post schemafull {
  ...short_id;
  // `id_hint` compiles to `string` — long_id is the later spread:
  ...long_id;
};
what Surform suggests
text
SDL-L0022 (spec/mixin.md)

  ! table `post` in database `acme/shop`: the `long_id` spread's field `id_hint` shadows `short_id`'s — the later spread wins at expansion
    ,-[15:3]
 14 |   // `id_hint` compiles to `string` — long_id is the later spread:
 15 |   ...long_id;
    :   ^^^^^|^^^^
    :        `-- the shadowing spread
 16 | };
    `----
  help: the later spread's field wholly wins at expansion — rename one member, or add an explicit field on the table to decide

An explicit member decides instead — wherever it sits — and its overshadow is deliberate, so it stays unlinted. Spreading the same mixin twice is still an error (SDL-E1064).

The rules mixins play by

  • Top level only, resolved document-wide. A mixin is a top-level statement and a spread lives in a non-view table block; position is free — a spread resolves against every mixin the document defines, above or below it, in the file or composed into it.
  • No doc comments. /// is rejected on the statement (it emits nothing, so there is no COMMENT to hang one on) and on members (the nested-statement rule).
  • Conflicts resolve last-wins. Between spreads, the later spread's member wholly wins (SDL-L0022 advises on the shadow); an explicit member decides position-independently; spreading the same mixin twice is an error.
  • An unspread mixin emits nothing. The linter flags it (SDL-L0021) — dead weight in the file.
  • One key per body. The same member key twice in one body — a mixin's or a table's — is an error (SDL-E1067): the engine's last-wins would silently hide one of them.
  • normalize keeps your spreads. Adopting a database into a mixin-carrying file merges per member: a member whose database state matches the definition stays covered by its spread, and a diverged one materializes as an explicit statement on the table — the override you would have written by hand:
applied to SurrealDB 3.3.0
schema.surdl
sdl
mixin stamp {
  created_at: datetime;
  updated_at: datetime;
};

table post schemafull {
  ...stamp;
  title: string;
};
surform build
sql
DEFINE TABLE OVERWRITE `post` TYPE NORMAL SCHEMAFULL;
DEFINE FIELD OVERWRITE created_at ON `post` TYPE datetime;
DEFINE FIELD OVERWRITE updated_at ON `post` TYPE datetime;
DEFINE FIELD OVERWRITE title ON `post` TYPE string;
the engine's own record of it (canonical form)
sdl
namespace doc;

database doc;

mixin stamp {
  field created_at: datetime;
  field updated_at: datetime;
};

table `post` {
  schemafull;
  field title: string;
  ...stamp;
};

The info panel's canonical readback is the same file — the spread survives the round trip. When the database's created_at has drifted (a default added by hand), surform normalize materializes the diverged member as the explicit override:

applied to SurrealDB 3.3.0
schema.surdl
sdl
mixin stamp {
  created_at: datetime;
  updated_at: datetime;
};

table post schemafull {
  created_at: datetime {
    default: time::now();
  };
  title: string;
  ...stamp;
};
surform build
sql
DEFINE TABLE OVERWRITE `post` TYPE NORMAL SCHEMAFULL;
DEFINE FIELD OVERWRITE created_at ON `post` TYPE datetime DEFAULT time::now();
DEFINE FIELD OVERWRITE title ON `post` TYPE string;
DEFINE FIELD OVERWRITE updated_at ON `post` TYPE datetime;

The spread survives — updated_at is still covered by it — and the override is exactly the overshadow rule performing the adoption. migrate generate reports the drift on the member itself, never on the mixin:

live drift against SurrealDB 3.3.0
your schema file
sdl
mixin stamp {
  created_at: datetime;
  updated_at: datetime;
};

table post schemafull {
  ...stamp;
  title: string;
};
someone ran this by hand
sql
DEFINE FIELD OVERWRITE created_at ON `post` TYPE datetime DEFAULT time::now();
surform migrate generate — the drift report
text
1 finding - 1 define, 0 removals, 0 data-migration slots, 0 staged tables
~ db:doc/doc field created_at
    default: (only in database) `default: time::now();`
the convergence script (one transaction — SURFORM WARNING marks the data-destroying lines)
sql
-- generated by `surform migrate generate` - the
--    convergence script.
-- Re-runs are safe: OVERWRITE redefines; IF EXISTS
--    no-ops on absence.
-- Lines marked SURFORM WARNING destroy data;
--    SURFORM REQUIRED marks fill-in data-migration
--    slots; SURFORM NOTE lines are advice.
-- summary: 1 finding - 1 define, 0 removals, 0
--    data-migration slots, 0 staged tables, 1 frame

USE NS doc DB doc;

-- ------------------------------------------------------------
-- ## Scope db:doc/doc
-- section: db:doc/doc (1 define, 0 removals)

BEGIN TRANSACTION;

-- ------------------------------------------------------------
-- ## Defines - the state the schema file describes
--
-- Each define carries OVERWRITE: a re-run redefines it.

DEFINE FIELD OVERWRITE created_at ON `post` TYPE datetime;

COMMIT;

The reference errors

real Surform diagnostics
this schema is wrong on purpose
sdl
namespace acme;
database shop;

table post schemafull {
  ...timestamped;
  title: string;
};
what Surform reports
text
SDL-E1061 (spec/mixin.md)

  x spread names no mixin of the document: `timestamped`
   ,-[5:3]
 4 | table post schemafull {
 5 |   ...timestamped;
   :   ^^^^^^^|^^^^^^
   :          `-- no mixin of this name in the document
 6 |   title: string;
   `----
  help: define the mixin anywhere in this document — spreads resolve against the whole composition, any file, any position; a spread that names nothing is an error, never a silent empty expansion

The spread names no mixin of the document (SDL-E1062 duplicate name, across composed files too; SDL-E1064 double spread, SDL-E1065 a spread in the wrong block, and SDL-E1067 a duplicate member key within one body complete the family — see the mixin spec).

Where next

  • Fields — the grammar a mixin's members follow.
  • The linter — the unspread-mixin advisory.