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 TABLEServer-only table. Excluded from sync entirely (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;

Keeping a table off the client (@nosync)

Put -- @nosync on the line above a DEFINE TABLE to make it server-only, useful for audit logs, bookkeeping, or anything that should live in your database but never reach a browser.

-- @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();

A @nosync table is:

  • Omitted from generated types (TypeScript / Dart / JSON Schema).
  • Omitted from relations: any record<...> field on another table pointing at it 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.
Not the same as locking permissions

@nosync is distinct from PERMISSIONS FOR select WHERE false. A permission-locked table is still synced (it shows up in generated types and the snapshot, it just can’t be read); @nosync removes the table from sync altogether. Reach for @nosync when a table should never leave the server.

Next