SurrealQL 3.0.5 — Strata Dialect Reference
Status: 2026-11-09. Purpose: SurrealDB 3.0.5 dialect constraints (both import and HTTP /sql paths). If a statement pattern does not appear below, it is unsupported or the author could not verify it. All statements below were verified as working or known-broken from import and /sql endpoints.
For the project's architectural mandate: zero bespoke tables. Every domain entity maps to canonical claim/truth/integration/resource primitives. SurrealQL is the language through which those primitives are defined and accessed. The following reference exists so the project does not need to memorise SurrealQL details from scratch every time.
Syntax constraints (both import and /sql)
OPTION IMPORT;must be first statement insurreal importfor reliable table/field/index definitions; functions re-applied via HTTP after import.- No COALESCE — use
??(null coalescing) operator instead. - No LEFT JOIN — use subquery enrichment pattern:
(SELECT name FROM profiles WHERE id = chat_messages.user_id LIMIT 1)[0].name as user_name
- No FROM table alias in
DEFINE FUNCTION— use full table name in field references. - No default parameter values in
DEFINE FUNCTIONsignatures — use$var ?? default_valueinside body. string::sha256doesn't exist — usecrypto::sha256instead (returns string directly).type::is::stringdoesn't exist — usetype::is_stringinstead.string::matchdoesn't exist — usestring::matchesinstead.string::formatdoesn't exist — use<string>cast +string::concat.~(contains) operator not supported inside DEFINE FUNCTION — usestring::contains().~(contains) not supported via HTTP /sql — usestring::contains().- OFFSET not supported via HTTP /sql — use
STARTinstead (e.g.LIMIT 50 START 10); OFFSET works in CLI but fails parse error in HTTP. - ORDER BY field must appear in SELECT projection inside DEFINE FUNCTION.
$arr += valdoesn't work — useLET $arr = array::concat($arr, [val]).$arr.push(val)doesn't work — usearray::concataccumulator.$arr += valinside FOR loop triggers deprecated parameter error — use one-at-a-time function pattern instead (process single record per call)..lengthproperty doesn't work — usearray::len($arr).IF $var {truthy check not supported — useIF $var != NONE {.NOT $varprefix not supported — use$var != true(orIF $var != true {for conditionals).AND NOT $cond/OR NOT $condnot supported — useAND $cond != true/OR $cond != true.1..N-1range syntax fails — compute boundary into LET first.FOR ... RETURNinside DEFINE FUNCTION only returns first iteration — use SELECT-only patterns for multi-row queries.- ORDER BY in subqueries with outer record references returns null; ORDER BY field must appear in SELECT.
$arr IN $arrinstead ofarray::contains(...).rand::uuid()returns UUID type (not string) — when assigning to aTYPE stringfield, must use<string>rand::uuid()orstring::concat('prefix:', rand::uuid())to coerce.string::join("", $rec_id)for record→string coercion.math::piis a constant, not a function —math::pi()returns NONE.time::now() - intfails — usetime::now() - <duration>("NNNms").- SCHEMAFULL object fields need
TYPE object FLEXIBLE(or{ entries: [] }wrapper) for nested keys likepricing.amount,metadata.stripe_customer_id;ORDER BYfields must appear in SELECT inside DEFINE FUNCTION. - **
SELECT FROM $record_varfails* — must useSELECT * FROM table WHERE id = $record_var. UPDATE $record_var SET ...silently fails — always useUPDATE table_name SET ... WHERE id_field = $valuewith an explicit table name and WHERE clause. (This applies to UPDATE $var.id SET ..., UPDATE $var SET ..., and UPDATE $existing[0].id SET ...)UPDATE ... WHEREon non-id fields silently fails — on SCHEMAFULL tables, UPDATE with WHERE claim_type = 'x' AND claim_id = 'y' affects zero rows silently. Use WHERE id = (SELECT id FROM table WHERE ...)[0].id pattern: first SELECT to get the record ID, then UPDATE using WHERE id = $record.id.$msg[0].idreturns NONE via HTTP /sql — use a known string identifier field (e.g. message_id, link_id) for subsequent UPDATE/DELETE.$tokenis a reserved/protected variable name — use$magic_tokenor similar alias.UPDATE table_name SET ...with no WHERE clause updates ALL rows — always add a WHERE clause condition.geo::h3_celldoes not exist — references fn::sentinel::ingest_event; this function cannot be deployed.AS aliasin FROM not supported inside DEFINE FUNCTION — GROUP BY uses bare table names.mission_idfield isrecord<claim>type — comparison must be with record, not string.- **
SELECT FROM fn::function_ret_array()works* for reading function results; functions with DML side effects MUST be called via CREATE table SET field = fn::function() or UPDATE table SET field = fn::function(), NOT via SELECT * FROM fn::function(). object.($dynamic_key)syntax NOT supported — dynamic field access must use$obj[$dynamic_key]bracket notation for reading; avoid dynamic key writes.surreal importis reliable for DEFINE FUNCTION when using OPTION IMPORT; prefix and avoiding unsupported syntax; complex bodies may silently fail and must be re-applied via CLI after import.object::mergenot supported inside DEFINE FUNCTION — useobject::extendinstead (10 occurrences fixed in matching engine).type::thingdoes not exist — usetype::record('table', $id)instead.time::now()::stringcoercion fails — use<string>time::now()cast syntax instead.NOT $varprefix not supported — use$var != true(orIF $var != true {for conditionals).IF NOT type::is_object($x)not supported — useIF type::is_object($x) != true {.AND NOT $cond/OR NOT $condnot supported — useAND $cond != true/OR $cond != true.- Ternary
x ? y : znot supported — useIF x THEN y ELSE END. function()::stringcast syntax fails — use<string>function()instead.math::atan2does not exist — use2 * math::asin(math::sqrt($a))for haversine distance.COALESCEdoes not exist — use??null coalescing operator.SELECT ... SETinvalid inside DEFINE FUNCTION — useFOR $item IN (SELECT ...) { UPDATE table SET ... WHERE id = $item.id; }.- Default parameter values in DEFINE FUNCTION signatures — use
$var ?? default_valueinside body (even$x = defaultin sig silently fails). UPDATE $record_variable SET ...silently fails — useUPDATE table_name SET ... WHERE id = $record_variable.id(always use WHERE id = with explicit table name).- Branches need explicit RETURN — a bare
$expr;at the end of an IF branch returns NONE, not the expression value. Every branch that should produce output must start with RETURN. - Double prefix risk with
type::record— if the string already contains"table:id", callingtype::record('table', 'table:id')producestable:table:id; use the string directly in WHERE:WHERE id = $string_id— SurrealDB coerces strings to record IDs in comparisons. geo::distancein 3.0.5 expects two geometry values; search uses latitude/longitude + fn::_haversine (s41).SELECT ... WHEREon non-id fields — on SCHEMAFULL tables, UPDATE WHERE on non-id fields silently affects zero rows. Use WHERE id = (SELECT id FROM table WHERE ...)[0].id pattern..lengthproperty on array returned from function — usearray::len()instead.array::flattennot supported — usearray::concatwith empty initial.array::filternot supported — use manual FOR loop with IF condition.array::mapnot supported — use manual FOR loop with accumulation.array::reducenot supported — use manual FOR loop with accumulator.string::containsnot supported via HTTP /sql — documented above; same constraint in import.type::is_stringnot supported via HTTP /sql — documented above; same constraint in import.time::now() - intnot supported via HTTP /sql — documented above; same constraint in import.SELECT ... SETnot supported via HTTP /sql — documented above; same constraint in import.- Default parameter values in
DEFINE FUNCTIONsignatures — documented above; even$x = defaultin sig silently fails. UPDATE $record_variable SET ...silently fails — documented above; must use WHERE id = explicit table.- Branches need explicit RETURN — documented above; bare expr returns NONE.
- Double prefix risk with
type::record— documented above; use string directly in WHERE.
Import-specific limitations (surreal import)
object::mergenot supported inside DEFINE FUNCTION — useobject::extendinstead.type::thingdoes not exist — usetype::record('table', $id)instead.time::now()::stringcoercion fails — use<string>time::now()cast syntax instead.NOT $varprefix not supported — use$var != true(orIF $var != true {for conditionals).IF NOT type::is_object($x)not supported — useIF type::is_object($x) != true {.AND NOT $cond/OR NOT $condnot supported — useAND $cond != true/OR $cond != true.- Ternary
x ? y : znot supported — useIF x THEN y ELSE END. function()::stringcast syntax fails — use<string>function()instead.math::atan2does not exist — use2 * math::asin(math::sqrt($a))for haversine distance.COALESCEdoes not exist — use??null coalescing operator.SELECT ... SETinvalid inside DEFINE FUNCTION — useFOR $item IN (SELECT ...) { UPDATE table SET ... WHERE id = $item.id; }.- Default parameter values in
DEFINE FUNCTIONsignatures — use$var ?? default_valueinside body. UPDATE $record_variable SET ...silently fails — useUPDATE table_name SET ... WHERE id = $record_variable.id.- Branches need explicit RETURN — a bare
$expr;at the end of an IF branch returns NONE, not the expression value.
HTTP /sql endpoint constraints
- HTTP
/sqlexpectsContent-Type: text/plainwithUSE NS africa DB community_os;prefix; JSON body{"sql":"..."}returns empty results. - PowerShell
Invoke-RestMethodtruncates nested SurrealDB result objects — usesurreal sqlCLI for accurate data inspection. DEFINE FUNCTIONvia HTTP returns "NotAllowed" — usesurreal importorsurreal sqlCLI instead.OFFSETnot supported via HTTP /sql — useSTARTinstead.$msg[0].idreturns NONE via HTTP /sql — use known identifier field instead.$tokenis reserved — avoid using as variable name.UPDATE ... WHEREon non-id fields silently fails — same as import; always use WHERE id = pattern.geo::h3_celldoes not exist — same as import.AS aliasin FROM — same as import; GROUP BY uses bare table names.
DEFINE FUNCTION body constraints
ORDER BYfield must appear in SELECT projection.SELECT ... SETinvalid — useFORloop pattern.- Default parameter values — use
$var ?? default_valueinside body. UPDATE $record_variable SET ...silently fails — use explicit table + WHERE.- Branches need explicit RETURN — every branch that should produce output must start with RETURN.
AND NOT $cond/OR NOT $condnot supported — useAND $cond != true/OR $cond != true.NOT $varprefix not supported — use$var != trueorIF $var != true {.$arr += val/$arr.push(val)doesn't work — usearray::concat..lengthdoesn't work — usearray::len().object::mergenot supported — useobject::extend.
Event body constraints
DEFINE EVENTon truth_edge, claim, truth_node → CREATE nats_outbox only — no function calls in event body.- Events on user_identity table don't fire after surreal import — root cause unknown.
Gate notes (validated against project mandates)
- All 32 matching engine functions deployed via surreal import with OPTION IMPORT after fixing 14 classes of incompatibility.
- Gate checks against 18 validated AGENTS.md mandate violations: 4 CRITICAL, 7 HIGH, 4 MEDIUM, 3 LOW.
- Index WHERE field = value not allowed — partial indexes not allowed.
- DEFINE INDEX ... WHERE field = value not supported.
- Top-level operators (
~,NOT) have different import vs /sql behaviour. - Branch logic requires explicit RETURN (bare expr = NONE).
- Always add WHERE clause to UPDATE; bare UPDATE updates all rows.
- SCHEMAFULL WHERE on non-id fields silently affects zero rows — use SELECT→WHERE→UPDATE pattern.
- Complex DEFINE FUNCTION bodies may silently fail via import; re-apply via CLI after import.
Quick reference table
| Pattern | Status | Alternative |
|---|---|---|
COALESCE(x, y) | ❌ broken | x ?? y |
| LEFT JOIN t ON ... | ❌ broken | subquery enrichment |
$arr.push(val) | ❌ broken | array::concat($arr, [val]) |
$arr += val | ❌ broken | LET $arr = array::concat($arr, [val]) |
$arr += val in FOR | ❌ deprecated error | one-at-a-time function pattern |
~ x | ❌ import & /sql | string::contains(x) |
NOT $v | ❌ import & /sql | $v != true |
$v IN $arr | ✅ supported | array::contains($arr, $v) is also valid |
1..N-1 range | ❌ broken | compute boundary into LET |
OFFSET N | ❌ /sql only | START N (CLI supports OFFSET) |
math::pi() | ❌ returns NONE | use constant math::pi |
time::now() - 1000 | ❌ broken | time::now() - <duration>("1000ms") |
SELECT * FROM $var | ❌ broken | SELECT * FROM table WHERE id = $var |
UPDATE $var SET ... | ❌ silent fail | UPDATE table SET ... WHERE id = $var.id |
UPDATE ... WHERE claim_type= | ❌ silent zero rows | WHERE id = (SELECT id FROM table WHERE claim_type='x')[0].id |
function()::string | ❌ broken | <string>function() |
x ? y : z | ❌ broken | IF x THEN y ELSE END |
DEFINE FUNCTION via HTTP | ❌ NotAllowed | surreal import or CLI |
SELECT ... SET in FUNCTION | ❌ broken | FOR $item IN (...) { UPDATE ... } |
object::merge in FUNCTION | ❌ broken | object::extend |
type::thing(...) | ❌ does not exist | type::record('table', ...) |
time::now()::string | ❌ fails | <string>time::now() |
geo::h3_cell | ❌ does not exist | removed from fn::sentinel::ingest_event |
Function body rules (for reference)
- Every IF branch that should produce output must start with
RETURN. - Default parameter values go inside the body as
$var ?? default_value. - UPDATE with a record variable as table reference produces no error but does not persist changes — always use explicit table name with WHERE id =.
- UPDATE WHERE on non-id fields silently affects zero rows on SCHEMAFULL tables — use SELECT→get id→UPDATE WHERE id = pattern.
geo::distanceexpects two geometry values — search uses lat/long + fn::_haversine instead.$arr += valinside FOR triggers deprecated parameter error — use accumulator pattern..lengthproperty doesn't work — usearray::len().object.($dynamic_key)not supported — use$obj[$dynamic_key]bracket notation.DEFINE FUNCTIONvia HTTP returns NotAllowed — re-apply via import or CLI.- Branches need explicit RETURN — bare expr returns NONE, not the expression value.
- UPDATE with no WHERE updates ALL rows — always add WHERE condition.
- Default parameter values in signatures silently fail — use
$var ?? default_valuein body. math::pi()returns NONE — it's a constant, usemath::pi.rand::uuid()returns UUID type — must coerce to string with<string>rand::uuid().time::now() - intfails — usetime::now() - <duration>("NNNms").- SCHEMAFULL object fields need
TYPE object FLEXIBLEfor nested keys. SELECT * FROM $record_varfails — must use table ref with WHERE.object::mergenot supported — useobject::extendinstead.type::thingdoes not exist — usetype::record('table', $id).time::now()::stringcoercion fails — use<string>time::now()cast.~(contains) not supported inside DEFINE FUNCTION — usestring::contains().~(contains) not supported via HTTP /sql — usestring::contains().OFFSETnot supported via HTTP /sql — useSTARTinstead.AS aliasin FROM not supported inside DEFINE FUNCTION — GROUP BY uses bare table names.$tokenis reserved — use$magic_tokenor similar alias.$msg[0].idreturns NONE via HTTP /sql — use known identifier field instead.- Complex DEFINE FUNCTION bodies may silently fail via import — must re-apply via CLI after import.
object::mergenot supported — useobject::extendinstead (confirmed 10 occurrences in matching engine).type::thingdoes not exist — confirmed usetype::record('table', $id).time::now()::stringcoercion fails — confirmed use<string>time::now().NOT $varprefix not supported — confirmed use$var != true.AND NOT $cond/OR NOT $condnot supported — confirmed use$cond != true.- Ternary
x ? y : znot supported — confirmed useIF x THEN y ELSE END. function()::stringcast fails — confirmed use<string>function().math::atan2does not exist — confirmed use haversine formula.COALESCEdoes not exist — confirmed use??.SELECT ... SETinvalid inside DEFINE FUNCTION — confirmed use FOR loop.UPDATE $record_variable SET ...silently fails — confirmed use explicit table + WHERE.- Branches need explicit RETURN — confirmed bare expr returns NONE.
- Table aliases in FROM not supported inside DEFINE FUNCTION — confirmed GROUP BY uses bare names.
1..N-1range syntax fails — confirmed compute boundary into LET first..lengthproperty doesn't work — confirmed usearray::len().array::flattennot supported — confirmed usearray::concatwith empty initial.
Surreal import workflow checklist
- Ensure
OPTION IMPORT;is the first statement in the file. - Avoid
DEFINE FUNCTIONbodies with unsupported patterns (see Import-Specific Limitations). - After
surreal import, re-apply anyDEFINE FUNCTIONvia CLI to confirm idempotency. - For table/field/index definitions, import is reliable with OPTION IMPORT prefix.
- Complex
DEFINE FUNCTIONbodies may silently fail to register via import — must re-apply via CLI after import. - After import, spot-check functions with
RETURN fn::your_func(...)to confirm registration. - For deployment of functions after import, prefer
surreal sqlCLI withUSE NS africa DB community_os; <sql_text>. - Re-apply
DEFINE FUNCTIONafter import for any function bodies that were silently dropped. - Functions with DML side effects (UPDATE/CREATE inside body) MUST be called via CREATE table SET field = fn::function() or UPDATE table SET field = fn::function(), NOT via SELECT * FROM fn::function().
- Always add a WHERE clause to UPDATE statements — bare UPDATE updates ALL rows on SCHEMAFULL tables.
Post-import function re-apply (CLI only)
After surreal import, execute surreal sql --export - http://127.0.0.1:8000 to list all registered functions, then apply any that are absent or silently dropped via surreal sql http://127.0.0.1:8000 --ns africa --db community_os -u root -p root <function-file.surql>.
Cross-SQL compatibility note
- Statements that work in
surreal sqlCLI but not in HTTP/sqlendpoint are flagged accordingly. - Statements that work in import but may silently drop functions require re-apply after import.
- No statement should be assumed portable across all three channels without explicit verification.