Changelog¶
All notable changes to this project will be documented in this file.
The format follows Keep a Changelog and this project adheres to Semantic Versioning.
[Unreleased]¶
[0.34.1] - 2026-10-01¶
Fixed¶
- A lone statement that lost a write conflict is sent again. SurrealDB 3.3 compacts indexes in the background after writes, and an ordinary single-statement write to the same index can lose a write conflict to that pass, failing with "This transaction can be retried". The query funnel behind
query,query_with_vars,query_with_surreal_varsand the typed CRUD methods now sends such a statement again, up to the connection'sretry_max_attempts, waiting its retry backoff between attempts. The conflict is read from the engine's structuredTransactionConflictkind, with the engine's fixed wording as a fallback; a statement's ownTHROWtext is never read as one. Only a request of one statement is retried: each statement commits on its own, so in a longer request the statements before a conflict may already have written, while a conflicted statement committed nothing.
[0.34.0] - 2026-09-30¶
A hardening pass over the whole crate: an adversarial review of every module, each finding reproduced by a failing test before it was fixed (unit tests, and engine tests against SurrealDB 3.0.5; the escaping rules also against 3.2.4 and 3.3.0), a cargo-fuzz harness whose oracle is the engine's own parser, and CI gates for the MSRV, the wasm client and the no-features build. Many fixes change rendered SQL, signatures or behaviour; every change a caller can observe is marked Breaking. Problems the pass found but did not fix are listed on the Known issues page.
A follow-up resolves those issues (migration checksums, deeply nested values, MTREE, COUNT indexes, union types, access and event comparison, Redis reconnects) and brings the crate to SurrealDB 3.3: it requires surrealdb 3.3, models the clauses 3.3 adds, reads back what 3.3 echoes, and runs its engine tests against a 3.3.0 server. What remains open is on the known-issues page.
Security¶
- Values are always data.
quote_valuerecognisedSurrealFnandRecordRefby JSON shape, so any object with an"expression"key (ortableplusrecord_id) inside insert, update, operator or batch data rendered as raw SurrealQL:{"body": {"expression": "1}; DELETE user; --"}}ran a second statement. Shape detection is gone. Raw expressions enter only through typed channels:Expression(nowFrom<SurrealFn>,From<RecordRef>,From<RecordID>), the neweq_expr/ne_expr/gt_expr/gte_expr/lt_expr/lte_expr, andset_expr, which now also fills CREATE / UPSERT / RELATECONTENT. Breaking - Record ids cannot be broken out of. String keys rendered as
⟨key⟩unescaped, and⟨ ⟩has no escape for⟩. Keys now render through the newtypes::escaperules (backticks for a key containing⟩,\escaped), tables throughquote_ident, and a digit-only string key stays a string key:RecordID::new("post", "123")renderspost:⟨123⟩, not the integer keypost:123(and"007"no longer names the integer 7).RecordID::parsekeeps any quoted key a string and reverses escapes. Breaking - Every identifier and literal sink is validated or escaped. Record-id targets were checked only up to the first
:in the builder, batch, crud, typed and graph helpers; ORDER BY, GROUP BY, search, vector, alias and graph names, CONTENT keys,SHOW CHANGEStimestamps and tables, hint text (which could close its/* */),db resettable names, the typed client's CRUD andLIVEtargets, and DDL comments, keys, backends and names all reached query text raw. Targets are now parsed and re-rendered; unquoted array, object, range and generator keys are refused. Breaking - An expired session is never replayed as the config's credentials. The client re-signed in with its config (typically root) credentials whenever a request failed with session expiry, even after
signinhad switched the session to a record user, and it matched expiry by substring on any error, so a stored value reading "session has expired" re-ran a whole multi-statement query. The replay now happens only for the config's own session, only on the request-level expiry error, and serialised with identity changes;connect()on acaller_sessionclient is refused. Breaking - Field
PERMISSIONSreach the database. They were modelled but never rendered, so every such field was createdFULL. They render, parse back, and drift is detected by the diff and the validator. - Event actions stay inside their event. A multi-statement
THENwas rendered without braces, so its later statements ran when the definition was applied. - Secrets are redacted from
Debugfor connection configs, credentials, tokens, clients, cache configs, Redis caches, settings and every orchestration type that embeds them. require_approvalandallow_destructiveare enforced on orchestrated deploys and on auto-rollback.- The Redis cache can no longer delete keys outside its prefix: an empty prefix meant
SCAN MATCH *, and the prefix was not glob-escaped. Breaking - GraphViz and Mermaid output, ASCII diagrams and validation reports escape names and terminal control bytes.
- A snapshot version can no longer write outside the snapshot directory, a migration description can no longer inject a
-- @upsection, and names in generated down statements are quoted. - A failed transaction reports the statement that failed, not the engine's generic "not executed" message for the others.
Fixed¶
- Query layer.
- Rows keep their shape: a row with a
resultfield, and the arrays ofSELECT VALUE, are no longer unwrapped or flattened, in the builder, the executor and the typed client alike. Breaking - Graph depth renders as unrolled hops (
->e->?), which the engine understands (->follows2named a tablefollows2);GraphQuery::tofollows the last hop's direction;LIMITrenders beforeFETCH; depths andshortest_pathare capped at 32. Breaking upsert_manyand friends require an id or conflict fields (a table-wideUPSERTadded a new record per call), prefix bare ids with the table, accept integer ids, and apply conflict fields on every path. Breakingtype_thingrenderstype::record(3.x rejectstype::thing);crud::lasthonours paging;has_moreno longer loops on a zero limit; non-finite vectors and thresholds are refused; integers abovei64::MAXrender as exact decimals; an empty object key renders.- Table names that are reserved words are quoted, following the engine's own reserved set: checked for every one of the 352 words in its lexer on SurrealDB 3.0.5, 3.2.4 and 3.3.0.
- Schema DDL and the
INFOparser. - The parser reads the engine's echo with a quote- and bracket-aware scanner: table permissions no longer keep the
,separator (a perpetual reconcile), FULLTEXT columns and index kinds read correctly, keyword-named fields and quoted clauses keep their clauses, function bodies with braces in strings survive, edgeIN/OUTendpoints are recovered, recordSIGNINsurvives a followingWITH JWT, andDROPand changefeeds are not read out of comments.parse_analyzerno longer panics on multi-byte names. - JWT access and bucket permission DDL follow the engine grammar (both were parse errors); the table
DROPflag renders. - Breaking:
BucketDefinition::permissionsisOption<String>(NONE | FULL | WHERE expr); parsed permission maps use"NONE"/"FULL"and drop default actions;JwtConfig::validaterefuses an unknown algorithm or anything but exactly one verifier. - Migrations: the schema diff.
- An added edge with permissions is one statement that applies; dropped tables, edges and indexes roll back to their whole definition (a
UNIQUEindex comes back unique); index, event and edge-shape changes are detected and re-defined withOVERWRITE; field nullability, record target and permission changes are detected; normalisation leaves string literals alone and is idempotent (it peeled one layer of parentheses per call). diff_schemasorders object adds (functions, params, sequences, analyzers, buckets) first, then every drop, then table and edge changes, then object drops. Breaking (output order; anddiff_permissionsrendersALTER TABLE … PERMISSIONS)- Migrations: files, squash, execution and history.
- Statements are split by a real SurrealQL lexer and sent verbatim: a
;or an apostrophe inside a comment split migrations in the wrong place, and a literal such as'a;b'could be stored as'a;\nb'. surql migrate squashno longer deletes schema statements:IF NOT EXISTSdefinitions were all keyed as the objectif, fields on different tables collided, and a DEFINE on one table paired with a REMOVE on another. Unreadable statements are never removed, and the text-based orphaned-UPDATE pass is gone. The squash preview no longer panics on non-ASCII text.- The history row commits inside the migration's own transaction, so a migration whose record fails rolls back, and two concurrent runners apply a migration once; a rollback's history delete is in the same transaction.
- The squash safety scan and rollback classification read past a leading comment (
-- purge\nDELETE FROM userhid the DELETE); aDELETEin a down body rates as danger. - Rolling back a migration with no
downfails instead of deleting its history row, andexecute_rollbackrefuses a risky plan untilRollbackPlan::approve(). Breaking - A squashed migration counts as applied when its sources are (it was re-applied); versions order numerically (
v10afterv9) anddepends_onorders migrations, with a cycle refused. - Two migrations generated in the same second no longer overwrite each other, and squash refuses to overwrite its output; a byte-order mark no longer hides the metadata block; checksums ignore line endings and the BOM (history rows recorded from the raw bytes still match).
- An applied migration edited afterwards is caught. Its checksum was recorded but never compared, so the edit went unnoticed and the database kept the old schema.
get_migration_statusnow lists such migrations inmodified(migrate statusshows them asmodified), andmigrate_up,surql migrate upand orchestration deploys refuse to run while any exist. Rows recorded from a file's raw bytes (earlier releases, surql-py) match on either line ending, with or without a byte-order mark. Breaking - The schema watcher no longer spins after it is dropped or panics outside a runtime, and reports over a bounded channel. Breaking
- Non-ASCII staged files are detected; a corrupt newest snapshot is an error instead of being skipped; snapshot comparison reports buckets.
- Validator and diagrams.
validate_schemacompares every attribute the crate renders (nullability, record target,REFERENCE,COMPUTED, permissions, view, changefeed,DROP, event bodies, edge mode and endpoints), reports database-only edges and members, returns results in a stable order, and no longer reads engine defaults (.*array children, HNSW and DISKANN tuning, theasciianalyzer) as drift.surql schema validatereads the complete live schema. Breaking:db_edges: Nonenow skips edges, and edge maps takeEdgeDefinition.- The engine's operator and spacing rewrites are not drift:
normalize_expression(and so the diff and the validator) reads&&/||asAND/OR,INasINSIDE,NOT INasNOTINSIDE, operator keywords in any case, and[1,2]/{a:1}as the engine's[1, 2]/{ a: 1 }. Event conditions, assertions and permissions using them used to warn on every validation and re-apply on every diff. - A COUNT index used to read back as a standard index with no columns, and a union field type as
any; both now read back as themselves. - A failed transaction reports the error that says why. The client reported the first "not executed" statement of a failed transaction, and the engine marks the statements before the failure that way too, with a fixed sentence. A failure that is itself "not executed" with a cause (SurrealDB 3.3 refuses a
DEFINE INDEXwhile the table's document ids are reclaimed this way) was reported as "The query was not executed due to a failed transaction". The error carrying a cause now wins over the fixed sentences. - The Redis cache reconnects in the background. A dropped connection was discarded on the error that revealed it and reopened by the next call.
RedisCachenow holds a redisConnectionManager(theconnection-managerfeature ofredis), which replaces a dropped connection itself; an unreachable server still fails after two connection attempts rather than the manager's default backoff. - Deeply nested values reach the engine. Values render into the statement as literals, and the engine's parser refuses a literal nested 20 levels deep ("Exceeded query recursion depth limit"), so the builder,
insert_many,relate_many,upsert_many_in_txand operator values failed on such data. A value nested more thanMAX_INLINE_DEPTH(16) levels now renders as its JSON text decoded by the engine (encoding::json::decode('…'), SurrealDB 3.1+), which the parser reads as one flat string; the value arrives the same. - Theme settings take effect; the registry refuses a table and an edge sharing one name.
- Connection, settings and cache.
- Unbounded or non-finite timeouts no longer panic on connect. Breaking: timeouts above 86400 s, retry waits above 3600 s and multipliers above 100 fail validation.
- Glob invalidation no longer falls back to match-everything for a non-ASCII pattern; Redis reconnects after a dropped connection;
MemoryCacheis a real LRU that evicts expired entries first; the stats report size and evictions; cache keys can no longer collide. Breaking: keys are prefixed once andcache_key_foroutput changed, so existing cache entries become misses. StreamingManagerprunes ended subscriptions. Breaking- Orchestration and the CLI.
- A failed migration fails its environment and triggers auto-rollback (it was counted as applied); deploys apply only each environment's pending migrations and roll back only what they applied, reported as
RolledBack; repeated environment names deploy once. surql migrate up/downandorchestrate deployexit non-zero on failure; the documented minimal environments file loads; a non-UTF-8 argument is a usage error instead of a panic; concurrent strategies wait for every deployment instead of detaching them.surql --config <file>reads the named file (any TOML carrying[package.metadata.surql]); it used to be ignored.
Changed¶
- MSRV is Rust 1.95.
rust-versionsaid 1.90, which never built the locked graph; surrealdb 3.3 usesstd::hint::cold_path(stable since 1.95) and declares norust-version. CI now checks it. - SurrealDB 3.3. The
surrealdbrequirement rises from 3.1.5 to 3.3.0 (the lockfile and the MSRV already assumed it, andclient-grpcneeds an SDK feature older releases lack). CI's engine tests run against a 3.3.0 server instead of 3.0.5, and the gRPC test with them. Engine tests that need 3.3 syntax skip on older servers; everything else also passes against 3.2.4. Breaking for a consumer pinned to an older surrealdb. - Destructive CLI commands (
db reset,bucket rm,bucket delete,orchestrate deploy) prompt, and require--yeswhen stdin is not a terminal;orchestrate deploygains--approve,--yesand--no-auto-rollback. Breaking - Breaking:
deploy_to_environments(&DeploymentPlan)replaces the ten-argument form;DeploymentResultgainsapplied_versionsandrolled_back_versions,DeploymentPlangainsapproved; the environments file rejects unknown keys and duplicate names;OrchestrateCommand::DeploytakesDeployArgs. - Breaking:
Query::similarity_scorereturnsResult;Operatorgains theExprvariant;GraphVizThemegainspalette; index column order is significant in validation;register_table/register_edgerefuse a cross-kind name clash. - Breaking:
MigrationandMigrationMetadatagainsquashed_from;RollbackPlangainsapproved;SnapshotComparisongains bucket fields;SchemaWatcher::startreturns a bounded receiver and needs a runtime;Transaction::begin,executeandrollbackreturn ready futures (awaiting them still compiles). - Shipped code may not
unwrap,expectorpanic!, enforced by lint; the only exception isRecordResult::unwrap, whose contract is to panic. Every source file is under 1000 lines. - Generators and validators are generic over
BuildHasher. - Hint documentation says what hints do: they are comments the engine ignores.
Added¶
types::escape:is_identifier,quote_ident,quote_str,quote_record_key,unescapeandunquote_str, following the engine's own printer.eq_expr,ne_expr,gt_expr,gte_expr,lt_expr,lte_expr;SettingsBuilder::config_file;try_register_table/try_register_edge;themes::color_scheme_by_name;cli::schema::fetch_live_schema;MemoryCache::with_stats;MAX_TIMEOUT_SECS,MAX_RETRY_WAIT_SECS,MAX_RETRY_MULTIPLIER; public diff comparators (fields_equal,indexes_equal,events_equal,table_permissions_equal,field_permissions_equal, …).migration::analyze_statements, the rollback analyser for any statement list;DiffOperation::ModifyIndexandModifyEvent.fuzz/, a cargo-fuzz crate whose targets check record ids, identifiers, string literals and values against the engine's parser, and that theINFOparsers return on arbitrary text.- CI jobs for the MSRV, the
client-wasmbuild and the no-features build; the pre-push hook runs every CI gate. - Union, literal and typed-container field types:
FieldDefinition::custom_type/with_custom_typerender a type theFieldTypekeywords cannot spell (array<string> | int,'draft' | 'published',array<string, 5>,set<int>,geometry<point>), and the parser keeps any type it cannot render back from the keywords there instead of reading it asany. The diff and the validator compare it through the newnormalize_type/type_eq;FieldDefinition::type_clausereturns the rendered type.FieldDefinitiongainscustom_type(Breaking for struct literals). - Access comparison:
accesses_equalandvalidate_accessescompare access definitions with the engine's echo, folding away redacted keys, normalised durations (duration_nanos:24his1d), the default token duration and the verifier the engine gives a record access declared without one. Accesses stay out of the migration diff (their keys never come back, so no rollback could restore them). - SurrealDB 3.3 access clauses:
JwtConfig::with_audience(AUDIENCE) andAccessDefinition::with_context(CONTEXT), plusAccessDefinition::with_authenticate(AUTHENTICATE). The parser reads all three;AUDIENCEused to run into the preceding key.JwtConfiggainsaudienceandAccessDefinitiongainsauthenticateandcontext(Breaking for struct literals). Query::for_update():SELECT ... FOR UPDATE(SurrealDB 3.3), which locks the selected records until the transaction ends. The engine takes only record id targets, andto_surqlrefuses a table target the same way.Querygainsfor_update.- The
client-grpcfeature andProtocol::Grpc/Protocol::GrpcSecure:grpc://andgrpcs://URLs connect over the SurrealDB 3.3 gRPC transport (the server answers on its main port), tested against a 3.3.0 server. Breaking for exhaustive matches onProtocol. - Relation flags and SurrealDB 3.3 graph caches:
EdgeDefinitiongainsenforced(ENFORCED, which the parser used to skip),lightweight(LIGHTWEIGHT, edges with no records), and, likeTableDefinition,inline_edges/inline_references(INLINE EDGES n/INLINE REFERENCES n);FieldDefinitiongainsinline(INLINE). Each has awith_*builder, renders in the engine's order, reads back from the echo, and is compared by the diff and the validator; validation refuses what the engine refuses (a lightweight relation with fields, anINLINEfield that is nested,COMPUTEDor on a non-relation table). The new members are Breaking for struct literals. Tokenizer::Segment(SegmentLanguage): the SurrealDB 3.3 CJK tokenizer (segment(chinese)/japanese/korean). An analyzer using it used to fail to parse and drop out ofparse_db_info. Breaking for exhaustive matches onTokenizer.- COUNT indexes:
IndexType::Count,count_index(name)andIndexDefinition::with_conditionrenderDEFINE INDEX … COUNT [WHERE …]; the parser reads the engine's echo (it used to read a standard index with no columns), and the diff and validator compare the condition.IndexDefinitiongainscondition(Breaking for struct literals and exhaustive matches onIndexType). modified_migrations,get_modified_migrations,rehash_migrations,update_migration_checksumandModifiedMigration;MigrationStatusReportgainsmodified(Breaking for struct literals);surql migrate rehash [<VERSION>...]accepts an edit to an applied migration by recording its current checksum.
Deprecated¶
mtree_indexandIndexType::Mtree. SurrealDB 3 has no MTREE index (every 3.x engine refuses the statement), soIndexDefinition::validatenow refuses one; usehnsw_indexordiskann_index. The variant stays so existing code and snapshots still load.IndexType::is_removedreports it.
Removed¶
reqwest, a dependency no source file ever used.Query::to_surql_or_panic_with_table(it wasdoc(hidden)). Breaking- The vendored SHA-256 implementation, in favour of the
sha2crate the crate already depended on.
[0.33.1] - 2026-08-27¶
Fixed¶
DatabaseClientre-establishes its session and retries once when the engine expires a long-lived authenticated session ("The session has expired"). Before this, a service holding a connection past the session duration failed every request until restarted. The replay only happens on clients whose authority is the config credentials;caller_sessionclones and credential-less connections surface the error unchanged, because replaying service credentials there would swap the session's identity.
[0.33.0] - 2026-08-12¶
Added¶
- DISKANN vector indexes and the F16 element type (SurrealDB 3.2).
IndexType::Diskannand thediskann_index(name, column, dimension, distance, vector_type)builder define the on-disk ANN graph the 3.2 engine parses, withwith_degree/with_l_build/with_alpha/with_hashed_vectorfor the tuning tail andDiskAnnDistanceTypefor the metric — its own enum (EUCLIDEAN/COSINE/INNER_PRODUCT/COSINE_NORMALIZED) because the engine's DISKANN set neither contains nor is contained by the HNSW one.MTreeVectorTypegainedF16,I8, andU8, which HNSW also accepts. The<|k,ef|>KNN operator reaches a DISKANN index through the sameKnnScanplan HNSW gets. This unblocks downstream F16/DiskANN adoption (copal roadmap item 6).
The engine echoes a DISKANN index back with DIST / TYPE / DEGREE / L_BUILD / ALPHA always spelled — defaults EUCLIDEAN / F32 / 64 / 100 / 1.2 filled in even when the definition never stated them, and a float ALPHA carrying a trailing f suffix (ALPHA 1.2f). The same lesson as the sequence BATCH/START echo applies: the renderer and builder spell the defaults explicitly, and the parser strips the f suffix, so a definition compares equal to its own echo instead of re-applying on every reconcile boot.
IndexDefinition::validate refuses what the probed engine refuses, by name: a DISKANN element type outside F32 / F16 / I8 / U8, an MTREE element type among the new F16 / I8 / U8 (MTREE still parses only its historical five), and an MTREE/HNSW metric aimed at a DISKANN index, which only diskann_distance can carry. The new IndexDefinition members (diskann_distance, degree, l_build, alpha, hashed_vector) all default off in serde, so stored snapshots and old contracts deserialise unchanged. The vector-index vocabulary moved to schema::index_vector to keep schema::index under the 1000-LOC budget; every existing path re-resolves through the old re-exports.
[0.32.0] - 2026-08-11¶
Security¶
surrealdbis required at 3.1.5 or later. The 3.0 line carries twenty-five published advisories (five high), all patched by 3.1.0 or 3.1.5, and the old"3.0"requirement let a consumer resolve a vulnerable engine and let this repository's own lock sit on one. The requirement now names the first fully patched version, so every downstream resolution is forced past the set; the lock moves to the current 3.2 line with it.
Fixed¶
-
The default branch compiles again. The ulid 3 and comfy-table 8 major bumps landed with APIs this crate no longer had:
Ulid::new()becameUlid::generate(), and comfy-table's string presets becameTableStylevalues loaded withload_style. Two call sites (the streaming subscription id and the CLI table renderer) moved to the new names; behaviour is unchanged. -
parse_table_full,parse_table_info, andparse_edge_inforefused the response shapequeryactually returns.queryanswers one result per statement, so anINFO FOR TABLEobject arrives wrapped in a one-element array that onlyparse_db_infohad learned to unwrap; every other caller had to remember to index[0]first. The table and edge parsers now accept either shape by the same argument — an INFO response is never itself an array, so the two cannot be confused — and callers that already index the wrapper keep working, since indexing yields the bare object. No other public parser takes the response value, so none can carry the same footgun. -
global_helpers_configure_and_invalidateflaked against its own binary. The cache integration tests run concurrently in one process, andis_cached_returns_false_when_no_managercallsclose_cache(), which empties the process-global manager slot; landing between another test'sconfigure_cacheand its reads of that slot, it madeinvalidateandclear_cachereport zero. The two tests that touch the global slot now serialize on a shared lock, the pattern the cache module's own unit tests already use. Test-only; no library change. -
A field that gained
REFERENCEsilently tracked nothing for its existing rows. The engine backfills nothing when the clause is added, and a self-assignment registers nothing either; only an actual value change does. Applying the DDL a diff renders therefore left<~blind to every row that predated the clause, and whatever consumed the reverse references undercounted with no error anywhere.schema::reference_backfill_sql(table, field)renders the rewrite that makes the tracking true (NONE-and-back per row, shaped by two probedFORquirks:SELECT VALUE idbecauseFORrefuses object rows,?? []because it refuses an empty selection). The field diff carries it indetailswith aSchemaDiff::reference_backfill_sql()accessor rather than insideforward_sql, because it is DML an application's own events may refuse and a live reconciler must choose where it runs; the migration generator, whose files a person reviews, writes it into the file right after the DDL, and registration inside the same transaction is probed behaviour.
Getting the rewrite through a migration file exposed a second bug: the statement splitter cut on every semicolon, which shattered any statement with a braced body — the backfill's FOR loop, and equally a DEFINE FUNCTION with more than one statement in it. The splitter now respects brace and parenthesis nesting and string literals, so a statement ends only at a top-level semicolon.
surql schema tables/export/validateandsurql bucket listread every database as empty. The CLI passed the rawclient.query("INFO FOR DB;")response toparse_db_info, butqueryanswers one result per statement, so the INFO object arrives wrapped in a one-element array the parser refused;unwrap_or_default()at all four call sites turned that refusal into an empty database report.parse_db_infonow accepts either shape (an INFO response is never itself an array, so the two cannot be confused), and the call sites surface parse errors instead of defaulting them, so an echo the parser cannot read is now a message rather than a silent nothing.
Added¶
- Record references (
DEFINE FIELD ... REFERENCE).FieldDefinitiongainedreference: Option<ReferenceAction>(IGNORE/REJECT/CASCADE/UNSET) andcomputed: Option<String>, withFieldBuilder::reference/::computedand thereverse_reference_field(name, source)constructor for the reverse half (COMPUTED <~source).Query::reverse_traverseandquery::references::reverse_reference_queryrender the<~table/<~table.{ a, b }projection that reads incoming links back.
The REFERENCE clause always spells out its ON DELETE action, because a bare REFERENCE is what INFO FOR TABLE echoes as ON DELETE IGNORE; emitting the short form would diff against the database forever. The parser reads both forms, and the assertion / default / value extractors now stop at REFERENCE and COMPUTED, which the engine emits after ASSERT.
FieldDefinition::validate rejects what v3.0.5 rejects: REFERENCE on a nested field (metadata.comics) or on anything that is not a record<table> / array<record<table>> link, and COMPUTED beside READONLY, VALUE, or DEFAULT. Union types (array<record<x>> | string), which the engine also rejects, are not expressible here.
- Background index builds (
DEFINE INDEX ... CONCURRENTLY).IndexDefinition::with_concurrentlyappends the directive, which lets a large index populate without blocking the statement.info_for_index_surql(name, table)renders the progress query andIndexBuildStatus::from_inforeads the{ building: { status, initial, pending, updated } }answer, including through the arrayDatabaseClient::querywraps results in.
v3.0.5 accepts CONCURRENTLY and then echoes the index back without it, so the parser deliberately reports concurrently: false and no comparison looks at the member. Storing it as parsed would make every background-built index diff forever.
- Table change feeds (
DEFINE TABLE ... CHANGEFEED).ChangeFeedandTableDefinition::with_changefeedrenderCHANGEFEED <duration> [INCLUDE ORIGINAL]between the mode and thePERMISSIONSclause,parse_changefeedreads it back out of theINFO FOR DBecho, anddiff_tablesreports a change as the newDiffOperation::ModifyTablecarrying the fullDEFINE TABLE OVERWRITEform (a bareCHANGEFEEDstatement would reset the table's mode and permissions).
The read side is query::changes: show_changes_surql(table, since, limit) renders SHOW CHANGES FOR TABLE <t> SINCE <versionstamp|d'...'> [LIMIT n], and ChangeSet::from_response pulls the versionstamp / changes pairs out of the answer so a consumer can resume where it stopped.
- Pre-computed view tables (
DEFINE TABLE ... TYPE NORMAL AS SELECT).ViewDefinition/ViewGroupandTableDefinition::with_viewrenderTYPE NORMAL <mode> AS SELECT <projections> FROM <tables> [WHERE ...] [GROUP BY ...|GROUP ALL],parser::parse_viewreads it back, and a changed body reports asDiffOperation::ModifyTable.
The parser splits on top-level commas and keywords only, so math::max([a, b]) stays one projection and a table literally named comment is not mistaken for a COMMENT clause. Views compare on the whitespace-normalised clause, because the engine reformats what it stores.
TableDefinition::validate rejects a view that declares fields: the engine computes a view's contents and stores no field definitions for one, so a reconciler would drop the declared fields on every boot.
- ID sequences (
DEFINE SEQUENCE).SequenceDefinition/sequence_schemarenderBATCH/START/TIMEOUTand theREMOVE SEQUENCE IF EXISTSandsequence::nextval("<name>")statements;parse_sequencereads thesequencesmap ofINFO FOR DBback;DatabaseInfo::sequencesandSchemaSnapshot::sequencescarry them; anddiff_objects::diff_sequencesreportsAdd/Modify/DropSequence.
BATCH and START are always rendered, including at their defaults (1000 and 0), because the engine echoes them either way.
- Custom functions (
DEFINE FUNCTION fn::<name>).FunctionDefinition/function_schemarender the signature, return type, body,COMMENT, andPERMISSIONS;parse_functionreads thefunctionsmap ofINFO FOR DBback (splitting arguments on top-level commas soarray<record<x>>survives, and taking the body from the outermost brace pair so a nested block does not truncate it); anddiff_objects::diff_functionsreportsAdd/Modify/DropFunction.
The engine rewrites what it stores — option<T> becomes none | T, the body loses its trailing ;, and an omitted PERMISSIONS comes back as PERMISSIONS FULL. FunctionDefinition::normalized applies the same rewrites and the diff compares canonical forms, so a function does not report as modified on every reconcile.
- Database params (
DEFINE PARAM $<name>).ParamDefinition/param_schemarender the value,COMMENT, andPERMISSIONS;parse_paramreads theparamsmap ofINFO FOR DBback; anddiff_objects::diff_paramsreportsAdd/Modify/DropParam. As with functions,normalizedfills in thePERMISSIONS FULLthe engine echoes.
The parser locates clause keywords outside quoted runs, so a value like 'leave a comment about permissions' is not cut at its own words.
-
migration::diff_objects. Every database-level object diff now lives here:diff_bucketsanddiff_analyzersmoved across (re-exported frommigration::diff, so no path changes), anddiff_namedcaptures the add / drop / modify shape they share, so a new kind is three lines rather than another copy of the walk. Net effect:migration::diffis smaller than it was before this release despite gaining change-feed and view diffing.SchemaDiffgained oneobject: Option<String>field naming the object such a diff targets. -
array<record<table>>field types. An ARRAY field with atarget_tablenow rendersarray<record<{target}>>instead of a barearray, which is the shape a to-many reference needs. Atarget_tableon an ARRAY field was previously carried but never rendered, so this changes emitted DDL for any definition that set both.
Changed¶
-
BREAKING:
SchemaSnapshotandSchemaDiffgained fields.SchemaSnapshot::sequencesandSchemaDiff::objectboth default on deserialize, so stored snapshots still load, but a struct literal that names every field stops compiling. Use the constructors (SchemaSnapshot::new/from_parts/from_all_parts) or..Default::default(), as the shipped examples now do. -
Module splits to stay inside the 1000-LOC budget.
FieldTypemoved toschema::field_typeandIndexDefinition(withIndexType, the distance and vector-type enums, and theindex/unique_index/search_index/bm25_index/mtree_index/hnsw_indexbuilders) moved toschema::index. Both are re-exported from their previous homes (schema::fields,schema::table), so no consumer path changes.
[0.31.0] - 2026-08-07¶
Added¶
-
The diff engine serves live-database reconciliation. Analyzers join
DatabaseInfo,SchemaSnapshot, anddiff_schemaswith their own parser, so the full schema a consumer declares can be compared against what a database actually holds.parse_table_fullnames the two-level composition (INFO FOR DBfor mode and permissions,INFO FOR TABLEfor fields, indexes, and events), because the database level alone yields fieldless tables, which is a trap for anyone diffing against it. -
Nullable fields (
TYPE option<...>).FieldBuilder::nullable(bool)/FieldDefinition::with_nullable(bool)wrap the rendered type inoption<...>(including record targets:option<record<blob>>) so a SCHEMAFULL column can acceptNONE. TheINFO FOR TABLEparser round-trips the wrapper, keeping migration diffing stable for nullable columns. Restores parity with surql-py (nullable=True, 1.5.8+) and the TS port; rendering for non-nullable fields is byte-identical to before. -
Row-level filtering on the graph helpers (
conditions).query::graphgained aconditionsargument ontraverse,traverse_raw,traverse_with_depth,get_outgoing_edges,get_incoming_edges,get_related_records, andshortest_path. Each entry renders throughQuery::where_and multiple entries combine withAND, so a traversal can carry a tenant guard or any other row-level predicate. Restores parity with the sibling ports, which have acceptedconditionson their graph helpers since surql-py 1.6.0.
Previously these helpers emitted a bare SELECT * FROM record->edge with no filtering hook at all. A caller needing row-level isolation, a mandatory WHERE tenant_id = ... alongside engine-enforced PERMISSIONS for instance, could not express it, and had to abandon the helpers for a hand-rolled equality-filtered edge table.
query::Condition. An ownedRaw(String) | Op(Operator)carrier withFromimpls for&str,String,&String,Operator, and&Operator, plusWhereConditionfor bothConditionand&Condition.WhereConditiontakesselfby value and so cannot be used behind a trait object;Conditionis what lets one slice mix raw fragments and operators, matching thestr | Operatorunion the sibling ports accept.
Changed¶
-
DatabaseClientclones now share ONE engine session. The SDK mints a session perSurrealclone and announces it with lifecycle events the remote router can lose under concurrency, which surfaced as intermittentSession not foundfailures in any service that clones its client per request (an axum state extraction does exactly that). The inner handle now rides anArc, so clones share the service session and session churn stops entirely. Code that wants an independent session asks for one:caller_sessionfor a caller-bound session,client.inner().clone()for a raw one. Auth calls (signin,authenticate,invalidate) now act on the shared session, which is what a service almost always means; the previous per-clone isolation was an accident of the SDK'sClone. -
BREAKING: graph helper signatures. The seven helpers above take a new trailing
conditions: Option<&[Condition]>parameter. Rust has no default arguments, so this follows the existing convention in this module of rendering a Python default argument as a requiredOption<T>parameter (ascreate_relation'sdata: Option<Value>already does). Existing call sites migrate by passingNone, which leaves the emitted SurrealQL unchanged.count_relatedis deliberately not included, because surql-py does not filter it either, and diverging would break the 1:1 contract. -
Graph helpers compose through
Queryinstead offormat!. EverySELECT-shaped helper now builds its statement withQuery::new().select(…).from_table(…).traverse(…)rather than interpolating identifiers into a string. Statement construction moved into pure sync functions (select_traversal_surql,count_related_surql,shortest_path_surql,depth_path) that are unit-testable without a live client.
create_relation and remove_relation are intentionally left hand-composed: Query::relate inlines its payload via render_data_object, whereas create_relation binds CONTENT $data as a variable, and routing it through the builder would inline caller payloads into the statement.
shortest_pathemits parenthesised predicates. Now that the identity check goes through the builder, the rendered clause isWHERE (id = <to>) [AND (…)]rather thanWHERE id = <to>. Semantically identical; noted because it changes the exact statement text.
Fixed¶
-
The guides document what this release adds. Nullable fields and
OVERWRITErendering are in the schema guide, reading a live database back throughparse_db_infoandparse_table_fullis in the migrations guide. Two examples that predate this release are corrected while passing:SchemaSnapshot::from_registrydoes not exist and never did, and aSchemaSnapshotstruct literal stops compiling every time the type gains a kind of definition, so both now use the constructors. Every example in the changed pages was compiled against the crate rather than read over. -
The dependency audit carries its two unfixable advisories in one place.
.cargo/audit.tomlnames each, what would have to change upstream for it to come out, and why it is safe to carry meanwhile. Both workflows now read that file instead of passing a flag with the reasoning written somewhere else. The file governs this repository's own audit and is not published with the crate: a consumer runningcargo auditsees both advisories and makes their own call. -
Diff results are now safe to apply to a live database. Grouped permission actions (
FOR select, create ...) compare equal to the engine's split echo instead of reporting a permanent false modification. Modify-class diffs renderOVERWRITEforms: a modified field re-defines withOVERWRITE, and a permissions change carries the owning table's FULL definition, because a permissions-onlyDEFINE TABLEwould silently reset the table's mode. -
OVERWRITErendering across the schema layer. Tables, fields, indexes, events, analyzers, and access methods gainto_surql_overwrite, andgenerate_table_sql_overwriterenders a table's full statement set with it.IF NOT EXISTScreates and then never updates, so a consumer whose definitions evolve needs the replacing form to bring an existing database up to the code's schema; data is untouched, only definitions are replaced. -
DatabaseClient::caller_sessionopens per-caller engine sessions. A cloned SDK handle is its own session, so one connection can hold a root session and record-authenticated caller sessions side by side, with the engine applyingPERMISSIONSto each session's actor. The method authenticates a record access token on a fresh clone, verifies the engine bound a record identity (refusing database-level tokens thatPERMISSIONSwould not filter), and returns a client whose drop ends the session. -
Connection credentials reach embedded engines at build time.
username/passwordnow construct embedded datastores with the root user in place, so anonymous sessions stop acting as owner and the engine is lockable from configuration alone. Remote engines keep the existing signin path. -
Query::set/set_expraccept dotted paths.SET metadata.processing = {...}is native SurrealDB nested assignment, and the schema layer already accepts dot notation for field definitions, but the update builder rejected any dotted target as an invalid identifier. Targets now validate per segment. -
FLEXIBLEnow renders immediately after theTYPEclause. The previous trailing position (... READONLY FLEXIBLE;) is a parse error on SurrealDB v3 ("FLEXIBLE must be specified after TYPE"), so any schema combining a flexible object field withREADONLY,DEFAULT,VALUE, orASSERTfailed to apply. Verified against v3.0.5: the after-TYPE position is accepted in every combination, includingoption<object> FLEXIBLE.
[0.30.0] - 2026-07-29¶
Added¶
- Files & buckets (SurrealDB v3 object storage), full code-first depth.
- Field types:
FieldType::File/FieldType::Bytes(emitTYPE file/TYPE bytes) plusfile_field(name)/bytes_field(name)builders; theINFO FOR ...field parser round-trips both. - Schema: new
schema::bucketmodule —BucketDefinitionwithbucket_schema(name, backend)/memory_bucket(name)/file_bucket(name, path)builders, renderingDEFINE BUCKET [IF NOT EXISTS|OVERWRITE] … BACKEND "…" [READONLY] [PERMISSIONS …] [COMMENT "…"],REMOVE BUCKET …, andALTER BUCKET [IF EXISTS] …(READONLY/DROP READONLY,BACKEND/DROP BACKEND,PERMISSIONS,COMMENT/DROP COMMENT). Exposed viagenerate_bucket_sql[_with_options]. - Migrations:
DiffOperation::{AddBucket, DropBucket, ModifyBucket}, abucketfield onSchemaDiff,diff_buckets(code, db), and bucket support threaded throughSchemaSnapshot,VersionedSnapshot, theSchemaRegistry(register_bucket/get_registered_buckets), the initial-migration generator, drift detection, andschema generate. - Parser:
parse_bucketreconstructsBucketDefinitionfromINFO FOR DB(bu/buckets) intoDatabaseInfo::buckets. FileRefvalue type (types::file):{ bucket, key }exposing SurrealDB's canonical key form — the key is stored verbatim (including the server's leading slash, e.g./a.txt), whileDisplayalways renders a single-slash pointer (bucket:/a.txt) for any input. serde round-trips the structured form ({ bucket, key: "/a.txt" }) and accepts thef"bucket:/key"literal the SDK emits. The Rust SDK decodesfilevalues (inhead/file::list/record fields) straight to that literal, so — unlike the Python port — nofile::bucket/file::keyprojection is needed.- Runtime API:
DatabaseClient::bucket(name)returns aBuckethandle withput/put_if_not_exists/get/get_text/exists/head/delete/copy/copy_if_not_exists/rename/rename_if_not_exists/list. Every op uses the parameterisedtype::file($bucket, $key)constructor with bound params (never string-interpolated). Binary payloads bind as a nativesurrealdb::types::Value::Bytesvia the newDatabaseClient::query_with_surreal_vars(no base64) — the JSON bind path cannot carry raw bytes. Data is accepted as aFileData::Text|Bytesenum. - CLI: new
surql bucketgroup —define/list/rmplus file opsput/get/delete/exists/files. -
Buckets require the server's
SURREAL_CAPS_ALLOW_EXPERIMENTAL=filesenvironment variable (the feature is hidden and not enabled by--allow-all; the--allow-experimental filesflag form is broken). Live round-trip coverage is intests/integration_files.rs(embedded probe + an#[ignore]d server test gated onSURREAL_FILES_URL), verified against SurrealDB 3.1.3. -
Sessions documented as unsupported. The Rust
surrealdbcrate has no multiplexed-session API, sosurql-rsdeliberately ships none (unlike the Python / TypeScript ports). The newconnection::sessionmodule documents this and advises a separateDatabaseClientper isolated namespace/auth context.
[0.29.0] - 2026-06-17¶
Added¶
- Full-text search (BM25) is now first-class — the sparse leg of hybrid retrieval. Define a
DEFINE ANALYZERin code withanalyzer(name)/standard_analyzer(name)(AnalyzerDefinition+Tokenizer+TokenFilter, rendered viagenerate_analyzer_sql/generate_analyzer_sql_with_options); build a BM25-scored full-text index withbm25_index(name, columns, analyzer)(orsearch_index(...).with_analyzer(...).with_bm25().with_highlights()); and run the lexical query withQuery::fulltext_search(field, reference, query)+Query::search_score(reference, alias), or thefulltext_search_query(...)helper. Pair it withvector_searchand fuse the two result orders by rank (Reciprocal Rank Fusion). Verified end-to-end against an embedded SurrealDB engine intests/integration_fulltext.rs.
Fixed¶
- Full-text index now emits the SurrealDB 3.x
FULLTEXTkeyword. The full- text index keyword was renamed fromSEARCHtoFULLTEXTin SurrealDB 3.0, so the previous output (... SEARCH ANALYZER ascii) was a parse error on v3.IndexType::Search/search_index/IndexDefinition::to_surql*and the migration diff now emitFULLTEXT, and theINFO FOR TABLEindex parser recognises both spellings. Seedocs/v3-patterns.md§9 — including the note that the v3 streaming executor's full-text scan returns rows in BM25 relevance order butsearch::scoreis not plumbed through it (returns 0), so rank by the scan's natural order.
[0.28.1] - 2026-06-12¶
Fixed¶
- Connect retries no longer mask the real failure behind "Already connected". The SDK engine connects once per handle and rejects a second
connect; after a partially-successful attempt (engine up, then credential signin or namespace selection failed), every retry died on that rejection, so the surfaced error wasAlready connectedinstead of the actual failure (e.g.There was a problem with authentication), and retries 2..n never re-attempted the failing step at all.DatabaseClientnow tracks engine-level connection state: a retry — or aconnecton an already-connected client (reconnect), which failed the same way — skips the engine connect and resumes at the step that failed.
[0.28.0] - 2026-06-06¶
Fixed¶
- Table-level
PERMISSIONSnow render correctly.TableDefinitionemitted a malformedDEFINE FIELD PERMISSIONS FOR {action} ON TABLE ...per action, which SurrealDB rejects (Unexpected token FOR). Table permissions now render inline on theDEFINE TABLEstatement (... PERMISSIONS FOR select WHERE ... FOR create WHERE ...), the only valid placement. Affectsto_surql_with_options/to_surql_all_with_optionsandgenerate_table_sql. - Edge table
PERMISSIONSwere silently dropped.EdgeDefinitionignored itspermissionsentirely; they now render inline on theDEFINE TABLE ... TYPE RELATIONstatement. - Migration diff renders a permissions change as valid SurrealQL. A
ModifyPermissionsdiff emitted the same malformedDEFINE FIELD PERMISSIONSform; it now emits a singleDEFINE TABLE <t> PERMISSIONS ...statement. (ASCHEMAFULLtable re-defined this way falls back toSCHEMALESS; full-mode fidelity is a follow-up once the diff carries the table mode.)
Added¶
- Expression-valued
UPDATE ... SETfor atomic read-modify-writes.Query::update_setbegins anUPDATE <target> SET ...whose assignments are supplied viaset(literal) orset_expr(expression-valued), combinable withwhere_andRETURN.Expressionnow implements the standard arithmetic operators (+ - * /over anythingInto<Expression>, withFrom<i64|i32|f64>for numeric literals), so aSETvalue can reference the row's current fields — e.g.UPDATE t SET n = n + 1 WHERE ...collapses a read-modify-write into one statement. is_none/is_not_noneoperators (field IS NONE) — the correct guard for an absent optional field, which SurrealDB reports asNONE, notNULL.AccessDefinition::to_surql_with_options(if_not_exists)andgenerate_access_sql_with_options(access, if_not_exists)to emitDEFINE ACCESS IF NOT EXISTS ...for idempotent re-application (e.g. a persistent store applying its schema on every connect).
[0.2.7] - 2026-05-30¶
Added¶
- Typed
record<table>field emission.FieldDefinitiongains atarget_tablefield, andrecord_field(name, Some("user")), the newtarget_table(...)builder setter, andwith_target_table(...)all renderTYPE record<user>. A canonicaltype::record("X", $value)coercion on a RECORD field is auto-lifted intotarget_tableat build time, dropping the now redundant VALUE clause. TheDEFINE FIELDparser readsrecord<table>back intotarget_tableso typed records round-trip.
[0.2.6] - 2026-05-22¶
Maintenance release focused on closing open security, dependency, and CI-hygiene work. No public-API breaking changes.
Security¶
- Bumped
opensslfrom0.10.79to0.10.80viacargo update, closing CVE-2026-45784 (medium severity, potential out-of-bounds write inCipherCtxRef::cipher_update_inplacefor AES-KW-PAD ciphers). The crate's defaultclient-rustlsbackend never linksopenssl; the bump only affects consumers that opt into theclient/client-tlsfeature.
Fixed¶
- Daily Security Audit workflow no longer fails on every scheduled run. Replaced the deprecated Node.js 20
rustsec/audit-check@v2.0.0action with a directtaiki-e/install-action+cargo auditinvocation. The new step exits non-zero only on actual vulnerabilities; informational unmaintained warnings (atomic-polyfill, bincode 2.x) are surfaced as logs because they reach the dep graph transitively throughsurrealdband are not actionable from this repo.
Changed¶
- CI workflow (
ci.yml) now runs thestableRust toolchain only on push and pull-request triggers.betatoolchain coverage moved to the daily Nightly workflow so regressions still surface within 24 hours without paying for two parallel jobs on every PR rev. - Added
paths-ignorefilters toci.ymlandcoverage.ymlso pure documentation, LICENSE, or.editorconfig/.gitignorechanges no longer trigger a full compile + clippy + test run.docs.ymlalready handles documentation rebuilds. - Dependabot auto-merge workflow now uses
dependabot/fetch-metadata@v3andlewagon/wait-on-check-action@v1.7.0, the latest stable majors of both actions. docs/features.mdanddocs/migration.mdcorrected: the default feature has beenclient-rustlssince0.2.3, notclient.
Added¶
docs/connection-management.mddocuments the task-scoped current client,ConnectionRegistry,AuthManager,StreamingManager/LiveQuery, andTransaction.docs/caching.mddocumentsCacheManager, theMemoryCacheandRedisCachebackends, thecached/cached_with/cache_key_forhelpers, and the invalidation surface.docs/orchestration.mddocumentsEnvironmentConfig/EnvironmentRegistry,DeploymentPlan,DeploymentCoordinator, the four built-inDeploymentStrategyimplementations (Sequential, Parallel, Rolling, Canary),DeploymentResult, andcheck_environment_health/verify_connectivity.docs/migration.mdnow carries anUpgrading 0.2.5 -> 0.2.6section.mkdocs.ymlnavigation surfaces the three new module pages under Guides.
[0.2.5] - 2026-05-19¶
Brings the parser, RecordID, and batch surfaces to feature parity with the surql-py 1.6.4 / 1.7.0 release window (and the sibling surql v1.5.0 TypeScript port). Also hardens the CI workflow set so PRs do not double- run and the docs build no longer serialises every ref behind a single queue.
Added¶
-
parse_edge_info(edge_name, info, define_table)insurql::schema::parser— counterpart to [parse_table_info] for graph-edge tables defined viaedge_schema/ [EdgeDefinition]. Edge mode is detected from theDEFINE TABLEstatement:TYPE RELATIONresolves toEdgeMode::Relation,SCHEMAFULLtoEdgeMode::Schemafull, anything else toEdgeMode::Schemaless.FROM <table>andTO <table>are extracted independently so a malformed live definition that lost one clause surfaces as missing-endpoint drift instead of a parse failure. OnRelation-mode edges the auto-emittedinandoutfield declarations SurrealDB stores are stripped on parse — they are implicit whenTYPE RELATIONis set, so the code-sideEdgeDefinitiondoes not declare them and round-trip diffs were flagging them as orphan additions. Per-actionPERMISSIONSround-trip via the newparse_table_permissionshelper. -
parse_table_permissions(definition)insurql::schema::parser— extracts the per-actionPERMISSIONSrules from aDEFINE TABLEstatement string. ReturnsNonefor the trivialNONE/FULLpostures (the code-side helpers have no representation for those) and for definitions without aPERMISSIONSclause. Recognises the expanded form (FOR select WHERE r1 FOR create WHERE r2 …), the comma-joined form v3 emits when several actions share a rule (FOR select, create, update, delete WHERE r), and arbitrary mixes of both. The Rustregexcrate does not support lookahead, so the body is split onFORboundaries before applying the per-clause matcher — same per-action map shape the surql-py port produces, no lookahead. -
parse_table_info(name, info, define_table)— the optional third argument is theDEFINE TABLE <name> ...statement string, fetched fromINFO FOR DB'stables.<name>entry. SurrealDB v3'sINFO FOR TABLEdoes not include the table-levelDEFINE TABLEstatement, so table mode andPERMISSIONScannot be recovered from it alone. Withoutdefine_tablethe parser falls back to the legacytbkey inside the response (the v1 / v2 shape) and table mode defaults toSchemalesson v3. -
strip_brackets(value)insurql::types, re-exported from the crate root. SurrealDB v3 wraps record-id keys that contain anything other than[A-Za-z_][A-Za-z0-9_]*or pure digits in unicode angle brackets⟨ … ⟩(U+27E8 / U+27E9). Downstream consumers that wanted the baretable:idshape were callingvalue.replace('⟨', "").replace('⟩', "")themselves at every API boundary;strip_bracketscentralises that strip and also accepts the legacy ASCII< … >form.Noneis passed through untouched so the helper is safe to apply unconditionally. -
upsert_many_in_tx(txn, table, items, conflict_fields)— atomic counterpart to [upsert_many]. Queues oneUPSERT <target> CONTENT { … }statement per item on the supplied [Transaction] buffer; the per-record statements inherit the surroundingBEGIN TRANSACTION/COMMIT TRANSACTIONframing so a single bad record rolls back the entire batch on commit instead of leaving the database half-seeded.Transaction::executequeues raw SQL without param bindings, so the CONTENT payload is rendered as a SurrealQL object literal (rather than$data-bound as it is in autocommit mode). Bothupsert_manyandupsert_many_in_txaccept an optionalconflict_fieldsslice that emits an inline-valueWHERE … AND …clause appended to each UPSERT.
Fixed¶
-
build_upsert_queryemittedUPSERT INTO <table> [ {…}, {…} ], which SurrealDB v3 rejects with a parse error — v3 wants a single record-id or table target afterUPSERT, not an array literal. The renderer now emits oneUPSERT <target> CONTENT { … }statement per item, joined by;, matching the surql-py 1.7.0 / surql 1.5.0 shape that is portable across the sibling ports. The pre-0.2.5 source comment acknowledged the bug ("not valid SurrealDB v3 SurrealQL") but kept the broken shape for byte-for-byte parity with the older surql-py renderer; that parity bridge is no longer needed. -
build_upsert_queryconflict_fieldsemittedWHERE field = $item.field, which has no$itembinding in scope at the call site (and the rendered string is also fed verbatim toTransaction::execute, which queues raw SQL without binding params). The renderer now inlines the conflict values (WHERE email = 'a@b.com' AND tenant = 'BFS'), matching the surql 1.5.0 fix. -
RecordID::Displayemitted ASCII<id>brackets for ids that could not be rendered bare. SurrealDB v3 rejects ASCII</>in record-id positions withUnexpected token '<', expected a record-id key; the output now uses the v3-correct unicode escape syntax⟨id⟩(U+27E8 / U+27E9).RecordID::parseaccepts both forms on input so legacy wire payloads still round-trip cleanly. Breaking for callers that asserted on the exactDisplayoutput; the SQL shape is identical otherwise. -
RecordID::needs_angle_bracketsaccepted leading-digit ids bare (chunk:1abc). The pre-0.2.5simple_id_patternwas[A-Za-z0-9_]+, which let1abcslip through and produced a literal v3 rejects withUnexpected token. The newidentifier_id_patternis[A-Za-z_][A-Za-z0-9_]*, with a separate allow-list for pure- digit strings (which v3 parses as integer-key ids and round-trips bare). Matches surql-py 1.7.0.
Changed¶
-
upsert_manyno longer routes throughUPSERT <table> CONTENT $datafor items that lack anidfield. The autocommit path always pins the target —data.idwhen present,<table>otherwise — and stripsidfrom the bound payload so v3 does not reject the duplicate field. -
CI workflow set hardened against runaway runs:
docs.ymlswitched from the globalgroup: pagesconcurrency queue (which serialised every build + deploy across all refs and caused multi-day stalls when a long-running deploy held the queue) to a per-ref group withcancel-in-progress: true.ci.ymlandcoverage.ymlgained per-ref concurrency groups so rapid pushes to a PR cancel the in-progress run. The redundantpush: branches: ['release/**']triggers were dropped — release branches only ever receive PRs that already fired the workflow viapull_request, so the push trigger was pure duplicated work.audit.yml,dep-review.yml, andpr-title.ymlgained per-ref concurrency groups so a sequence of PR edits cancels the in-progress lint and only the latest revision is checked.
Verified¶
cargo fmt --all -- --check— clean.cargo clippy --lib --all-features --tests -- -D warnings— clean.cargo test --lib --no-default-features— 927 passed, 0 failed.cargo test --lib --all-features— 1088 passed, 0 failed (baseline was 1066 on 0.2.4; +22 regression tests coveringparse_edge_info,parse_table_permissions,strip_brackets, unicode-bracketRecordID::Display, and the per-recordbuild_upsert_queryshape).- All integration tests compile.
[0.2.4] - 2026-05-02¶
Added¶
client-wasmfeature (Oneiriq/surql-rs#115). Wasm-friendly client surface that compiles cleanly towasm32-unknown-unknown. Pullssurrealdbwithprotocol-ws+kv-memonly -- norustls/native-tls/reqwest, since browsers terminate TLS at the WebSocket layer andkv-memlets wasm callers run an embedded engine for local state and tests. Exposes the sameDatabaseClient/executor/crud/graph/batchAPI asclient-rustls.[target.'cfg(target_arch = "wasm32")'.dependencies]block inCargo.tomlthat overridestokioto the wasm-buildable subset (sync,macros,rt,time) and pullsgetrandom 0.3with thewasm_jsfeature soulid/rand_corelink on wasm..cargo/config.tomlwith the--cfg=getrandom_backend="wasm_js"rustflag required bygetrandom 0.3onwasm32-unknown-unknown(the feature flag alone is insufficient -- see https://docs.rs/getrandom/0.3/#webassembly-support).scripts/check-wasm.sh-- canonical local + CI gate for the wasm build. On macOS auto-detects Homebrew LLVM socc-rscan handring 0.17's build script a wasm-capable clang (Apple's/usr/bin/clanghas no wasm32 backend).
Changed¶
- The optional
tokiodependency moved from a top-level[dependencies]declaration withfeatures = ["full"]to two target-specific declarations: native targets keep the historical["full"]feature set, whilewasm32-*targets get["sync", "macros", "rt", "time"]. No source-level API changes.
Fixed¶
cargo build --target wasm32-unknown-unknown -p oneiriq-surql --no-default-features --features client-wasmnow succeeds on a system with a wasm-capable clang in scope. Unblocks Oneiriq/pixel-stroke#236 (web-build ofpixel-stroke-persistence).
[0.2.3] - 2026-05-02¶
Changed¶
- The default feature set is now
["client-rustls"](pure-Rust TLS). Previously the default was["client"], which pulledsurrealdb/native-tlsandreqwest/default-tlsand thereforeopenssl-sysinto the dependency graph. The historical native-tls backend is still available via theclientfeature (now also exposed under theclient-tlsalias) for consumers that need the system OpenSSL stack. - The
cliandorchestrationfeatures now depend onclient-rustlsinstead ofclientso thatcargo install oneiriq-surql --features cliand other typical builds no longer compile againstopenssl-sys.
Security¶
- Drops the
openssl-systransitive dependency from the default dependency graph, clearing the following Dependabot advisories on this crate's published default build: - rust-openssl: incorrect bounds assertion in AES key wrap (HIGH)
- rust-openssl: unchecked callback length in PSK / cookie trampolines leaks adjacent memory to peer (HIGH)
- rust-openssl:
MdCtxRef::digest_final()writes past caller buffer with no length check (HIGH) - Consumers who explicitly opt into
--features client(or theclient-tlsalias) still link the system OpenSSL stack and remain subject to upstreamrust-openssladvisories.
[0.2.2] - 2026-04-21¶
Added¶
client-rustlsfeature (Oneiriq/surql-rs#97). Same surface as the defaultclientfeature, but with a pure-Rust TLS stack (rustls+webpki-roots) instead ofnative-tls. Enables building on runners that do not havelibssl-dev/ the system OpenSSL headers installed. See docs/features.md for the trade-offs and docs/migration.md for a switching guide.
Changed¶
- The
clientfeature now explicitly selectssurrealdb/native-tlsandreqwest/default-tls. Behaviour is unchanged for existing consumers (the implicit TLS stack was alreadynative-tls), but the TLS backend is no longer inherited from upstream defaults -- it is pinned by the feature flag. No API changes. - Optional
surrealdbandreqwestdependencies are declared withdefault-features = falseso the TLS backend is selected exclusively byclient/client-rustls.
[0.2.1] - 2026-04-18¶
Documentation¶
docs/features.md-- full feature-flag reference.docs/query-ux.md-- before / after walkthroughs for the 0.2 crate-root helpers (type_record,type_thing,extract_many,has_result,select_expr,execute,aggregate_records).docs/v3-patterns.md-- SurrealDB v3-specific SurrealQL shapes (subprotocol handshake,type::recordrename, datetime coercion, unrolled graph depth, rejectedUPSERT INTO [...], buffered transactions,SurrealValueavoidance).docs/cli.md-- full subcommand reference (replaces the pre-0.1 "planned" placeholder).docs/migration.md-- 0.1.x -> 0.2.x upgrade notes.- Updated README top-level example with
type_record,Query::select_expr,Query::execute,aggregate_records. - Updated
mkdocs.ymlnav with the new pages anddocs.rs/oneiriq-surqlreference link. - Fixed pre-existing rustdoc intra-doc link warnings so
cargo doc --no-deps --all-featuressucceeds underRUSTDOCFLAGS="-D warnings".
No API changes.
[0.1.0 - 0.2.0] see releases¶
Added¶
migration::versioning--VersionedSnapshot,VersionGraph, andcompare_snapshotsfor DAG-based migration history.migration::generator-- generate migration files (generate_migration,generate_initial_migration,create_blank_migration,generate_migration_from_diffs) with atomic writes and round-trip load.migration::diff-- schema diff engine (diff_tables,diff_fields,diff_indexes,diff_events,diff_permissions,diff_edges,diff_schemas).migration::{models, discovery}--.surqlfile-format migrations with-- @metadata/-- @up/-- @downsection markers and SHA-256 checksum.schema::{visualize, themes, utils}-- Mermaid / GraphViz / ASCII diagrams with modern / dark / forest / minimal themes.schema::parser-- parses SurrealDBINFO FOR DB/INFO FOR TABLEresponses back into schema definitions.schema::{validator, validator_utils}-- cross-schema validation with severity-filtered reports.schema::{sql, registry}-- full DEFINE-statement composition and a thread-safeSchemaRegistry.schema::{fields, table, edge, access}-- code-first schema DSL.query::{builder, helpers}-- immutableQuerywith fluent chaining.query::expressions-- 25+ function builders and typed expression kinds.query::{hints, results}-- query optimization hints + typed result wrappers with raw-response extraction helpers.connection::{config, auth}-- connection configuration (URL / ns / db / timeouts / retry / live-queries gate) + auth credential types.types::{operators, record_id, record_ref, surreal_fn, reserved, coerce}-- operator enum +RecordID<T>with angle-bracket syntax + reserved-word checks + ISO-8601 datetime coercion.error::SurqlError-- unified error enum withContextchaining trait.
Notes¶
This is a pre-release port of surql-py targeting 1:1 feature parity. The runtime async client, CRUD executor, and CLI land in the 0.1 -> 0.2 window.