Skip to content
spockIt’s only logical !
Non-normative guide. This page shows how to use Spock; the v0 specification governs where they disagree. Spock is pre-1.0 and interfaces may change — see project status.

Tables, types, and defaults

A table declares durable truth — the data the authority owns and answers for. Everything else in a Spock program exists in relation to tables: functions read and guard them, the derived API exposes them, seed fills them. This page covers the whole table surface: fields and types, keys, optionality, defaults, uniqueness, references, closed-set types, and validator checks.

What a table is

Each table compiles to exactly one SQLite table, with declaration order preserved: columns appear in the order you wrote the fields (v0 specification, §7.1). The database file is plain SQL that any SQLite reader can open: types, keys, uniques, checks, and foreign keys all land as ordinary DDL. The database is rebuilt from source and seed on every start; Seed and disposable state covers that lifecycle.

table user {
key id: uuid = auto
username: text unique
}
table post {
key id: uuid = auto
author: user on delete cascade
slug: text
body: text
published_at: timestamp?
unique (author, slug)
}

This is a complete program. spock check compiles it, and spock run serves a working API from it — the floor described in The derived API.

Fields and builtin types

A field is name: type plus optional modifiers, in a fixed order:

[key] name: type [?] [check fn] [unique] [= default]
[on delete restrict|cascade|set null]

Identifiers are lowercase snake_case. There are six builtin types (v0 specification, §5.1):

TypeJSON on the wireSQLite storageNotes
textstringTEXT
intnumber (integer)INTEGERsigned 64-bit
floatnumberREALIEEE 754 double; NaN and infinity have no JSON spelling and render as null
boolbooleanINTEGER (0/1)converted at the edge
timestampstring, RFC 3339TEXTstored canonically in UTC with fixed six-digit fractional seconds, so text order is chronological order; inputs in any RFC 3339 offset are canonicalized on write
uuidstring, hyphenatedTEXTstored lowercase-canonical

A type that names another table is a reference — covered below. A type that is a set of string literals is a closed set — also below.

Keys

Every table declares exactly one key. A single-field key is the inline modifier; a multi-field key is the composite form:

table session {
key token: uuid = auto
label: text
}
table membership {
key (org, member)
org: text
member: text
}

The key is the row’s identity: it names the row in by-key reads, it is what references store, and it is immutable — an update can never touch a key field, because changing the identity would be a different row. Key fields cannot be optional. A table with no key is diagnostic E005; a table with two is E006:

table user {
key id: uuid = auto
key email: text
}

Optionality

? after the type makes a field optional. Optionality is absence, not a value: an absent optional field is JSON null on the wire and SQL NULL in storage, and providing null for a required field is the same validation error as omitting it. There is no distinction to learn between “not set” and “set to null” — they are one state.

The single carve-out is update inputs, where a change set must be able to say both “keep this field” and “clear this field”; The derived API covers that write semantics.

Defaults

A default is = value after the type, and it fills the field when an insert omits it:

table article {
key id: uuid = auto
title: text
status: "draft" | "published" | "archived" = "draft"
pinned: bool = false
created_at: timestamp = now
}

Three kinds of default exist (v0 specification, §5.2):

  • auto, on uuid fields: the runtime generates a UUIDv7 at insert. UUIDv7 is time-ordered, so key order roughly tracks insert order.
  • now, on timestamp fields: the runtime captures UTC now at insert.
  • A literal, on text, int, float, and bool fields — and on a closed-set field, where the literal must be one of the set’s values.

Defaults apply at insert only; an update never applies them, because an update edits a row that already has every value. auto and now are also installed as engine DEFAULT clauses backed by the same generators the runtime uses, so SQL written in function bodies receives identical values and the two paths cannot drift.

The fourth default, = me, stamps the current actor into a reference to the program’s auth table at insert; it belongs to the identity seam and is covered in Identity: the actor seam.

Unique fields and groups

unique on a field constrains that one column; unique (a, b) as a table item constrains the combination. The user and post tables above show both: every username is distinct, and each author can use a slug once while different authors can share one.

Rows in which an optional unique field is absent never conflict with each other. Absence is not a value, so two absences are not the same value — this is the standard SQL UNIQUE treatment of NULL, and Spock keeps it.

References

A field whose type is another table’s name is a reference: it holds the key of a row in that table. In the program at the top of this page, author: user stores a user key, and the runtime rejects any write whose value does not name an existing row. References can only target tables with a single-field key — there is no composite-key reference target in v0 (diagnostic E010). A table can reference itself: a threaded comment table carries reply_to: comment?.

on delete declares what happens to the referencing row when the target row is deleted:

  • restrict — the default: the delete is refused while referencing rows exist.
  • cascade — deleting the target deletes the referencing rows.
  • set null — deleting the target clears the reference field, which therefore must be optional; a required field has no cleared state to take:
table user {
key id: uuid = auto
username: text unique
}
table post {
key id: uuid = auto
author: user on delete set null
body: text
}

Change author to user? and the program compiles.

Join tables

A reference field can be a member of a composite key, and that combination is the join-table pattern — many-to-many association without an artificial surrogate id:

table user {
key id: uuid = auto
username: text unique
}
table follow {
key (follower, followed)
follower: user on delete cascade
followed: user on delete cascade
at: timestamp = now
}

The composite key makes each pairing exist at most once, and cascade on both sides means deleting a user dissolves their follow edges instead of stranding them.

Closed-set types

A type written as string literals joined by | is a closed set: the field’s value is one of the listed strings, checked at every layer.

table ticket {
key id: uuid = auto
title: text
priority: "low" | "normal" | "urgent" = "normal"
}

Storage is TEXT plus a named CHECK constraint — the constraint is named <table>_<field>_invalid, and that name is verbatim the derived error code a violating write receives, so the database itself routes the failure. A set needs at least two distinct, non-empty values.

A closed set is a stored table-field type only in v0: it cannot be a key field, a record field, or a fn parameter. The set’s members also bound its defaults and seed values — = "normal" compiles because "normal" is a member.

Validator check functions

For rules beyond membership in a set, a field or a group of fields can name a validator function:

fn nonempty(value: text) -> bool {
unchecked sql("SELECT length(trim(:value)) > 0")
}
fn valid_range(low: int, high: int) -> bool {
unchecked sql("SELECT :low <= :high")
}
table booking {
key id: uuid = auto
guest_name: text check nonempty
starts_at: int
ends_at: int
check (starts_at, ends_at) valid_range
}

The field form is name: type check fn; the row form is check (a, b) fn as a table item, mirroring the shape of a unique group. Parameters bind to the checked fields positionally, so valid_range receives starts_at and ends_at in that order.

A validator is an unmarked (read) fn returning bool from a single clauseless SELECT: one deterministic boolean expression over its parameters, with no FROM, no WHERE, and no subquery. The laws are strict because of what the compiler does with the body — it inline-expands into a SQL CHECK constraint, each :param replaced by its bound column, named <table>_<fields>_invalid like a closed set’s constraint. The database file stays pure SQL, and the rule is enforced by the engine for every write path, including SQL in function bodies that bypasses the floor’s validation.

A validator is also an ordinary read function: it appears on the derived Query surface like any other fn, so a client can call nonempty or valid_range directly and pre-validate a form against the exact rule the database will enforce. A check cannot attach to a closed-set field, which already validates itself, or to an auto/now-defaulted field, whose value the runtime mints.

Every constraint is API

Each construct on this page mints a derived error with a stable code — user_username_taken from a unique field, post_author_slug_taken from the group, post_author_not_found from the reference, ticket_priority_invalid from the closed set, booking_starts_at_ends_at_invalid from the row check — and shapes the derived read and write surface every table receives. The derived API walks that surface, error vocabulary and all.