The SQL surface skaidb accepts. It's a subset of SQL — one statement per call,
no subqueries or CTEs (joins, UNION, aggregates, prepared statements, and
embedded-engine transactions are supported; see below). Rows are schema-less
documents keyed by a declared primary key; any field not present reads as
NULL.
Maintenance: this document is the source of truth for the query language.
Whenever the parser/grammar changes — a new statement, clause, operator,
literal form, function, or type — update this file in the same change.
Statements
A <table> reference may be qualified by a database: <database> . <table>
(e.g. shop.orders). An unqualified table resolves against the connection's
current database (see Databases below).
-- DML upsert
INSERT INTO <t> (...) [OVERRIDING SYSTEM VALUE] VALUES (...)
[ON CONFLICT DO NOTHING -- keep existing row; affected 0
| ON CONFLICT DO UPDATE SET <col> = <expr>, ...] -- merge into existing row
[RETURNING * | <expr> [AS <alias>] [, ...]] -- the rows as they landed
-- DDL
CREATE TABLE [IF NOT EXISTS] <table> (PRIMARY KEY (<col> [, <col> ...])
[, <col> [NOT NULL] [DEFAULT <expr>] [CHECK (<expr>)]] -- column item (no type: see below)
[, <col> {SERIAL | BIGSERIAL | GENERATED {ALWAYS | BY DEFAULT} AS IDENTITY [(<seq-option> ...)]}
[NOT NULL] [CHECK (<expr>)]] -- auto-increment column (owned sequence)
[, <col> GENERATED ALWAYS AS (<expr>) STORED [NOT NULL] [CHECK (<expr>)]] -- generated column
[, [CONSTRAINT <name>] CHECK (<expr>)]
[, [CONSTRAINT <name>] FOREIGN KEY (<col> [, ...]) REFERENCES <parent> (<col> [, ...])
[ON DELETE {RESTRICT | NO ACTION | CASCADE | SET NULL}]
[ON UPDATE {RESTRICT | NO ACTION | CASCADE | SET NULL}] ...])
[WITH (ttl = <duration>, -- rows expire after this age
witness = <bool>, -- mirror to witness nodes (default true)
replication = <n>, -- per-table RF override
nodes = ['<alias-or-id>', ...], -- pin the whole table to these members
toast_threshold = <bytes>, -- store big fields out-of-line (value-TOAST)
partition_by = 'range(<col>, <interval>)', -- range partitioning (see below)
cluster_by = (<col> [, ...]), -- physical sort + identity (see below)
distribute_by = (<col> [, ...]))] -- place by this leading PK prefix:
-- rows sharing it share a replica set
-- (fixed at CREATE)
CREATE TIMESERIES TABLE [IF NOT EXISTS] <table>
(SERIES KEY (<label> [, <label> ...])
[, RETENTION <duration>] [, OOO <duration>])
CREATE ROLLUP [IF NOT EXISTS] <name> ON <ts-table> BUCKET <duration>
[RETENTION <duration>]
CREATE ROLLUP [IF NOT EXISTS] <name> ON <event-table> BUCKET <duration>
[BY (<dim> [, <dim> ...])]
AGGREGATE (count(*)|count(<col>)|sum(<col>)|min(<col>)|max(<col>) AS <alias> [, ...])
[REFRESH <duration>] [RETENTION <duration>]
-- event-table form: pre-aggregated bucket table over a
-- partitioned/clustered regular table (see below)
SHOW PARTITIONS <table> -- a range-partitioned table's partitions:
-- start/end, rows, disk bytes, serving
DROP TABLE [IF EXISTS] <table>
-- cascades to every derived index on the table (secondary, search, vector)
CREATE [UNIQUE] INDEX [IF NOT EXISTS] <name> ON <table> (<component> [, ...]) [WITH (global = true)] [WHERE <expr>]
-- <component> := <path>[[]] | (<expr>) -- an expression component; WHERE = a partial index
-- WITH (global = true): value-sharded index — equality probes route to the value's
-- replica set instead of scattering; ranges keep the scatter path (GLOBAL_INDEXES.md)
DROP INDEX [IF EXISTS] <name>
CREATE VECTOR INDEX [IF NOT EXISTS] <name> ON <table> (<path>) DIM <n> [USING <metric>] [QUANTIZED] [EMBED]
DROP VECTOR INDEX [IF EXISTS] <name>
ALTER VECTOR INDEX <name> SET (ef = <n>) -- live recall/latency tuning
CREATE GEO INDEX [IF NOT EXISTS] <name> ON <table> (<point-path>)
-- Morton/Z-order index: geo_distance / geo_bbox prune to a neighborhood
DROP GEO INDEX [IF EXISTS] <name>
CREATE SEARCH INDEX [IF NOT EXISTS] <name> ON <table> (<path> [, <path> ...])
[WITH (<option> = <literal> [, <option> = <literal> ...])]
-- options are global (analyzer, refresh_ms) or per-column (<path>.<option>)
DROP SEARCH INDEX [IF EXISTS] <name>
REBUILD SEARCH INDEX <name>
ALTER SEARCH INDEX <name> SET (<option> = <literal> [, ...])
-- query-time options only: synonyms, refresh_ms,
-- <col>.search_analyzer, <col>.boost (applied live, no reindex)
CREATE STREAM [IF NOT EXISTS] <name> ON <table> WHEN (<predicate>)
[WITH (start = 'now' | 'earliest' [, retention = '<duration>'])]
-- a standing filter over the table's writes; see STREAMS.md
DROP STREAM [IF EXISTS] <name>
SHOW STREAMS -- (name, table, predicate, consumers, lag)
CREATE PROCEDURE [IF NOT EXISTS] <name>(<param> <TYPE> [, ...])
BEGIN <step>; [<step>; ...] [EXCEPTION WHEN <cond> THEN <step>; ...] END
-- a step is a SQL statement, a nested CALL, or control flow:
-- DECLARE <v> <TYPE> [DEFAULT <expr>] | DECLARE <c> CURSOR FOR <select>
-- SET <v> = <expr> | SELECT ... INTO <v> [, <v>] FROM ...
-- IF <c> THEN ... [ELSEIF <c> THEN ...] [ELSE ...] END IF
-- WHILE <c> DO ... END WHILE | FOR <v> IN (<select>) DO ... END FOR
-- OPEN <c> | FETCH <c> INTO <v> [, <v>] | CLOSE <c> | LEAVE
-- RETURN [<expr> | {<k>: <expr>, ...}] | BEGIN ... END
-- see PROCEDURES.md
DROP PROCEDURE [IF EXISTS] <name>
CALL <name>(<expr> [, <expr> ...]) -- returns the last statement's result set
SHOW PROCEDURES -- (name, params, definer, created)
CREATE JOB [IF NOT EXISTS] <name>
ON SCHEDULE { EVERY '<duration>' | CRON '<expr>' }
CALL <procedure>(<expr> [, ...]) [WITH (<option> = <literal>, ...)]
-- options: timezone (UTC or a fixed +HH:MM offset; CRON only),
-- catchup, timeout, budget, ttl, result_cap; see PROCEDURES.md
DROP JOB [IF EXISTS] <name>
SHOW JOBS -- (name, source, procedure, definer, owner, next_run,
-- last_run, last_status, last_error, failures)
CREATE TRIGGER [IF NOT EXISTS] <name> ON <table> WHEN (<predicate>)
CALL <procedure>(<expr> [, ...]) [WITH (<option> = <literal>, ...)]
-- a job whose source is a change stream; owns a hidden stream
DROP TRIGGER [IF EXISTS] <name> -- removes the trigger AND its stream
SHOW TRIGGERS -- (name, table, predicate, procedure, definer, stream,
-- cursor, last_status, last_error, failures)
SUGGEST '<text>' ON <index> [COLUMN <col>] [LIMIT <n>]
EXPLAIN SCORE <select> FOR <pk-literal>
EXPLAIN <statement>
ALTER TABLE <table> RENAME TO <new_table>
ALTER TABLE <table> RENAME COLUMN <from> TO <to>
ALTER TABLE <table> ADD [CONSTRAINT <name>] FOREIGN KEY (<col> [, ...])
REFERENCES <parent> (<col> [, ...]) [ON DELETE <action>] [ON UPDATE <action>]
[NOT VALID] -- skip checking existing rows
ALTER TABLE <table> ADD [CONSTRAINT <name>] CHECK (<expr>) [NOT VALID]
ALTER TABLE <table> VALIDATE CONSTRAINT <name> -- FOREIGN KEY or CHECK
ALTER TABLE <table> DROP CONSTRAINT [IF EXISTS] <name>
ALTER TABLE <table> ALTER [COLUMN] <col> SET NOT NULL -- checks existing rows first
ALTER TABLE <table> ALTER [COLUMN] <col> DROP NOT NULL
ALTER TABLE <table> ALTER [COLUMN] <col> SET DEFAULT <expr>
ALTER TABLE <table> ALTER [COLUMN] <col> DROP DEFAULT
ALTER TABLE <table> ALTER [COLUMN] <col> ADD GENERATED {ALWAYS | BY DEFAULT} AS IDENTITY
[(<seq-option> ...)] -- existing rows must already have the column
ALTER TABLE <table> ALTER [COLUMN] <col> DROP IDENTITY [IF EXISTS]
ALTER TABLE <table> ALTER [COLUMN] <col> ADD GENERATED ALWAYS AS (<expr>) STORED -- rewrites every row
ALTER TABLE <table> ALTER [COLUMN] <col> SET EXPRESSION AS (<expr>) -- rewrites every row
ALTER TABLE <table> ALTER [COLUMN] <col> DROP EXPRESSION [IF EXISTS] -- values stay as data
-- Sequences (database-scoped counters; <seq-option> is any of
-- INCREMENT [BY] <n> | MINVALUE <n> | NO MINVALUE | MAXVALUE <n> | NO MAXVALUE
-- | START [WITH] <n> | CACHE <n> | CYCLE | NO CYCLE)
CREATE SEQUENCE [IF NOT EXISTS] <name> [<seq-option> ...] [OWNED BY <table>.<col> | OWNED BY NONE]
ALTER SEQUENCE [IF EXISTS] <name> [<seq-option> ...] [OWNED BY ...] [RESTART [WITH <n>]]
DROP SEQUENCE [IF EXISTS] <name> [, <name> ...]
-- Common table expressions (statement-scoped views; SELECT statements only)
WITH <name> [(<col> [, ...])] AS (<select>) [, ...] <select>
-- Enum types and domains (named constraint bundles a column item names)
CREATE TYPE [IF NOT EXISTS] <name> AS ENUM ('<v>' [, ...])
ALTER TYPE <name> ADD VALUE [IF NOT EXISTS] '<v>'
DROP TYPE [IF EXISTS] <name> [, ...]
CREATE DOMAIN [IF NOT EXISTS] <name> [AS] [NOT NULL] [DEFAULT <expr>] [CHECK (<expr over VALUE>)]
DROP DOMAIN [IF EXISTS] <name> [, ...]
-- and in CREATE TABLE a column item may name one: <col> <type> [NOT NULL] [DEFAULT ...] ...
-- Views (a stored SELECT; a materialized view stores the result)
CREATE [OR REPLACE] VIEW [IF NOT EXISTS] <name> [(<col> [, ...])] AS <select>
DROP VIEW [IF EXISTS] <name> [, <name> ...]
CREATE MATERIALIZED VIEW [IF NOT EXISTS] <name> [(<col> [, ...])]
[WITH (refresh = '<interval>')] AS <select> [WITH [NO] DATA]
REFRESH MATERIALIZED VIEW <name> -- re-runs the body; affected = new row count
DROP MATERIALIZED VIEW [IF EXISTS] <name> [, <name> ...]
SELECT nextval('<seq>') -- also in a DEFAULT and an INSERT's VALUES
SELECT setval('<seq>', <n> [, <is_called>]) -- FROM-less SELECT only
-- DML
INSERT INTO <table> (<col> [, <col> ...]) VALUES (<expr> | DEFAULT, ...) [, (<expr>, ...) ...]
UPDATE <table> SET <path> = <expr> [, <path> = <expr> ...] [WHERE <expr>]
DELETE FROM <table> [WHERE <expr>]
-- Query
SELECT <expr> [[AS] <alias>] [, ...] -- no FROM: constant projection, one row
-- (`SELECT 1` liveness probe; no other
-- clause may follow; `*` needs a table)
SELECT [DISTINCT | DISTINCT ON (<expr> [, ...])] <select-item> [, <select-item> ...]
FROM <table> [[AS] <alias>]
[ <join> ... ]
[NEAREST (<path>, <query-vector>, <k>)]
[WHERE <expr>]
[RANK BY RRF [(<constant>)]] -- hybrid: fuse NEAREST + WHERE-search by RRF
[RERANK [ON <col>] [WITH '<model>'] [QUERY '<text>'] [TOP <n>]]
-- second-stage cross-encoder reranking
[GROUP BY <expr> [, <expr> ...] [TOP <k> BY <expr> [ASC|DESC]]]
[HAVING <expr>]
[ { UNION | INTERSECT | EXCEPT } [ALL] <select> ... ] -- applied left to right
[ORDER BY <expr> [ASC|DESC] [, <expr> [ASC|DESC] ...]]
[LIMIT <n>] [OFFSET <n>]
[AFTER (<sort-value>, <pk-value>)] -- deep pagination: keyset cursor (search queries)
<join> := [INNER | LEFT [OUTER] | RIGHT [OUTER] | FULL [OUTER] | CROSS] JOIN <table> [[AS] <alias>] [ON <expr>]
-- Transactions (see note)
BEGIN [TRANSACTION] -- embedded / standalone driver connections
BEGIN ATOMIC PARTITION (<col> = <literal> [, ...]) -- cluster: single-shard txn
COMMIT [TRANSACTION]
ROLLBACK [TRANSACTION]
-- Databases (see note)
CREATE DATABASE [IF NOT EXISTS] <name>
DROP DATABASE [IF EXISTS] <name>
USE [DATABASE] <name>
-- Users, roles, grants (see Access control note)
CREATE USER [IF NOT EXISTS] <name> PASSWORD '<password>'
CREATE USER [IF NOT EXISTS] <name> GSSAPI -- external Kerberos principal,
-- no local secret; <name> is the
-- principal, e.g. "user@REALM"
-- (double-quote it — it contains
-- '@' and '.')
ALTER USER <name> PASSWORD '<password>'
DROP USER [IF EXISTS] <name>
CREATE ROLE [IF NOT EXISTS] <name>
DROP ROLE [IF EXISTS] <name>
GRANT <privilege> ON { <table> | DATABASE <db> | TOPIC '<filter>' | * } TO <role>
REVOKE <privilege> ON { <table> | DATABASE <db> | TOPIC '<filter>' | * } FROM <role>
GRANT ROLE <role> TO <user>
REVOKE ROLE <role> FROM <user>
SHOW GRANTS [FOR <role>]
-- Introspection (read-only catalog)
SHOW TABLES
SHOW CREATE TABLE <table> -- the table's DDL + its indexes + constraints, replayable
SHOW INDEXES
SHOW CONSTRAINTS [ON <table>] -- NOT NULL / DEFAULT / IDENTITY / GENERATED / CHECK / FOREIGN KEY, one view
SHOW FOREIGN KEYS [ON <table>] -- foreign keys declared on / referencing a table
SHOW SEQUENCES -- every sequence of the current database + its counter
SHOW VIEWS -- views and materialized views of the current database, dependency order
SHOW TYPES -- enum types and domains of the current database (SHOW DOMAINS is the same)
SHOW STATUS
SHOW DATABASES
-- Admin control plane (network server only; reads need MONITOR on *,
-- mutations need ADMIN on *; ADMIN implies MONITOR)
SHOW CLUSTER -- ring, members, epoch, liveness
SHOW CONFIG [LIKE '<pattern>'] -- flattened keys, secrets masked
SET CONFIG <section.field> = <literal> -- live-mutable keys apply instantly (ADMIN)
SHOW SLOW QUERIES [LIMIT <n>] -- masked slow-query sample
SHOW QUERIES -- statements running NOW on this node
-- (id, user, db, via, elapsed_ms,
-- rows_examined, sql)
SHOW MAINTENANCE -- background jobs running NOW on this
-- node (id, kind, target, elapsed_ms,
-- progress): anti-entropy passes,
-- content-digest builds, index/vector/
-- geo backfills, search catch-ups,
-- hint drains, flush builds,
-- compactions, embed drains
KILL QUERY <id> -- cooperatively terminate a running
-- statement by its SHOW QUERIES id (ADMIN);
-- it fails with "terminated by KILL QUERY"
-- at its next scan tick
RESET OOM COUNTER [ON '<node>'] -- zero the OOM-kill counter reported in
-- node_stats/host stats, on every member
-- AND registered witness, or on one node
-- by its id / witness id (ADMIN); returns
-- (node, absorbed, status) — witnesses
-- apply on their next pull cycle
-- (absorbed = NULL, status "stamped")
REPAIR CLUSTER -- one anti-entropy pass
RECLAIM -- drop unowned keys/series
ALTER CLUSTER ADD NODE '<host:port>'
ALTER CLUSTER REMOVE NODE '<id>'
ALTER CLUSTER SET NAME '<name>' -- rename the cluster (ADMIN)
ALTER NODE '<alias|dotted|id>' SET NAME '<n>' -- rename a member/witness alias (ADMIN)
ALTER TABLE <table> SET (ttl = <dur> -- live TTL change (0 clears)
| witness = <bool> -- toggle witness mirroring
| replication = <n> -- online placement transition
| nodes = ['<ref>',..] -- online pin change
| toast_threshold = <bytes> -- value-TOAST on new writes (0 = off)
| placement_finalized = true) -- operator escape hatch
ALTER TABLE <ts-table> SET (retention = <dur> -- live retention change (0 clears)
| ooo = <dur>) -- live out-of-order window change
-- Session (binary-protocol connections)
SET CONSISTENCY { ONE | QUORUM | ALL } -- per-connection override
SET SCAN BUDGET ROWS <n> [BYTES <n>] -- session scan limits (tightening-only)
SET SCAN BUDGET DEFAULT -- clear the session override
-- Backup & restore (ADMIN)
BACKUP TO '<path>' -- crash-consistent copy of this node's data dir
BACKUP CLUSTER TO '<path>' -- every member backs up + one shared cut instant
RESTORE FROM '<path>' -- embedded / single node only; old data kept aside
RESTORE FROM '<path>' TO TIMESTAMP '<when>' -- point-in-time recovery
CREATE TABLE declares the primary key and constraints — there is
no typed column list; documents are schema-less. A composite PK lists
several columns. A column item (name NOT NULL DEFAULT 'x' CHECK (…))
constrains a field rows may carry, never its type or existence
(name TEXT NOT NULL is a parse error) — see Column constraints
below.
WITH (memory = true) makes the table RAM-only: no write-ahead log
fsyncs, never flushed to disk, empty after a restart, and skipped by
repair/reshard data motion — for short-lived bounded data (node stats,
caches); pair with a ttl. Indexes on memory tables are not supported.
WITH (ttl = <duration>) makes rows expire: a row older than the TTL
(measured from its write's HLC timestamp) becomes invisible to every read
and is physically dropped at the next compaction. TTL is a read-visibility
rule applied uniformly on every replica (the stamped data still
replicates, so expiry converges regardless of which node serves the
read). Useful for caches, sessions, and rolling event windows.
TTL is live-tunable: ALTER TABLE t SET (ttl = 30d) applies to
reads immediately — shortening it can expire existing rows at once, and
because expiry is lazy, widening or clearing it (ttl = 0) un-expires
rows compaction hasn't physically reclaimed yet.
Visibility is immediate; space comes back shortly after. Flush is
size-triggered, so a low-write expiring table would otherwise hold every
expired row in memory until it happened to cross the flush threshold — a
background sweep flushes and compacts such tables every
storage.ttl_reclaim_interval_secs (900 by default, 0 disables). Size an
expiring table for its TTL plus about one sweep interval, not for its TTL
exactly.
CREATE INDEX with one path is a single-column index; with several it is a
composite index (ordered left-to-right). See indexing notes below.
WITH (global = true) makes it a global (value-sharded) index: entries
live in an internal replicated table placed on the ring by indexed value,
so a full-tuple equality/IN probe routes to one replica set instead of
scattering to every member — the RF < members win; local stays the default.
Ranges and partial prefixes keep the scatter path. The DDL acks at
schema-apply and backfills in the background (SHOW INDEXES says
global (building); probes fall back to scatter until ready). Guide:
INDEXING.md.
A [] suffix on one path (CREATE INDEX i ON t (account, labels[])) marks
a multikey component: the value there is an array and each element gets
its own index entry, so labels = 'x' (element containment) becomes an
index probe — including exact index-only counts. At most one [] per
index. The planner uses a multikey index only when every column through
the [] component is equality-constrained; other shapes (ranges or sorts
on the array column) fall back to a scan.
CREATE VECTOR INDEX builds an HNSW index for nearest-neighbor search over the
float array at <path>. DIM <n> (the vector dimension) is required;
USING <metric> is cosine (default), l2, or dot. QUANTIZED stores
int8 scalar-quantized vectors in the in-RAM graph (4× less vector RAM);
queries over-fetch and rescore the top-k against the exact row vectors, so
returned distances stay exact (build-time choice — rebuild to change; not
combinable with EMBED). It broadcasts across the cluster so every node
indexes its shard. Query it with the NEAREST clause (see Vector search
below and VECTOR.md).
CREATE GEO INDEX builds a Morton (Z-order) spatial index over the
{lat, lon} point column at <point-path>, so geo_distance / geo_bbox
predicates in a WHERE scan a small set of code ranges instead of the whole
table — no query change, the index is used transparently when present. It
broadcasts across the cluster (each node indexes its shard), maintains itself
on write, backfills existing rows in the background, and persists on disk (no
rebuild on restart). There is nothing to configure; a row whose column is not
a readable point is simply not indexed. See GEO.md.
CREATE STREAM declares a standing filter over a table's writes:
every committed change to <table> is matched against the WHEN
predicate, which is an ordinary boolean expression evaluated like a
WHERE clause. retention accepts a duration ('24h', '90m', or a
bare 30d); start accepts only 'now' — backfilling from an earlier
point is not implemented, and a start that cannot be honoured is
refused rather than silently ignored. The predicate is stored as text and
re-parsed on open, which is also what SHOW STREAMS displays.
Events land in a table you can read today. Each matching write
appends to _stream_<name>, an ordinary table in the same database:
columns id (position), op, k (the row's primary-key value), ts,
doc (the row). It replicates like any table, retention is its TTL, and
a replay is an ordinary ordered read:
SELECT * FROM _stream_orders WHERE id > '<last>' ORDER BY id LIMIT 500.
Dropping the stream drops the log. Push delivery to consumers does not
exist yet — nothing is published to subscribers, so consumers and
lag read 0 — and only inserts/updates emit: deletes do not yet.
A stream that matches most writes roughly doubles the table's write
volume, since every matching change writes a second replicated row.
CREATE PROCEDURE stores a statement list in the catalog that CALL
runs server-side, so a fixed sequence of statements costs one round trip
instead of N. A body takes SELECT, INSERT, UPDATE, DELETE and
nested CALL — no DDL, no BEGIN/COMMIT, no dynamic SQL — and each
statement ends with ;, including the last. Nesting is capped at 16 deep,
which is what ends a recursive cycle. Parameters carry a declared
type (TEXT, INT, FLOAT, BOOL, TIMESTAMP, JSON, ARRAY, ANY)
checked against the argument at the call; the store stays schema-less, so
the type is a contract on the call and nothing else. A CALL returns the
last statement's result set, inherits the caller's scan budget and
deadline, and runs with invoker rights: EXECUTE on the procedure plus
every privilege the body itself needs.
Control flow is a PSM subset, not an embedded VM: every leaf is a
statement the ordinary executor already runs. A WHILE has no iteration
cap — it is bounded by the caller's scan budget and deadline, which is the
only termination guarantee — and an EXCEPTION handler can catch data
errors (NOT_FOUND, CONSTRAINT, UNIQUE_VIOLATION,
FOREIGN_KEY_VIOLATION, NOT_NULL_VIOLATION, CHECK_VIOLATION,
TYPE_ERROR, UNSUPPORTED, OTHERS) but never a resource limit or a storage/cluster
error. A cursor is a resumable keyset scan over the primary key, not a
snapshot: it sees writes made while it is open.
See PROCEDURES.md.
CREATE JOB runs a stored procedure on a schedule — EVERY '<duration>'
(a duration since the last tick) or CRON '<expr>' (calendar instants,
five fields, no seconds). Exactly one node runs each job: the ring picks a
preferred owner and a linearizable CAS lease makes that correct. A job runs
as the role that created it, always with an explicit timeout and row budget
(an unattended run has no caller to inherit one from), and its state and
history live in the feature-owned _job_state / _job_runs tables, which
clients may read and nothing else. Delivery is at-least-once, so a body
should key its writes on SCHEDULED_AT() — the logical tick, stable across
attempts. Timezones are UTC or a fixed +HH:MM offset; named zones are
refused, since skaidb embeds no timezone database. See
PROCEDURES.md.
CREATE TRIGGER is the same job with a change stream as its source
instead of a schedule: it owns a hidden stream carrying pre-images, and
DROP TRIGGER removes both. The body sees NEW, OLD (NULL on an
insert), EVENT() and OP, all from the log rather than from a re-read.
It fires from the log, never inline on the write path. A cascade cycle
between triggers is refused at declaration — a cascade without one is a DAG
and terminates by construction.
CREATE SEARCH INDEX builds a full-text (BM25) index over the listed
document paths. Global options: analyzer ('standard' default,
'folding', 'whitespace', 'keyword', 'ngram(min,max)',
'edge_ngram(min,max)', or a language like 'english' — full list in
SEARCH.md) and refresh_ms (integer, default 1000 — how
quickly writes become searchable). Per-column options use the path as
prefix: <path>.type ('text' default, 'keyword', 'long',
'double', 'bool', 'date'), <path>.analyzer,
<path>.search_analyzer, <path>.boost (number), <path>.keyword
(boolean — adds a <path>.keyword exact-match twin), and
<path>.copy_to (composite target field). Query it with
MATCH()/SEARCH() predicates and score() (see Full-text search
below and SEARCH.md); REBUILD SEARCH INDEX re-indexes the
table from scratch (recovery escape hatch).
<select-item> is * (all fields seen in the result rows) or
<expr> [[AS] <alias>].
ALTER TABLE … RENAME TO renames a table (moving its on-disk data and
repointing its indexes); RENAME COLUMN from TO to rewrites that field in
every row (recomputing the primary key if it is a key column) and rebuilds any
index that referenced it. The store is schema-less, so there is no
ADD/DROP COLUMN — a field simply exists in the rows that set it.
CREATE UNIQUE INDEX <i> ON <t> (<path>[, ...]) constrains a value
(or value tuple) to at most one row — the second unique axis a primary
key cannot express (users keyed by id but email unique too), and
composite uniqueness beside a surrogate key. A violating write fails
with a distinct unique violation error (catchable separately from
other constraint errors) and writes nothing. Rows where any indexed
path is absent or NULL are unconstrained, matching SQL. Uniqueness is
enforced cluster-wide, not per shard: the constraint is a
reservation keyed by the value itself, claimed through the same
per-key consensus round as UPDATE, so concurrent writers racing one
value yield exactly one winner. UPDATE enforces and maintains the
constraint like INSERT: updating INTO a taken value is rejected (the
row untouched), updating a row off a value frees it, the moved-to
value is claimed, and a multi-row UPDATE whose own rows would
collide on a unique value is rejected whole before anything is
written (a collision with an EXISTING value still errors at the
colliding row, so earlier rows of a multi-row statement stay applied
— the same mid-statement semantics as any other row error). A
PK-changing UPDATE that keeps the same unique value is refused
(the value still belongs to the old row key until the delete);
delete-then-insert instead. Deleting a row frees its values.
CREATE UNIQUE INDEX over data that already contains duplicates
fails rather than silently tolerating them. Cannot be combined with
WITH (global = true). On a cluster, UPDATE/upserts on
unique-indexed tables take the plain read-compute-put path rather
than the per-row linearizable round (same documented fallback as
toast/gidx tables); the VALUE claim itself is still
consensus-serialized, so uniqueness stays exactly-one-winner.
FOREIGN KEY (<cols>) REFERENCES <parent> (<cols>) — declared
inline in CREATE TABLE after the primary key, or added later with
ALTER TABLE … ADD [CONSTRAINT <name>] FOREIGN KEY … — makes the
child's column tuple point at an existing parent row. The referenced
columns must be the parent's primary key (its full key, including any
cluster_by columns) or exactly the columns of one of its UNIQUE
indexes; arity and order must match. A child row whose tuple has no
parent is refused with a foreign key violation error (procedures
catch it as FOREIGN_KEY_VIOLATION) and the whole statement writes
nothing. A tuple with any NULL or absent column is unconstrained
(MATCH SIMPLE). The parent side acts per clause: RESTRICT / NO
ACTION (the default) refuses deleting a referenced parent row or
changing its referenced tuple; CASCADE deletes the children with the
parent, or rewrites their tuple to the parent's new one; SET NULL
nulls the children's columns. Actions chain through several levels,
a violation anywhere down the chain fails the whole statement, and a
chain deeper than 16 levels is refused. Self-references are allowed —
a tree inserted in one multi-row INSERT satisfies itself whatever
the row order. Both tables must be regular row tables in the same
database: the parent cannot be a time-series, memory, TTL or
range-partitioned table, the child cannot be memory or partitioned,
and constraint columns are top-level fields. The default constraint
name is fk_<table>_<col>[_<col>…]. A supporting index on the child
columns is required: an existing local index whose leading paths are
the columns is reused, otherwise <constraint>_idx is created with
the constraint (and dropped with it); an index a constraint depends on
— on either side — cannot be dropped, nor can a parent table while
another table references it. ADD CONSTRAINT checks every existing
row unless NOT VALID is given, in which case only new writes are
enforced until VALIDATE CONSTRAINT finds the data clean and marks it
valid. RENAME TO / RENAME COLUMN rewrite the definitions on both
sides. Inside a BEGIN … COMMIT transaction the checks see the
transaction's own writes. On a cluster the constraint is enforced
cluster-wide: the coordinator checks the parent (or the
referencing children) at QUORUM wherever the rows live, then
writes, and ADD CONSTRAINT / VALIDATE CONSTRAINT scan every
member's rows before the DDL broadcasts — a stray child anywhere
fails the statement and nothing is applied. A member that missed the
DDL picks the constraint up through schema sync. A violating
statement writes nothing on a cluster either (every row is checked
before the first is written), but the rows of a passing statement
land one at a time, so a mid-statement failure of another kind can
leave the earlier rows written. Tables on either side of a foreign
key are refused inside BEGIN ATOMIC PARTITION. Concurrent
statements on opposite sides of a constraint (a child insert against
a parent delete) are serialized per referenced value through a
hidden guard table, so the outcome matches a single writer's; a
coordinator that dies mid-statement leaves guard state that ages out
after 10 s (see docs/CLUSTERING.md).
Column constraints — NOT NULL, DEFAULT <expr> and CHECK
(<expr>) — are declared per column in CREATE TABLE
(qty NOT NULL DEFAULT 1 CHECK (qty >= 0), the three in any order),
as table-level [CONSTRAINT <name>] CHECK (<expr>) items, or later
with ALTER TABLE … ADD [CONSTRAINT <name>] CHECK (…) [NOT VALID] and
ALTER TABLE … ALTER [COLUMN] <col> SET | DROP NOT NULL | DEFAULT.
A column is a top-level field or a dotted path (meta.lang); the
store stays schema-less, so an item says nothing about the field's
type or existence. NOT NULL requires the column to be present and
non-NULL in every row that lands — a schema-less row that never
mentions the field counts as NULL — so INSERT INTO t (id) … on a
table with name NOT NULL is refused. SET NOT NULL scans the
existing rows first and refuses when any violates; there is no NOT
VALID form (use CHECK (c IS NOT NULL) NOT VALID). DEFAULT is
applied on INSERT only, to a column the statement's column list
does not name (an explicit NULL is named and gets no default), and
before the key is computed; never on UPDATE. The expression is
evaluated per row against an empty row — now() and uuid() are
per-row — so it may not reference columns, aggregates, windows or
parameters (refused at DDL time), and an unknown function is refused
at DDL time too. CHECK is evaluated against the final row: false
fails the write, true or NULL (an unknown, e.g. an absent column)
passes — SQL semantics; a non-boolean result is a type error. Like
DEFAULTs, CHECKs may not use aggregates, windows or parameters.
NOT VALID leaves existing rows unchecked until VALIDATE CONSTRAINT
finds them clean; new writes are always enforced. UPDATE is gated on
each computed row, and an INSERT … ON CONFLICT DO UPDATE checks the
proposed row before conflict resolution and then the merged row
(Postgres semantics: an upsert that omits a NOT NULL column fails
even when the row already exists). A violating statement writes
nothing (every row is checked before the first is written). Errors:
not null violation: column "c" of table "t" (condition
NOT_NULL_VIOLATION) and check violation: constraint "n" of table
"t": <col> = <value>, … (condition CHECK_VIOLATION), both in the
Constraint error class. Names: a column-level CHECK is
chk_<table>_<col>, an unnamed table-level one chk_<table> then
chk_<table>_<n>; CHECKs and FOREIGN KEYs share one namespace per
table, and VALIDATE / DROP CONSTRAINT find either. RENAME
COLUMN rewrites column items; a CHECK whose expression references
the column refuses the rename (drop and re-add it). SHOW CONSTRAINTS
[ON <table>] lists every constraint of every kind in one view
(constraint, table, type, columns, valid, definition; the name and
columns cells are NULL where they do not apply), SHOW CREATE TABLE
prints the column items and valid CHECKs inline and NOT VALID CHECKs
as trailing ALTER TABLE … ADD CONSTRAINT … NOT VALID rows. On a
cluster enforcement is identical on every coordinator; ADD CHECK,
VALIDATE CONSTRAINT and SET NOT NULL scan every member's rows at
QUORUM before the DDL broadcasts, and the constraints travel with
schema sync. Partition children enforce the parent's constraints.
Sequences and auto-increment columns. A sequence is a
database-scoped counter: CREATE SEQUENCE s [INCREMENT BY n]
[MINVALUE n | NO MINVALUE] [MAXVALUE n | NO MAXVALUE] [START WITH n]
[CACHE n] [CYCLE | NO CYCLE] [OWNED BY t.c] (defaults as Postgres:
increment 1, min 1 / max i64 max for an ascending sequence, min i64
min / max −1 for a descending one, start = min (max when descending),
CACHE 1, NO CYCLE). nextval('s') hands out the next value —
s is resolved in the current database, or write db.s — and is
allowed in a column DEFAULT, in an INSERT's VALUES and in a
FROM-less SELECT; anywhere else (a projection over rows, WHERE,
CHECK, a procedure expression) is refused with nextval() is allowed
in a DEFAULT, an INSERT's VALUES and a FROM-less SELECT.
setval('s', n [, is_called]) (FROM-less SELECT only) sets the
counter: with is_called true (the default) the next call returns
n + increment, with false it returns n; ALTER SEQUENCE s RESTART
[WITH n] is setval(n, false) as DDL — the next call returns n
(default: the START value). Reaching
MAXVALUE (or MINVALUE when descending) on a NO CYCLE sequence
fails the statement with nextval: reached maximum value of sequence
"s" (n); CYCLE wraps around to the other bound. CACHE n leases
n values per counter write and hands them out from memory: on a
cluster every coordinator holds its own block, so values stay unique
and dense per coordinator but interleave across coordinators, and a
setval / RESTART only invalidates the blocks of the node that ran
it — the others hand theirs out to the end (Postgres sessions with
CACHE > 1 behave the same). With the default CACHE 1 every call
is one counter round (a local write on standalone, a linearizable
CAS on a cluster), values are dense across the whole cluster, and a
failed statement still consumes its value — Postgres semantics: a
sequence is never rolled back. SERIAL / BIGSERIAL on a column
item (id SERIAL, the two are synonyms — values are i64) creates
the sequence <table>_<col>_seq owned by the column and makes the
column GENERATED BY DEFAULT AS IDENTITY: NOT NULL with a
nextval default, so INSERT INTO t (name) VALUES ('x') keys the
row by the next value (a DEFAULT on a primary-key column is applied
before the key is computed). GENERATED {ALWAYS | BY DEFAULT} AS
IDENTITY [(seq-options)] is the same with explicit options
(id GENERATED ALWAYS AS IDENTITY (START WITH 1000 INCREMENT BY 10)).
BY DEFAULT lets an INSERT supply its own value (the counter does
not move); ALWAYS refuses a supplied value on INSERT (column "id"
of "t" can only be updated to DEFAULT: it is an identity column
defined as GENERATED ALWAYS) — except under INSERT INTO t (…)
OVERRIDING SYSTEM VALUE VALUES (…) (bulk loads, imports) — and
refuses the column in UPDATE … SET and ON CONFLICT DO UPDATE SET
outright. An identity column may not also carry a DEFAULT. Writing
the word DEFAULT as a VALUES element (INSERT INTO t (id, name)
VALUES (DEFAULT, 'x')) un-names the column for that row so its
default applies. ALTER TABLE t ALTER COLUMN c ADD GENERATED … AS
IDENTITY [(…)] adopts an existing column (every row must already
have it, as for SET NOT NULL; refused on a column that has a
DEFAULT or is already an identity column); … DROP IDENTITY [IF
EXISTS] makes it an ordinary column again and drops the owned
sequence. The owned sequence is dropped with its table, keeps its
name across RENAME and shows in SHOW SEQUENCES as owned_by
"t.c"; DROP SEQUENCE s is refused while a column DEFAULT calls
it (sequence 's' is used by the DEFAULT of t.c — drop the default
first), and DROP DATABASE drops the database's sequences. INSERT
… RETURNING * | <expr> [AS alias], … returns the rows as they
landed — defaults (so the generated id) applied, the merged row for
ON CONFLICT DO UPDATE, and nothing for a row DO NOTHING skipped —
as a result set instead of an affected count; * is every field of
the rows in name order. SHOW SEQUENCES lists sequence, increment,
min_value, max_value, start, cache, cycle, owned_by, last_value —
last_value is the highest value leased so far (NULL before the first
nextval; with CACHE > 1 it is the block's end, as Postgres), read
at QUORUM on a cluster. SHOW CONSTRAINTS reports an identity
column as one IDENTITY row whose definition is the column item; SHOW
CREATE TABLE prints the item inline (the owned sequence is implied);
a free-standing sequence a DEFAULT calls must exist before the
table is replayed. Sequence definitions travel with schema sync; the
counter is one row of the hidden per-database table __seq__,
replicated at the database's factor, so it survives any node.
skaidbsh export writes free-standing sequences into schema.sql,
identity columns and setval(…) for every counter into
constraints.sql (replayed after the data), and import loads
GENERATED ALWAYS tables with OVERRIDING SYSTEM VALUE. Not
offered: currval() / lastval() (use RETURNING), typed
sequences (AS smallint), SMALLSERIAL, UPDATE/DELETE …
RETURNING.
Enum types and domains — the store has no column types, so
CREATE TYPE mood AS ENUM ('happy', 'sad') and CREATE DOMAIN
positive AS NOT NULL DEFAULT 1 CHECK (VALUE > 0) are named
constraint bundles: a column item may name one (m mood, qty
positive, alongside its own NOT NULL / DEFAULT / CHECK), and
CREATE TABLE expands it into ordinary constraints — an enum into a
column CHECK (m IN ('happy', 'sad')) named <col>_<type>, a domain
into its NOT NULL / DEFAULT / CHECK with VALUE replaced by the column
— which SHOW CONSTRAINTS lists like any other. ALTER TYPE mood ADD
VALUE 'angry' grows the enum and rewrites every column CHECK derived
from it (found by name and exact expression); DROP TYPE is refused
while such a CHECK exists (drop the constraint first), DROP DOMAIN
leaves the constraints it produced in place. A domain's DEFAULT yields
to one written on the column. Types are database-scoped (db.mood
qualifies like a table), travel with schema sync (typ: keys, before
views) and list in SHOW TYPES with their CREATE statement. Not
offered: enum ordering (< between enum values compares as strings),
ALTER TYPE … RENAME VALUE, ALTER DOMAIN, composite types,
collations.
Partial and expression indexes — CREATE [UNIQUE] INDEX i ON t
((lower(email))) indexes an expression's value per row (any
deterministic expression over the row's columns — the CHECK rules: no
aggregates, windows, subqueries, parameters, now(), nextval); the
planner uses it for a filter whose left side is the same expression
(WHERE lower(email) = 'x', ranges, IN, ORDER BY lower(email)),
matched by canonical text. … WHERE <predicate> makes an index
partial: only rows satisfying the predicate have entries, and the
planner uses it only when the query's WHERE implies the predicate —
every AND-conjunct of the predicate appears literally among the
query's AND-conjuncts (WHERE active = true AND score > 5 uses an
index … WHERE active = true; WHERE score > 5 alone does not). A
partial UNIQUE index constrains only the rows it covers. Both
compose with global and multikey ([] on a plain path only) and
round-trip through SHOW INDEXES, schema sync and skaidbsh export.
Views — CREATE [OR REPLACE] VIEW [IF NOT EXISTS] v [(c1, c2, …)]
AS <select> stores a named SELECT (any SELECT the engine accepts:
joins, GROUP BY, set ops, ORDER BY / LIMIT, FTS, NEAREST — but no bind
parameters and no nextval/setval). A view lives in the table
namespace of its database (db.v qualifies like a table; a view and a
table cannot share a name), sees the tables as they are now (the
store is schema-less: a column added later shows through SELECT *
immediately), and exposes exactly its projection — with a column list
or a non-* item list, any other column is column "x" does not
exist in view "v", and a computed item has no sub-fields. Querying
it: a simple body (no DISTINCT / GROUP BY / HAVING / set ops / LIMIT /
OFFSET / aggregates / windows / NEAREST) is inlined — the outer
query is rewritten onto the base table, so point lookups, index scans,
ORDER BY … LIMIT pushdown, FTS and NEAREST all keep working through
it; any other body (or a view on the right of a JOIN) runs first and
the outer query reads its rows as a derived table, with outer WHERE
conjuncts over a grouped body's plain grouping columns pushed into it.
Views over views expand recursively (a cycle is refused at CREATE;
depth capped at 16). INSERT / UPDATE / DELETE through a view are
refused ("v" is a view), DROP TABLE / RENAME of a table a view
names is refused (view "v" depends on "t" — drop the view first),
DROP DATABASE drops its views. RBAC: SELECT on the view's name
is what is checked; the body runs with definer rights, like a
procedure. CREATE VIEW needs CREATE on the database, DROP VIEWDROP. Definitions travel with schema sync as CREATE OR REPLACE VIEW
(vw:<name> keys, tombstoned on DROP), in dependency order.
Common table expressions — WITH a [(cols)] AS (SELECT …), b AS
(SELECT … FROM a …) SELECT … FROM b JOIN a … names one or more
selects for the statement that follows (a SELECT — not UPDATE /
DELETE / INSERT). Each CTE behaves exactly like a view scoped to the
statement: the same inline-or-overlay expansion, the same projection
rule (column "x" does not exist in view "a"), visible to the
statement's subqueries and to a view body that uses WITH; a later
CTE may name an earlier one; a CTE shadows a table or view of the
same name. Non-recursive only (WITH RECURSIVE is not offered), and
a CTE is not a materialization fence — a CTE named twice runs twice.
Materialized views — CREATE MATERIALIZED VIEW [IF NOT EXISTS] m
[(cols)] [WITH (refresh = '<interval>')] AS <select> [WITH [NO] DATA]
runs the body and stores its result in a hidden backing table
(__mv__m, PRIMARY KEY (__row): a content hash of the output row
plus an occurrence ordinal, so an unchanged result lands on unchanged
keys and duplicate rows survive), placed like the body's FROM table.
Reading m is an ordinary table read of that snapshot — point
lookups, ORDER BY … LIMIT, streaming all apply; the backing key never
shows and SELECT * on a never-refreshed view (or WITH NO DATA)
returns no rows. REFRESH MATERIALIZED VIEW m re-runs the body at the
session consistency, upserts every output row and deletes the keys the
new result did not produce — readers during a refresh see the union of
old and new rows, never an empty table (which is what Postgres'
CONCURRENTLY buys, so it is not offered); it reports the new row
count as the affected count. WITH (refresh = '5m') (ms/s/m/h/d/w)
refreshes on the maintenance tick (every 60 s) once the interval has
elapsed — on a cluster from the ring owner of the view's name. SHOW
VIEWS lists view, kind (view | matview), refresh, last_refresh,
rows, definition in dependency order (rows is the backing table's
count); SHOW TABLES lists views with kind = view | matview and no
key; SHOW CREATE TABLE m prints the CREATE statement. DROP
MATERIALIZED VIEW m drops the backing table too (DROP VIEW on a
materialized view, and vice versa, is refused). skaidbsh export
writes views into schema.sql after the tables (a materialized view
as … WITH NO DATA) and a REFRESH MATERIALIZED VIEW per
materialized view into constraints.sql, so an import ends populated.
Generated columns. A column item c GENERATED ALWAYS AS (<expr>)
STORED (total GENERATED ALWAYS AS (qty * price) STORED) is
computed from the row that is about to land — after DEFAULTs, after
an UPDATE's assignments, after the ON CONFLICT DO UPDATE merge —
and overwrites whatever the column held, on every write path (INSERT,
UPDATE, upsert, the cluster's per-key rounds); RETURNING sees the
value, indexes and filters treat it as an ordinary stored field. A
NULL result is stored as NULL (a NOT NULL on the column then refuses
the write) and an evaluation error fails the write. Explicit writes
are refused: INSERT naming the column (cannot insert a non-DEFAULT
value into column "c" of "t": it is a generated column; the DEFAULT
element is allowed and OVERRIDING SYSTEM VALUE does not apply),
UPDATE … SET c and ON CONFLICT DO UPDATE SET c (column "c" of "t"
can only be updated to DEFAULT: it is a generated column). The
expression may read the row's other columns (dotted paths too) and
nothing else: no aggregates, window functions or bind parameters (as
CHECK), no nextval / setval, no now(), no excluded., not
itself and no other generated column — all refused at DDL time. A
generated column cannot be a PRIMARY KEY, distribute_by,
cluster_by or partition column, and carries no DEFAULT or
identity; NOT NULL and CHECK combine freely. ALTER TABLE t ALTER
COLUMN c ADD GENERATED ALWAYS AS (<expr>) STORED makes an existing
column generated and rewrites every row (through the ordinary
UPDATE path — cluster-wide from the coordinator that ran the DDL; a
row the recomputed value fails a constraint on fails the ALTER after
the schema change, so the next write judges it again); … SET
EXPRESSION AS (<expr>) replaces the expression the same way (refused
on a column that is not generated); … DROP EXPRESSION [IF EXISTS]
makes the column ordinary and leaves the stored values in place.
RENAME COLUMN is refused while a generated expression references
the column; renaming the generated column itself keeps its
expression. SHOW CONSTRAINTS reports one GENERATED row per
generated column whose definition is the column item (a NOT NULL on
it is its own row); SHOW CREATE TABLE prints the item inline. The
expression travels with schema sync. skaidbsh export writes the
stored values as data and DROP EXPRESSION IF EXISTS + ADD GENERATED
… into constraints.sql; import drops the column from the rows and
the ADD recomputes it. Not offered: VIRTUAL generated columns
(every read would re-evaluate), generated key columns.
Array functions operate on list-valued fields. Mutators —
array_append(a, x), array_append_distinct(a, x) (no-op when an
equal element exists; never reorders), array_remove(a, x) (drops all
equal elements), array_set(a, i, x) (positional write; out-of-bounds
is an error) — are evaluated server-side inside UPDATE/upserts, so
the whole element mutation inherits per-row linearizability on a
cluster: UPDATE t SET tags = array_append(tags, ?) WHERE id = ? is
one atomic operation, and a NULL/absent base acts as the empty array
(array_append(NULL, x) = [x]). Element equality is the SQL =
comparison, so mixed numeric encodings match numerically. Readers:
array_length(a) (NULL for non-arrays) and array_contains(a, x)
(false for non-arrays), both usable in WHERE.
INSERT ... ON CONFLICT makes inserts upserts: with no clause an
existing primary key is REPLACED wholesale (the historical semantics);
DO NOTHING leaves the existing row untouched and reports 0 affected
for that row; DO UPDATE SET ... merges only the named columns into
the existing row — other columns survive. Merge expressions see the
EXISTING row's columns directly and the incoming row's values as
excluded.<col> (the name excluded is reserved inside this clause),
so SET hits = hits + 1 and SET name = excluded.name compose. The
existing row can also be named explicitly as <table>.<col>, so the
Postgres spelling SET hits = t.hits + 1 works too — with one
precedence rule the schemaless model forces: if the row really has a
column named after its own table, that column wins and t.x stays a
path into it. On a
cluster the whole check-and-write runs inside the same per-row
consensus round as UPDATE, so concurrent create-or-update races
serialize: exactly one insert wins and every merge counts.
Value-TOAST and globally-indexed tables use a best-effort
read-merge-write instead (same caveat class as their UPDATEs).
Clustered UPDATE is linearizable per row: every row a clustered
UPDATE touches is read-modified-written through a per-key consensus
round (single-decree Paxos among the row's replicas), so concurrent
updates to one row serialize — SET n = n + 1 from many clients counts
every increment exactly. Caveats: assignments that change a PRIMARY KEY
column take the plain last-writer-wins path (a consensus commit cannot
move a key); plain INSERT/DELETE stay last-writer-wins, so mixing
overwrites of the same key with concurrent UPDATEs forfeits the
guarantee for those writes; heavy single-key contention can error with
CAS contention after bounded retries — clients retry safely (an
update whose effect landed is recognized and never re-applied).
Embedded (single-node) UPDATEs were always serialized by the engine
lock. Monitoring: skaidb_cluster_cas_*_total metrics.
Clustering — WITH (cluster_by = (<col> [, ...])) stores rows
sorted by the clustering values, and folds them into row identity:
a row is identified by (cluster_by…, PRIMARY KEY), exactly like
Cassandra's clustering columns. What that buys and costs:
Range predicates on a clustering prefix seek instead of scanning
(WHERE ts >= a AND ts < b on cluster_by = (ts) reads only the
matching key range — the scan budget then counts only those rows),
and ORDER BY a clustering prefix walks in stored order.
Same-tuple re-ingest is idempotent: replaying a row with the same
clustering values and key overwrites in place — the at-least-once
pipeline case. A row with the same PRIMARY KEY but a different
clustering value is a new row by definition.
An UPDATE that changes a clustering value moves the row
(put-new-then-delete-old — a failure between the two leaves a
recoverable duplicate, never a lost row). Clustering columns must be
present and non-null on every write, cannot be renamed, and cannot
overlap the PRIMARY KEY.
A bare-PK lookup (no clustering bound) is a scan by design —
declare a secondary index on the key column where needle lookups
matter. cluster_by cannot combine with distribute_by or
toast_threshold. Composes with partition_by (put the partition
column at the clustering head). Fixed at CREATE.
Range partitioning — WITH (partition_by = 'range(<col>, <interval>)')
splits a regular table into non-overlapping ranges of <col> (integer or
timestamp values, typically epoch-ms; <interval> is a duration literal:
15min, 1h, 1d, 1w). Rows land in a hidden per-interval partition,
created lazily on first write; every row must carry the partition
column (a row without it is refused). What it buys:
Partition elimination: a WHERE bound on the partition column
scans only the overlapping partitions — scan_row_budget then counts
rows after pruning, so ranged reads on large event tables stay under
the guardrail instead of tripping it.
O(1) retention: with ttl, expired partitions drop whole
(a metadata + directory delete — no per-row tombstones, no compaction
debt). Retention is partition-granular: a partition serves reads until
its entire range is older than the ttl, so rows can outlive a pure
row-ttl by up to one interval; a fully-expired partition never serves
reads even before the physical drop.
No out-of-order horizon: a late row lands in the partition its
column value names, however late it arrives.
Duplicates on the partition column are first-class (append semantics);
identity stays with the PRIMARY KEY. The partition column is part of
row placement: keep it immutable per key — rewriting a key with a
different partition value files the new version in the new partition and
leaves the old one behind (like a re-key). On a cluster, the parent's
DDL replicates and every member creates the same per-interval children
lazily as writes apply (deterministic names — no extra coordination);
retention runs as replicated DROP TABLE DDL on a background cadence,
and reads hide fully-expired partitions regardless. Secondary
indexes work: CREATE INDEX ON <parent> keeps a template on the
parent and fans a physical index to every partition (new partitions
inherit automatically; the needle path — trace_id-style equality
lookups — prunes through them); DROP INDEX fans the removal, and a
partition's drop cleans its indexes. Introspection: `SHOW PARTITIONS
` lists each partition's range, live rows, disk bytes and
whether reads still serve it; `EXPLAIN SELECT …` reports
`partitions_total` / `partitions_scanned` / `partitions_pruned`.
Current restrictions (refused loudly, not half-applied): no UNIQUE or
global indexes (uniqueness would only hold per partition), no
search/vector/geo indexes, streams, or triggers on a partitioned
table; no `memory`, `toast_threshold` or `distribute_by` combinations;
not usable inside `BEGIN ATOMIC PARTITION`; `AFTER` keyset paging
pages with a `WHERE` range instead.
Pick the interval so the retention window holds tens of partitions
(e.g. `1d` over a 90-day ttl). **Adopting on an existing table**:
`ALTER TABLE SET (partition_by = 'range(
, )')` moves
a table to partitioning **without re-ingest** — the current data
becomes a hidden legacy partition (a directory rename, no copy) and a
background drain relocates rows into their range partitions with their
original versions; reads stay complete throughout (children + legacy),
writes during the drain land in their range partition and retire the
pre-adoption copy, and the legacy partition drops once empty. On
clusters the drain is per-node local (a row's partition replicas ARE
its pre-adoption replicas) and fully online; embedded and standalone
servers complete the move within the ALTER statement. Rows missing the
partition column cannot be placed: they stay in the legacy partition
(still served, listed by `SHOW PARTITIONS` with NULL bounds) until an
UPDATE gives them the column. The table must first shed its indexes,
streams and triggers (recreate indexes after — they then fan per
partition).
- **Event-table rollups** — `CREATE ROLLUP ON BUCKET
[BY ()] AGGREGATE ( AS , …) [REFRESH
] [RETENTION ]` maintains a pre-aggregated **bucket
table** over a partitioned or clustered regular table: one row per
`(bucket, dims…)` holding the declared aggregates (`count(*)`,
`count(col)`, `sum`, `min`, `max`) — dashboards query the small rollup
(`SELECT bucket, n FROM r WHERE bucket >= …`) instead of re-scanning
raw events. The bucketed column is inferred: the source's partition
column, else its clustering head (a source with neither is refused —
recompute must prune). The rollup is a real table with `PRIMARY KEY
(bucket, dims…)`: query it, index it, back it up like any other.
A background pass (~1 min cadence; on clusters one member owns each
rollup) **recomputes** the trailing `REFRESH` window (default 2
buckets) from the raw source and upserts the buckets — recompute
replaces rather than increments, so retries, ownership handovers and
replica divergence all converge to the same values; late rows within
the window are absorbed on the next pass. A row arriving LATER than
the refresh window does not update its bucket. On creation the first
pass populates the full history. `RETENTION` expires rollup rows by
age (independent of the source's ttl — keep 13 months of hourlies
over 30 days of raw events). Groups whose bucket or dim value is
missing/NULL are skipped. Dropping the source drops its rollups;
`SHOW CREATE TABLE ` shows the full rollup definition. Rollups of
rollups are refused.
- `ALTER TABLE SET (toast_threshold = )` enables **value-TOAST**:
on every subsequent write, a top-level field whose encoded value is at
least the threshold is stored out-of-line in a hidden replicated
companion table and the row keeps a small marker. Scans that do not
project the field skip the marker instead of reading, decrypting and
decompressing the payload — the win for wide rows (multi-KB JSON blobs)
under narrow dashboards/aggregations. Reads that DO want the field
resolve it transparently (one extra point read per row). Applies to NEW
writes only; existing rows stay inline until rewritten. Fields covered by
any index (secondary, global, search, vector, geo) and primary-key
columns never toast — and creating a new index on a possibly-toasted
field is refused (set `toast_threshold = 0` and rewrite rows first).
`0` disables future splits; already-toasted rows remain readable.
- `ALTER TABLE SET (witness = )` toggles witness mirroring for an
existing table. `WITH (witness = false)` at CREATE (or the ALTER form)
excludes the table from witness pulls: witnesses skip it entirely (rows
already mirrored are kept but stop updating), and the table stops holding
back tombstone GC for lagging witnesses. Toggling back to `true` re-includes it on the next full sweep.
System/registry tables (`witnesses`, `drivers`, `node_aliases`, ...) refuse
placement/witness options — every node consults them locally.
- **Per-table placement**: `replication = ` overrides the cluster
replication factor for one table (n above the member count = full copy,
the same semantics cluster-wide RF has); `nodes = ['', ...]`
pins the WHOLE table to an explicit member set (entries accept node
aliases as sugar, resolved to stable ids at DDL time; a reference that
is not a current member is refused, and witnesses can never be pinned) — every pin holds every row,
quorum math counts the pins, and a non-pin coordinator routes reads and
writes to them. The two are mutually exclusive. Pins are a deliberate
durability trade: a pinned node down means quorum errors for that table
until it returns, and `ALTER CLUSTER REMOVE NODE` refuses to remove a
pinned member (re-pin first with `ALTER TABLE t SET (nodes = [...])`).
GLOBAL-index entry tables follow their base table's placement.
- **Placement transitions**: `ALTER TABLE t SET (replication = n)` /
`SET (nodes = [...])` change placement ONLINE. The old placement is
kept alongside the new one and every read/write addresses the UNION
of both (the per-table twin of the membership change's dual-ring
window), so quorum reads stay correct while new owners are still
empty. A background driver (the sorted union's first member) runs
repair until every member has completed a full anti-entropy pass that
started after the change, then finalizes automatically; `SHOW TABLES`
shows `transition = true` while the window is open. One transition
per table at a time. If the driver is down the window just stays open
(safe, wider than needed) — the operator escape is `REPAIR CLUSTER`
followed by `ALTER TABLE t SET (placement_finalized = true)`. After
finalize, `RECLAIM` trims copies the new placement no longer owns.
Shrinking a pin set needs no window (remaining pins already hold full
copies) and applies immediately.
- **`SHOW CREATE TABLE `** renders the table's definition as replayable
DDL — the `CREATE TABLE` (or `CREATE TIMESERIES TABLE` / `CREATE
ROLLUP`) statement carrying every catalog option (ttl, memory,
replication, nodes, witness, toast_threshold, distribute_by), followed by one row per
index on the table (secondary/global, vector, search, geo). One
`statement` column, one row per statement; works on hidden `__toast__`/
`__gidx__` companions when named explicitly. Needs no privilege beyond
connecting (same class as `DESCRIBE`).
- **`SHOW TABLES`** lists catalog tables as
`(table, primary_key, replication, nodes, witness, transition, kind)`
rows — `replication`/`nodes` per-table placement, `witness` the
mirroring flag, `transition` whether a placement change's
dual-placement window is currently open, `kind` one of
`row` / `timeseries` / `rollup`;
**`SHOW INDEXES`** lists secondary, vector, and search indexes as
`(index, table, kind, columns, local, definition)` — `definition` is the
canonical `CREATE … IF NOT EXISTS` statement that recreates the index
exactly (the same rendering cluster schema sync replays, so vector
DIM/metric/`QUANTIZED`/`EMBED`, search analyzer options, `UNIQUE` and
global flags are all present); `local` is **this node's** live
state for the index: `ok` (open and serving), `building` (backfill or
catch-up running), or `missing` (in the catalog but no live index — the
divergence to check first when one node's searches fail while its peers'
succeed). Both are read-only and require no special privilege,
so a monitoring/tooling agent can enumerate the schema without `/query`
data access. In cluster mode they answer from the local catalog — ask each
node to compare `local` states across the ring.
- **`DESCRIBE
`** (alias **`DESC
`**) is one table's structure as
`(column, key, indexes)` rows — one per column that is part of the primary key
or an index. `key` is `primary key` (with a `(n/m)` position for a composite
key); `indexes` lists the covering indexes as `name (kind)` (`secondary`,
`secondary, building`, `vector`, `search`), and a MULTIKEY `[]` suffix is
stripped from the column name. Rows come out primary-key columns first (in key
order), then the remaining indexed columns alphabetically. Like the `SHOW`
pair it is read-only, needs no privilege, and answers from the local catalog —
no data is read. **The store is schema-less, so a column that is neither part
of the key nor indexed is not in the catalog and does not appear here.** An
unknown table is an error. Accepts a `db.table` qualifier.
- **`DESCRIBE
FULL [SAMPLE n | EXACT]`** additionally reads the data
to surface **every** field — the schema-less fields the catalog can't know.
It returns `(column, type, key, indexes)`: the added `type` is the set of
value types seen for that field, joined by ` | ` (a field can hold several
types). Because it reads rows, `FULL` requires the `SELECT` privilege (plain
`DESCRIBE` does not) and, on a cluster, reads the local shard (complete when
RF ≥ members). Time-series tables report catalog columns only (blank types).
- Default/**`SAMPLE n`**: reads the first `n` rows in primary-key order
(default `1000`), streamed so it reads at most `n` rows and never
materializes the table — a field that appears only in rows outside the
sample is not seen; widen `SAMPLE` to trade cost for completeness.
- **`EXACT`**: scans all rows (streamed — memory stays bounded regardless
of table size) and caches the field map in a RAM **field registry**,
stamped with the table's write sequence. Repeated `EXACT`s on an
unchanged table answer from RAM in O(fields); any write, delete, or
reclaim to the table invalidates the stamp so the next call rescans —
results are always exact, including fields *disappearing* when their last
row is deleted. The registry is process-local (a restart clears it; the
first `EXACT` after rebuilds) and is skipped for TTL tables, whose row
visibility decays without writes — `EXACT` on those rescans every call.
- **Admin statements** (`SHOW CLUSTER`/`SHOW CONFIG`/`SET CONFIG`/
`SHOW SLOW QUERIES`/`REPAIR CLUSTER`/`RECLAIM`/`ALTER CLUSTER`) are the
SQL spellings of the HTTP `/admin/*` control plane — identical handler,
RBAC (reads: `MONITOR` on `*`; mutations: `ADMIN` on `*`), and audit;
results come back as `(key, value)`
rows (LIKE uses `%`/`_`). They execute on the **network server** — the
embedded `--local` engine rejects them. `SET CONSISTENCY` is
per-connection session state on binary-protocol sessions (it overrides
the wire consistency until changed); the stateless REST gateway rejects
it with guidance. **`SET SCAN BUDGET ROWS [BYTES ]`** is
per-connection too, and **tightening-only**: statements run under
`min(node config, session)` per axis, so a session can restrict what
its own statements may scan but never lift the operator's ceiling —
which is why any authenticated role may issue it (values must be
positive; `DEFAULT` clears). One trap to know: after a silent driver
reconnect the session reverts to node defaults — drivers replay the
session database but not this setting (the Rust driver's
`set_scan_budget_rows` is the exception: it stores and replays it).
- **`BACKUP TO ''`** copies the whole data directory (tables, WALs,
catalog, search and time-series stores) under the exclusive lock —
crash-consistent by construction (opening the copy replays WALs like a
crash recovery); vector indexes load their persisted snapshot on open and
replay only rows newer than its watermark (full rebuild if absent). A
target already holding a skaidb backup is updated **incrementally**:
unchanged files are reused, changed ones re-copied, removed ones
deleted — always a faithful full snapshot, costing only the delta
(the result reports `files`/`bytes` copied, `reused_files`/
`reused_bytes`, `deleted`). A target that exists but is not a backup is
refused. On a cluster each node backs up **its own
shard** — and **`BACKUP CLUSTER TO ''`** does that on EVERY member
in one statement, then mints a single cut instant strictly AFTER all
backups completed and records it beside each one (`CLUSTER_CUT`, and
the `cut_ms` result column). Restoring every node with
`RESTORE FROM '' TO TIMESTAMP ` lands the whole ring on
that one instant: each node's archive replay extends its backup FORWARD
to the cut (replay is exact at its end — it can reach forward, never
rewind), which is why the cut postdates the backups. Requires
`storage.wal_archive_dir` for the restore to be exact. **`RESTORE FROM ''`** swaps the backup in and reopens,
moving the previous data aside to `.pre-restore-` (never
deleted); it refuses to run on a cluster — stop the node and restore
offline, then let repair converge it.
- **`RESTORE FROM '' TO TIMESTAMP ''`** is point-in-time
recovery: the backup is restored, then archived WAL segments are
replayed on top of it up to `` — so a bad `DELETE` at 14:07 is
undone without losing the work done since the last backup. `` is
ISO-8601 (`'2026-08-19T14:06:00Z'`, `'2026-08-19 14:06'`) or epoch
milliseconds, and the window INCLUDES everything stamped in that
millisecond. Requires `storage.wal_archive_dir`; the result adds
`replayed_to`, `segments` and `records` columns.
Refused, rather than silently approximated, when: no archive is
configured, the instant is unparseable, or the window **crosses a
schema change** — the archive holds row mutations only, so DDL inside
the window cannot be replayed and rows belonging to it would be
dropped. Restore to a point before the change, or use a later backup.
Replay is idempotent (every record keeps its original stamp and the
engine is last-writer-wins), so running the same restore twice gives
the same state. Replayed rows go through the same apply path a
replicated write does, so **secondary, full-text, vector and geo
indexes are maintained** — a recovered row is found by `MATCH`,
`NEAREST` and an indexed lookup, not only by a scan. Change streams
observe replayed rows as they would any other write, so a consumer may
re-see changes it already processed. Time-series tables replay too, cut on the sample's
ARRIVAL time rather than its own timestamp — a backfill written at
14:10 carrying `ts=13:00` does not appear in a restore to 14:06.
- **`SHOW STATUS`** returns storage and runtime statistics for the current
database as `(metric, value)` rows — table/index counts, on-disk and memtable
bytes, SSTable count, WAL bytes/fsyncs, compactions, cache hit/miss/hit-rate,
and a per-table `table...*` breakdown: row tables report
`{live_keys,tombstones,disk_bytes}`, TIME-SERIES tables report
`{kind=timeseries,series,samples_appended,disk_bytes}` (there are no
tombstones and no cheap exact live-sample count; the legacy
`timeseries..*` keys remain). It
is the same data the server publishes at `GET /metrics`, surfaced as SQL.
- **`CREATE`/`DROP DATABASE`**, **`USE`**, and **`SHOW DATABASES`** manage
databases — each is an isolated set of tables and indexes. A database is a
**namespace**: internally a table is stored under a per-database name, with the
implicit `default` database using unprefixed names (so an existing
single-database directory keeps working unchanged — its tables are the
`default` database). `USE` sets the **current database** for the connection
(the `skaidbsh` shell, or a binary-protocol connection); unqualified table
names resolve against it, and `db.table` reaches another database without
switching. `SHOW DATABASES` lists them as `(database, current)` rows with `*`
marking the current one; `SHOW TABLES`/`SHOW INDEXES` are scoped to the current
database. The `default` database cannot be dropped; dropping the current
database (or its cascade) reverts the connection to `default`.
- **Replication:** in cluster mode, `CREATE`/`DROP DATABASE` broadcast to every
node (like other DDL), and writes inside any database replicate by the same
quorum/hinted-handoff/read-repair path as the `default` database. The REST
`/query` gateway is stateless — it always starts at `default`, so reach other
databases there with `db.table` qualifiers rather than `USE`.
- **`DISTINCT ON (, …)`** keeps the first row of each group of
equal expressions, in the query's `ORDER BY` — whose leading keys must
be exactly the `DISTINCT ON` expressions (`SELECT DISTINCT ON (k) k, v
FROM ev ORDER BY k, ts DESC` = the latest row per `k`); without an
`ORDER BY` the row picked per group is arbitrary. `LIMIT` / `OFFSET`
apply after the pick. Lowered onto `ROW_NUMBER() OVER (PARTITION BY …
ORDER BY …) = 1`, so it materializes the filtered set like a window
function and cannot combine with `GROUP BY` / aggregates (use `GROUP
BY … TOP 1 BY …` for that) or appear in a set-operation branch.
- **`DISTINCT`** removes duplicate output rows. `SELECT DISTINCT ` (no ORDER BY/GROUP BY/JOIN) streams the value set without
materializing rows — safe on tables of any size; an array-valued column
dedupes whole arrays. **`HAVING`** filters groups after
aggregation (it may reference aggregates and the `GROUP BY` columns).
- **Output aliases resolve in `ORDER BY`, `GROUP BY`, and `HAVING`**
(`SELECT count(*) AS c … HAVING c > 1 ORDER BY c DESC`, `SELECT
time_bucket(1m, ts) AS t … GROUP BY t`). `ORDER BY`/`GROUP BY` resolve
bare names (`ORDER BY c`, not `ORDER BY c + 1`); `HAVING` resolves
references anywhere in its predicate. When an alias shadows a source
column of the same name (`SELECT price >= 10 AS price`), `ORDER BY`
prefers the output column while `GROUP BY`/`HAVING` keep the source
column — SQL's usual preference rules.
- **`GROUP BY ... TOP BY [ASC|DESC]`** — per-group top-k **rows**:
instead of one aggregated row per group, each group contributes its `k`
best rows ranked by the expression (`DESC` — best-first — is the
default; NULLs rank last either way). The select items are then ordinary
per-row expressions (`*` works; aggregates cannot mix with `TOP`), and
`HAVING` (which may aggregate) still filters whole groups first.
`ORDER BY`/`LIMIT`/`OFFSET` apply to the flattened output; without
`ORDER BY`, groups keep first-seen order with rows best-first inside
each. Under a search predicate `TOP k BY score()` ranks by BM25 — the
SQL spelling of ES `top_hits` (per-group best documents), and
`HIGHLIGHT()` works in the projection.
- **`JOIN`** combines tables — equi-joins (`ON a = b`) run as a hash join,
other predicates and `RIGHT` joins fall back to a nested loop.
`INNER`/`LEFT`/`RIGHT`/`CROSS` are supported (`JOIN` alone means `INNER`;
`CROSS JOIN` takes no `ON`). Reference columns **qualified** by table alias
(`u.id`, `o.amt`); an unqualified field resolves against whichever joined
table defines it (first table wins on a name clash). `SELECT *` over a join
expands to the underlying fields.
- **`UNION`** / **`INTERSECT`** / **`EXCEPT`** `[ALL]` combine the rows of
several `SELECT`s that share a column count: `UNION` concatenates,
`INTERSECT` keeps the rows present in both, `EXCEPT` removes the rows
the right side has. Without `ALL` the result is deduplicated; with
`ALL` it is a multiset (`UNION ALL` keeps every row, `INTERSECT ALL`
keeps each row as many times as both sides have it, `EXCEPT ALL`
removes one occurrence per right-side row). Operations apply **left to
right** (no `INTERSECT`-binds-tighter rule: parenthesise by writing the
legs in the order intended). A trailing `ORDER BY`/`LIMIT`/`OFFSET`
after the last branch applies to the whole combined result and
references the **output column** names.
- **`BEGIN`/`COMMIT`/`ROLLBACK`** wrap several statements in a transaction
with read-your-writes: buffered writes are invisible to every OTHER
session until `COMMIT` (transaction state is per-connection), and
`ROLLBACK` discards them. Available on the embedded engine and over
**binary-driver connections to a standalone server**; stateless surfaces
(REST/ES/UI) autocommit, and cluster mode refuses (no distributed
transaction coordinator). Concurrent sessions' transactions coexist;
conflicting commits resolve last-writer-wins. DDL is not transactional.
**Measured ACID contract** (`acid-crash` harness, kill -9 rounds):
every acknowledged statement survives a crash (durability is
fsync-on-ack), and a transaction commit is **all-or-nothing across
crashes** — COMMIT persists a redo journal before applying, and
recovery replays an interrupted commit to completion (a crash before
the journal is durable means the transaction cleanly never happened).
- **`BEGIN ATOMIC PARTITION (col = lit, ...)`** is the CLUSTER transaction:
a single-shard transaction pinned to one partition of `distribute_by`
tables (over a binary-driver connection). Every statement inside may
touch only rows whose distribution columns equal the pinned literals —
across any number of tables distributed by those columns — and reads see
the session's own buffered overlay. `COMMIT` applies the write set
**all-or-nothing**: it serializes on the partition through a consensus
round whose committed manifest embeds the write set, so a coordinator
crash after the round is completed forward by every replica's sweeper
(never a partial commit). Isolation is **atomicity only** — concurrent
transactions and non-transactional writes resolve last-writer-wins per
row; there is no read-set validation. Constraints: the buffered write
set is capped at 8 MB; tables with UNIQUE indexes, GLOBAL indexes, or
value-TOAST are refused (those write legs live outside the partition);
DDL and `USE` are refused while a transaction is open; time-series
writes cannot participate. Transaction control requires the `INSERT`
privilege on the **session's current database** (the same gate as
embedded `BEGIN`) — a grant `ON *` also satisfies it. The gate decides
only whether a session may open a transaction; every statement inside is
still authorized against its own table. Clients
MUST stop on a failed `BEGIN`: the statements that follow would
otherwise execute in autocommit — durable partial writes where a
transaction was intended.
- **Access control.** `` is one of `SELECT`, `INSERT`, `UPDATE`,
`DELETE`, `CREATE`, `DROP`, `GRANT`, `MONITOR`, `PUBLISH`, `SUBSCRIBE`,
`ADMIN` (`ADMIN` on `*` =
superuser; `ADMIN` on a table implies every privilege on it). `MONITOR`
(granted `ON *`) opens the **read-only** control plane — `SHOW CLUSTER`,
`SHOW CONFIG` (secrets stay masked), `SHOW SLOW QUERIES`, and the matching
read-only HTTP admin endpoints — so an application role can report cluster
health and its effective config without an admin credential; it never
authorizes a mutation (`SET CONFIG`, repair, membership stay `ADMIN`).
**Index DDL is table-scoped:** `CREATE INDEX` needs `CREATE` on the target
table, and `DROP INDEX` / `REBUILD`/`ALTER` of an index need that same
`CREATE` on the index's **owning table** — an index is derived data, so a
role that can create its own indexes can also drop or retune them (no
`DROP on Global` needed; `DROP TABLE` still requires `DROP`). Dropping a
nonexistent index needs no privilege (`IF EXISTS` stays an idempotent
no-op; index existence is already free via `SHOW INDEXES`). A **user** authenticates
(SCRAM on the binary protocol, HTTP Basic on REST) and acts as its
own-named role; `GRANT ROLE r TO u` adds inherited roles. A user created
`… GSSAPI` is **external**: it has no local password — a Kerberos KDC
vouches for the principal, and skaidb only maps the authenticated principal
to its own-named role (grants and role inheritance work identically). An
external user cannot authenticate by password/SCRAM, and a password user is
never reachable through the external path. A grant
`ON DATABASE db` covers every table in that database (checked against the
session's current database; shown by `SHOW GRANTS` as `db:`).
**A table grant is scoped to the table's canonical `.
`
identity, not the raw name.** A bare `GRANT … ON t` issued while the
session is in database `d` grants `d.t` — never a wildcard that would
match a same-named `t` in another database — and a qualified
`GRANT … ON d.t` authorizes the natural `USE d; … t` query just as it
authorizes `… FROM d.t` (both resolve to the same table). `SHOW GRANTS`
renders the object as `.
` (bare for the default
database). All
management statements (and `SHOW GRANTS`) require the `GRANT` privilege
cluster-wide — except `SHOW GRANTS FOR `, which any
authenticated role may run to inspect itself. Auth DDL (user/role/grant
changes) is recorded in the identity audit log (the login-log category)
with the acting role and a secret-free statement summary.
Users/roles persist in the catalog, replicate like other DDL (passwords
travel between nodes only as salted SCRAM verifiers, never plaintext), and
converge via schema repair. Each credential carries a **random** salt,
drawn once when the password is set and stored inside the verifier, so it
is identical on every member; `ALTER USER … PASSWORD` draws a fresh one. The config-file superuser remains the
bootstrap principal. The Prometheus endpoints are covered too:
`remote_write` requires `INSERT` and `/api/v1/query*` require `SELECT` on
the `metrics` table.
## Expressions
- **Array membership:** comparing an array-valued column to a non-array
scalar tests containment: `labels = 'work'` matches rows
whose array holds `'work'`; `!=` is not-contains. Array-to-array
comparison remains whole-value equality.
- **Literals:** integer (`42`), float (`3.14`, scientific `1.2e-5`/`3E8`), string (`'ada'`), `TRUE`,
`FALSE`, `NULL`, **array** literals `[ [, ...]]` (constant
elements only — a literal or a negated number, e.g. `[0.1, -0.2, 0.3]`),
and **object/document** literals `{: [, ...]}` — the way to write
a nested document in `SET`/`VALUES`. Keys are bare identifiers or string
literals (quote reserved words: `{'from': 1}`); values must be constant and
may nest arrays and objects: `{name: 'ada', tags: ['x'], addr: {city: 'paris'}}`.
A duplicate key keeps the last value; `{}` is the empty document. Assigning
an object literal to a path (`SET meta.addr = {…}`) **replaces** that whole
sub-document (sibling fields outside the path survive); dotted-path `SET`
(`SET meta.addr.city = 'x'`) remains the idiom for updating a single scalar
leaf in place.
- **Subqueries:** ` [NOT] IN (SELECT …)` (membership in the
subquery's first column), `[NOT] EXISTS (SELECT …)`, and a scalar
`(SELECT …)` (one column, at most one row — `NULL` when none; more
rows or columns is an error) — anywhere an expression goes: a SELECT's
items / `WHERE` / `HAVING` / `ORDER BY` / `GROUP BY` / join `ON`, an
`UPDATE`'s `SET` and `WHERE`, a `DELETE`'s `WHERE`, an `INSERT`'s
`VALUES`; they nest, and may name a view. **Uncorrelated only:** a
subquery's column references are its own tables' (the store is
schema-less, so an inner name can never be proven to belong to the
outer row); a column qualified by the OUTER query's alias that the
inner query does not declare is refused (`correlated subqueries are
not supported: "c.id" refers to the outer query`). Each subquery runs
**once per statement**, before the statement, at the session
consistency and under the scan byte budget, and is replaced by a
literal — `IN` by an `IN (…)` list (NULLs dropped, so `NOT IN` behaves
like a plain list rather than Postgres' three-valued trap), `EXISTS` by
a boolean (the inner runs with `LIMIT 1`), a scalar by its value — so
the statement that then runs is an ordinary one: single-table `WHERE`
pushdown, index scans, keyset paging and every cluster path see a
plain predicate. An `IN` list is checked per candidate row by a linear
scan, as any `IN (…)`; a large membership set is what a `JOIN` is for.
Not allowed in a `CHECK` / `DEFAULT` / generated expression. RBAC:
`SELECT` on every table a statement reads — its `FROM`, join sides,
set-operation legs and subqueries — is checked on the user's own
grants.
- **Column / field paths:** `name`, or a dotted path into a nested document:
`address.city`, `a.b.c`. Usable in projections, `WHERE`, `GROUP BY`,
`ORDER BY`, and `UPDATE … SET` targets.
- **Operators**, by increasing precedence:
1. `OR`
2. `AND`
3. `NOT `
4. comparison: `=`, `!=` (or `<>`), `<`, `<=`, `>`, `>=`
5. ` IS [NOT] NULL`
6. postfix predicates: ` [NOT] IN ( [, ...])`,
` [NOT] BETWEEN AND `,
` [NOT] { LIKE | ILIKE } `
7. additive: `+`, `-`
8. multiplicative: `*`, `/`
9. unary: `-`, `NOT `
10. parentheses `( … )`
`in`, `between`, `like`, and `ilike` are contextual keywords — still usable
as column names outside the operator position.
- **`IN` / `NOT IN`** — set membership: `x IN (a, b, c)` is true when `x`
equals any listed element (three-valued: unknown if `x` is `NULL`, or if
no element matches but some element is `NULL`); `NOT IN` negates it. The
list needs at least one element (`IN ()` is a parse error). A list element
that is an **array** is flattened — each of its elements becomes a
candidate — so a bound array parameter works directly: `WHERE id IN (?)`
with `?` = `[1, 2, 3]` tests membership in that set (the "fetch these N
ids" pattern). When the left side is an array column, `IN` matches if the
array holds any listed value, mirroring the `=` containment rule above.
> **Performance:** when **every primary-key column** is pinned by `=` or a
> literal `IN` list (bound array parameters included), the query resolves
> as a **point-read set** — one bloom-gated point read per candidate key
> (cross product on composite keys, capped at 1000 keys), routed to each
> key's replica set on a cluster; `EXPLAIN` shows `point-read set`. Other
> `IN` shapes (non-PK columns, `NOT IN`, non-literal elements) evaluate as
> a residual row filter over the scanned range and can trip
> `scan budget exceeded` on large unindexed scans — pair those with a
> narrowing indexed predicate.
- **`BETWEEN`** — `x BETWEEN lo AND hi` is the inclusive range
`x >= lo AND x <= hi` (three-valued: a `NULL` operand or bound makes the
undecided side unknown); `NOT BETWEEN` negates it. A `col BETWEEN lo AND hi`
with literal bounds contributes both bounds to index/PK **range pushdown**,
exactly like writing the two comparisons.
> **Performance:** a range on the **leftmost primary-key column** is a
> bounded key slice, not a scan — the table is stored in
> primary-key order, so `WHERE id >= ? AND id < ?` seeks. A
> time-sortable PK (ULID/snowflake) therefore serves time ranges,
> *including* range aggregations (`GROUP BY`,
> `count`). An equality-pinned PK prefix plus a range on the next
> column slices the same way. `EXPLAIN` shows
> `primary-key range slice`.
- **`LIKE` / `ILIKE`** — SQL pattern match on strings: `%` matches any run of
characters (including none), `_` matches exactly one; every other character
matches itself, and there is **no escape sequence** (a literal `%`/`_` in the
data cannot be targeted — use equality or a search index). `ILIKE` is
case-insensitive (Unicode-aware lowercasing). The pattern is any expression
(usually a literal or a `?` parameter). Non-string operands — including
`NULL` — compare as unknown, so a mixed-type column never errors the query.
This is **exact substring/prefix matching**, complementing the analyzed
full-text `MATCH()` (which tokenizes words): use `LIKE '%needle%'` for
verbatim fragments, `MATCH` for word search.
> **Performance note:** `LIKE`/`ILIKE` evaluate as a residual row filter
> (no index acceleration, including `'prefix%'`) — the same
> scan-budget caveat as `IN` applies on large unindexed scans.
- **Aggregate functions:** `COUNT(*)` / `COUNT()` /
`COUNT(DISTINCT )` (exact distinct non-null values),
`APPROX_COUNT_DISTINCT()` (opt-in approximate distinct: the
search-index pushdown may answer from an HLL sketch — never truncates,
~±1–2% at high cardinality; every other path answers exactly), `SUM`,
`AVG`,
`MIN`, `MAX`, `PERCENTILE(,
)` (the linear-interpolated
`percentile_cont` quantile of the group's numeric values; `