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.
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.
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.
| Descriptor | Placement | Effect |
|---|---|---|
-- @nosync | above a DEFINE TABLE or DEFINE FIELD | Server-only table or field. Excluded from sync entirely (see below). |
-- @opaque | above a DEFINE FIELD | Field 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 | above a DEFINE FIELD | Marks a collaborative text field backed by a Loro CRDT. See CRDT fields. |
-- @cursor | above a DEFINE FIELD (with @crdt) | Stores per-session cursors alongside the CRDT snapshot. |
-- @parent | suffix on a DEFINE FIELD ... TYPE record<...> | Marks the parent side of a relationship; written automatically from the auth context, never by client code. |
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.
A @nosync table is:
- Omitted from generated types (TypeScript / Dart / JSON Schema).
- Omitted from relations: any
record<...>field on another table pointing at a@nosynctable 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.
@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.
An @opaque field is:
- Present in generated types and in the client’s local cache, flagged
opaque: trueon 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
@opaquefield 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.
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.