Github|...

Define your schema

Your .surql files are the single source of truth. Everything else (client types, sync, permissions) is derived from them.

You write plain SurrealQL in .surql files. Sp00ky reads them and derives your client types, your sync rules, and your row-level permissions from the same definitions.

schema/src/schema.surql
DEFINE TABLE user SCHEMAFULL;

DEFINE FIELD username   ON TABLE user TYPE string;
DEFINE FIELD created_at ON TABLE user TYPE datetime VALUE time::now();

That’s the whole contract. Run spky generate and the matching User type appears in your app, with username as a string and created_at as a Date.

Fields carry the rules

Put constraints in the schema, not in your app code. ASSERT rejects bad writes at the database, and PERMISSIONS are enforced for every client, including the ones you didn’t write.

schema/src/schema.surql
DEFINE TABLE user SCHEMAFULL
  PERMISSIONS
    FOR select, create WHERE true
    FOR update, delete WHERE id = $auth.id; -- only the user can modify their own record

DEFINE FIELD username ON TABLE user TYPE string
  ASSERT $value != NONE AND string::len($value) > 3;

DEFINE FIELD created_at ON TABLE user TYPE datetime
  VALUE time::now();

DEFINE INDEX unique_username ON TABLE user FIELDS username UNIQUE;

PERMISSIONS also shape what live queries return: a client subscribed to user only ever receives rows the FOR select clause admits. See Authentication for how $auth is populated and which permission expressions the sync engine can evaluate.

Annotations

Sp00ky reads special comment descriptors in your .surql files. They look like ordinary SurrealQL comments (-- @name), so SurrealDB ignores them, but the CLI uses them to change how a table or field is generated and synced.

DescriptorPlacementEffect
-- @nosyncabove a DEFINE TABLE or DEFINE FIELDServer-only table or field. Excluded from sync entirely (see below).
-- @opaqueabove a DEFINE FIELDField is synced to the client but never stored by the sync engine, so it can’t be filtered or ordered on (see below).
-- @crdt text / -- @crdt prosemirrorabove a DEFINE FIELD ... TYPE stringCollaborative field backed by a Loro document. Edits travel through a generated edit log; the field itself holds the server-written plain text (see below).
-- @channelabove a DEFINE TABLEClients may open peer to peer channels on the table’s rows (.channel(peers) on a query); updates reach the other participants directly, on top of the normal sync.
-- @ephemeralabove a DEFINE FIELD (of a @channel table, TYPE option<...>)Field only travels between peers over channels and is never written to the database.
-- @cursorabove a DEFINE FIELD (with @crdt)Adds per-session presence (remote cursors) for that field, in a generated <table>__crdt_presence table.
-- @parentsuffix on a DEFINE FIELD ... TYPE record<...>Marks the parent side of a relationship; written automatically from the auth context, never by client code.
-- @crdt prosemirror
-- @cursor
DEFINE FIELD content ON TABLE thread TYPE string;

DEFINE FIELD author ON TABLE thread TYPE record<user>; -- @parent

-- @nosync
DEFINE TABLE audit_log SCHEMALESS;

A descriptor must sit directly above its statement, with no blank line between. A marker that attaches to nothing is reported as a warning by spky generate rather than silently ignored.

A descriptor is the marker alone on its comment line, plus one value for the markers that take one (-- @crdt text). Anything longer is read as an ordinary comment, so you can write about a marker without applying it: -- @nosync would break the client here does not make the next table server-only. A single stray word on a marker that takes no value (-- @nosync true) is refused with a warning rather than guessed at.

Keeping a table or field off the client (@nosync)

Put -- @nosync on the line above a DEFINE TABLE or DEFINE FIELD to make it server-only. This is useful for audit logs, bookkeeping, and internal data that should live in your database but not in your client’s cache.

-- @nosync
DEFINE TABLE audit_log SCHEMALESS;
DEFINE FIELD action ON TABLE audit_log TYPE string;
DEFINE FIELD at     ON TABLE audit_log TYPE datetime VALUE time::now();

DEFINE TABLE user SCHEMALESS;
DEFINE FIELD username ON TABLE user TYPE string;
-- @nosync
DEFINE FIELD import_batch_id ON TABLE user TYPE option<string>;

A @nosync table is:

  • Omitted from generated types (TypeScript / Dart / JSON Schema).
  • Omitted from relations: any record<...> field on another table pointing at a @nosync table is dropped from the generated relationships.
  • Never synced: no sync events are emitted, so nothing flows to the scheduler or SSP.
  • Excluded from the scheduler snapshot and from SSP bootstrap.
  • Still stored in the main database, and still included in backups.

A @nosync field is:

  • Omitted from generated types and from the client’s local cache schema, so it never lands in the local database and is stripped from synced records before they are written.
  • Omitted from sync event payloads, and excluded from the scheduler replica and from SSP bootstrap, so no server-side component holds the value.
  • Still stored in the main database, and still included in backups.
A field-level `@nosync` is not a read barrier

@nosync controls what the sync engine stores, not what SurrealDB will return. A client’s down-sync issues a SELECT against your database, so a @nosync field’s value still travels over that connection before being discarded on arrival. For data that must never reach the client at all, put the barrier in the database: PERMISSIONS FOR select WHERE false on the field, or keep the field on a @nosync table (whose rows are never fetched). Use a field-level @nosync to keep server-side bookkeeping out of your generated types and out of every client’s cache, not to keep a secret.

Syncing a field without indexing it (@opaque)

-- @opaque is the middle ground between a normal field and @nosync: the value is synced to the client and readable from query results, but no server-side component ever stores it. Good for large blobs and payloads you render but never query on: thumbnails, serialized editor state, attachments.

DEFINE TABLE document SCHEMAFULL;
DEFINE FIELD title      ON TABLE document TYPE string;
DEFINE FIELD updated_at ON TABLE document TYPE datetime;

-- @opaque
DEFINE FIELD thumbnail ON TABLE document TYPE option<bytes>;

-- Fine: title is a normal field.
--   db.document.where({ title_contains: 'draft' }).orderBy('updated_at', 'desc')
-- Throws: thumbnail is @opaque, the sync engine has no value to compare.
--   db.document.where({ thumbnail: someBytes })

An @opaque field is:

  • Present in generated types and in the client’s local cache, flagged opaque: true on the column.
  • Read from your database on demand. Sp00ky’s sync payloads carry record ids and versions, not field values, so the client fetches the row body from SurrealDB directly and picks the field up for free. Writing only an @opaque field still bumps the record version, so subscribed clients refetch.
  • Never held by the scheduler replica or an SSP, which is the point: a multi-megabyte blob does not need to sit in the sync engine’s memory on every node to be delivered.
`@opaque` fields can't be queried on

Because no server-side component holds the value, it cannot be evaluated. Using an @opaque field in where, orderBy, a join, or a table’s PERMISSIONS expression is rejected: the query builder throws, the SSP refuses the registration with a 400, and spky deploy fails on a schema whose PERMISSIONS or DEFINE INDEX references one. This is deliberate — such a comparison would appear to work against the local cache while matching nothing server-side, so rows would flicker in and out instead of erroring.

Collaborative fields (@crdt, @cursor)

Put -- @crdt prosemirror (a rich-text tree for ProseMirror-based editors) or -- @crdt text (one plain LoroText) above a TYPE string field, and add -- @cursor for remote cursors. Clients edit the field through useCrdtField, never with db.update.

-- @crdt prosemirror
-- @cursor
DEFINE FIELD content ON TABLE thread TYPE string;

-- @crdt text
DEFINE FIELD summary ON TABLE thread TYPE option<string>;

For each table with @crdt fields, spky generate and spky migrate add:

  • <table>__crdt_log, an append-only log of each client’s edits. Clients create rows where the table’s update rule allows (rebased through the row’s doc link), read them where its select rule allows, and never change or delete them.
  • <table>__crdt_presence (only with @cursor), one row per editing session and field.
  • _00_crdt_doc and _00_crdt_peer, internal: the canonical snapshot the SSP folds the log into, and which user owns which Loro peer id.
  • <field>__crdt on the table itself, the version stamp each fold writes.

The annotated field becomes the document’s plain text, written by the server on every fold; a client write leaves it unchanged. It is an ordinary synced column: render it, filter and order on it, or add @opaque to keep the text out of the sync engine. The field must be TYPE string (or option<string>) with no VALUE or ASSERT, sit on a top-level field of a non-relation table, and the surrealism deploy mode refuses it. See CRDT fields for the client API and how the fold works.

Next