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 textabove a DEFINE FIELDMarks a collaborative text field backed by a Loro CRDT. See CRDT fields.
-- @cursorabove a DEFINE FIELD (with @crdt)Stores per-session cursors alongside the CRDT snapshot.
-- @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 text                       -- collaborative text field (Loro CRDT)
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.

Next