Skip to content

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_vars and the typed CRUD methods now sends such a statement again, up to the connection's retry_max_attempts, waiting its retry backoff between attempts. The conflict is read from the engine's structured TransactionConflict kind, with the engine's fixed wording as a fallback; a statement's own THROW text 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_value recognised SurrealFn and RecordRef by JSON shape, so any object with an "expression" key (or table plus record_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 (now From<SurrealFn>, From<RecordRef>, From<RecordID>), the new eq_expr / ne_expr / gt_expr / gte_expr / lt_expr / lte_expr, and set_expr, which now also fills CREATE / UPSERT / RELATE CONTENT. Breaking
  • Record ids cannot be broken out of. String keys rendered as ⟨key⟩ unescaped, and ⟨ ⟩ has no escape for ⟩. Keys now render through the new types::escape rules (backticks for a key containing ⟩, \ escaped), tables through quote_ident, and a digit-only string key stays a string key: RecordID::new("post", "123") renders post:⟨123⟩, not the integer key post:123 (and "007" no longer names the integer 7). RecordID::parse keeps 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 CHANGES timestamps and tables, hint text (which could close its /* */), db reset table names, the typed client's CRUD and LIVE targets, 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 signin had 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 a caller_session client is refused. Breaking
  • Field PERMISSIONS reach the database. They were modelled but never rendered, so every such field was created FULL. They render, parse back, and drift is detected by the diff and the validator.
  • Event actions stay inside their event. A multi-statement THEN was rendered without braces, so its later statements ran when the definition was applied.
  • Secrets are redacted from Debug for connection configs, credentials, tokens, clients, cache configs, Redis caches, settings and every orchestration type that embeds them.
  • require_approval and allow_destructive are 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 -- @up section, 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 result field, and the arrays of SELECT 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 (->follows2 named a table follows2); GraphQuery::to follows the last hop's direction; LIMIT renders before FETCH; depths and shortest_path are capped at 32. Breaking
  • upsert_many and friends require an id or conflict fields (a table-wide UPSERT added a new record per call), prefix bare ids with the table, accept integer ids, and apply conflict fields on every path. Breaking
  • type_thing renders type::record (3.x rejects type::thing); crud::last honours paging; has_more no longer loops on a zero limit; non-finite vectors and thresholds are refused; integers above i64::MAX render 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 INFO parser.
  • 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, edge IN/OUT endpoints are recovered, record SIGNIN survives a following WITH JWT, and DROP and changefeeds are not read out of comments. parse_analyzer no longer panics on multi-byte names.
  • JWT access and bucket permission DDL follow the engine grammar (both were parse errors); the table DROP flag renders.
  • Breaking: BucketDefinition::permissions is Option<String> (NONE | FULL | WHERE expr); parsed permission maps use "NONE" / "FULL" and drop default actions; JwtConfig::validate refuses 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 UNIQUE index comes back unique); index, event and edge-shape changes are detected and re-defined with OVERWRITE; 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_schemas orders object adds (functions, params, sequences, analyzers, buckets) first, then every drop, then table and edge changes, then object drops. Breaking (output order; and diff_permissions renders ALTER 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 squash no longer deletes schema statements: IF NOT EXISTS definitions were all keyed as the object if, 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 user hid the DELETE); a DELETE in a down body rates as danger.
  • Rolling back a migration with no down fails instead of deleting its history row, and execute_rollback refuses a risky plan until RollbackPlan::approve(). Breaking
  • A squashed migration counts as applied when its sources are (it was re-applied); versions order numerically (v10 after v9) and depends_on orders 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_status now lists such migrations in modified (migrate status shows them as modified), and migrate_up, surql migrate up and 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_schema compares 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, the ascii analyzer) as drift. surql schema validate reads the complete live schema. Breaking: db_edges: None now skips edges, and edge maps take EdgeDefinition.
  • The engine's operator and spacing rewrites are not drift: normalize_expression (and so the diff and the validator) reads && / || as AND / OR, IN as INSIDE, NOT IN as NOTINSIDE, 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 INDEX while 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. RedisCache now holds a redis ConnectionManager (the connection-manager feature of redis), 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_tx and operator values failed on such data. A value nested more than MAX_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; MemoryCache is 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 and cache_key_for output changed, so existing cache entries become misses.
  • StreamingManager prunes 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 / down and orchestrate deploy exit 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-version said 1.90, which never built the locked graph; surrealdb 3.3 uses std::hint::cold_path (stable since 1.95) and declares no rust-version. CI now checks it.
  • SurrealDB 3.3. The surrealdb requirement rises from 3.1.5 to 3.3.0 (the lockfile and the MSRV already assumed it, and client-grpc needs 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 --yes when stdin is not a terminal; orchestrate deploy gains --approve, --yes and --no-auto-rollback. Breaking
  • Breaking: deploy_to_environments(&DeploymentPlan) replaces the ten-argument form; DeploymentResult gains applied_versions and rolled_back_versions, DeploymentPlan gains approved; the environments file rejects unknown keys and duplicate names; OrchestrateCommand::Deploy takes DeployArgs.
  • Breaking: Query::similarity_score returns Result; Operator gains the Expr variant; GraphVizTheme gains palette; index column order is significant in validation; register_table / register_edge refuse a cross-kind name clash.
  • Breaking: Migration and MigrationMetadata gain squashed_from; RollbackPlan gains approved; SnapshotComparison gains bucket fields; SchemaWatcher::start returns a bounded receiver and needs a runtime; Transaction::begin, execute and rollback return ready futures (awaiting them still compiles).
  • Shipped code may not unwrap, expect or panic!, enforced by lint; the only exception is RecordResult::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, unescape and unquote_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::ModifyIndex and ModifyEvent.
  • fuzz/, a cargo-fuzz crate whose targets check record ids, identifiers, string literals and values against the engine's parser, and that the INFO parsers return on arbitrary text.
  • CI jobs for the MSRV, the client-wasm build 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_type render a type the FieldType keywords 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 as any. The diff and the validator compare it through the new normalize_type / type_eq; FieldDefinition::type_clause returns the rendered type. FieldDefinition gains custom_type (Breaking for struct literals).
  • Access comparison: accesses_equal and validate_accesses compare access definitions with the engine's echo, folding away redacted keys, normalised durations (duration_nanos: 24h is 1d), 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) and AccessDefinition::with_context (CONTEXT), plus AccessDefinition::with_authenticate (AUTHENTICATE). The parser reads all three; AUDIENCE used to run into the preceding key. JwtConfig gains audience and AccessDefinition gains authenticate and context (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, and to_surql refuses a table target the same way. Query gains for_update.
  • The client-grpc feature and Protocol::Grpc / Protocol::GrpcSecure: grpc:// and grpcs:// 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 on Protocol.
  • Relation flags and SurrealDB 3.3 graph caches: EdgeDefinition gains enforced (ENFORCED, which the parser used to skip), lightweight (LIGHTWEIGHT, edges with no records), and, like TableDefinition, inline_edges / inline_references (INLINE EDGES n / INLINE REFERENCES n); FieldDefinition gains inline (INLINE). Each has a with_* 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, an INLINE field that is nested, COMPUTED or 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 of parse_db_info. Breaking for exhaustive matches on Tokenizer.
  • COUNT indexes: IndexType::Count, count_index(name) and IndexDefinition::with_condition render DEFINE 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. IndexDefinition gains condition (Breaking for struct literals and exhaustive matches on IndexType).
  • modified_migrations, get_modified_migrations, rehash_migrations, update_migration_checksum and ModifiedMigration; MigrationStatusReport gains modified (Breaking for struct literals); surql migrate rehash [<VERSION>...] accepts an edit to an applied migration by recording its current checksum.

Deprecated

  • mtree_index and IndexType::Mtree. SurrealDB 3 has no MTREE index (every 3.x engine refuses the statement), so IndexDefinition::validate now refuses one; use hnsw_index or diskann_index. The variant stays so existing code and snapshots still load. IndexType::is_removed reports it.

Removed

  • reqwest, a dependency no source file ever used.
  • Query::to_surql_or_panic_with_table (it was doc(hidden)). Breaking
  • The vendored SHA-256 implementation, in favour of the sha2 crate the crate already depended on.

[0.33.1] - 2026-08-27

Fixed

  • DatabaseClient re-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_session clones 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::Diskann and the diskann_index(name, column, dimension, distance, vector_type) builder define the on-disk ANN graph the 3.2 engine parses, with with_degree / with_l_build / with_alpha / with_hashed_vector for the tuning tail and DiskAnnDistanceType for 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. MTreeVectorType gained F16, I8, and U8, which HNSW also accepts. The <|k,ef|> KNN operator reaches a DISKANN index through the same KnnScan plan 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

  • surrealdb is 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() became Ulid::generate(), and comfy-table's string presets became TableStyle values loaded with load_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, and parse_edge_info refused the response shape query actually returns. query answers one result per statement, so an INFO FOR TABLE object arrives wrapped in a one-element array that only parse_db_info had 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_invalidate flaked against its own binary. The cache integration tests run concurrently in one process, and is_cached_returns_false_when_no_manager calls close_cache(), which empties the process-global manager slot; landing between another test's configure_cache and its reads of that slot, it made invalidate and clear_cache report 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 REFERENCE silently 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 probed FOR quirks: SELECT VALUE id because FOR refuses object rows, ?? [] because it refuses an empty selection). The field diff carries it in details with a SchemaDiff::reference_backfill_sql() accessor rather than inside forward_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 / validate and surql bucket list read every database as empty. The CLI passed the raw client.query("INFO FOR DB;") response to parse_db_info, but query answers 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_info now 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). FieldDefinition gained reference: Option<ReferenceAction> (IGNORE / REJECT / CASCADE / UNSET) and computed: Option<String>, with FieldBuilder::reference / ::computed and the reverse_reference_field(name, source) constructor for the reverse half (COMPUTED <~source). Query::reverse_traverse and query::references::reverse_reference_query render 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_concurrently appends the directive, which lets a large index populate without blocking the statement. info_for_index_surql(name, table) renders the progress query and IndexBuildStatus::from_info reads the { building: { status, initial, pending, updated } } answer, including through the array DatabaseClient::query wraps 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). ChangeFeed and TableDefinition::with_changefeed render CHANGEFEED <duration> [INCLUDE ORIGINAL] between the mode and the PERMISSIONS clause, parse_changefeed reads it back out of the INFO FOR DB echo, and diff_tables reports a change as the new DiffOperation::ModifyTable carrying the full DEFINE TABLE OVERWRITE form (a bare CHANGEFEED statement 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 / ViewGroup and TableDefinition::with_view render TYPE NORMAL <mode> AS SELECT <projections> FROM <tables> [WHERE ...] [GROUP BY ...|GROUP ALL], parser::parse_view reads it back, and a changed body reports as DiffOperation::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_schema render BATCH / START / TIMEOUT and the REMOVE SEQUENCE IF EXISTS and sequence::nextval("<name>") statements; parse_sequence reads the sequences map of INFO FOR DB back; DatabaseInfo::sequences and SchemaSnapshot::sequences carry them; and diff_objects::diff_sequences reports Add / 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_schema render the signature, return type, body, COMMENT, and PERMISSIONS; parse_function reads the functions map of INFO FOR DB back (splitting arguments on top-level commas so array<record<x>> survives, and taking the body from the outermost brace pair so a nested block does not truncate it); and diff_objects::diff_functions reports Add / 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_schema render the value, COMMENT, and PERMISSIONS; parse_param reads the params map of INFO FOR DB back; and diff_objects::diff_params reports Add / Modify / DropParam. As with functions, normalized fills in the PERMISSIONS FULL the 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_buckets and diff_analyzers moved across (re-exported from migration::diff, so no path changes), and diff_named captures the add / drop / modify shape they share, so a new kind is three lines rather than another copy of the walk. Net effect: migration::diff is smaller than it was before this release despite gaining change-feed and view diffing. SchemaDiff gained one object: Option<String> field naming the object such a diff targets.

  • array<record<table>> field types. An ARRAY field with a target_table now renders array<record<{target}>> instead of a bare array, which is the shape a to-many reference needs. A target_table on an ARRAY field was previously carried but never rendered, so this changes emitted DDL for any definition that set both.

Changed

  • BREAKING: SchemaSnapshot and SchemaDiff gained fields. SchemaSnapshot::sequences and SchemaDiff::object both 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. FieldType moved to schema::field_type and IndexDefinition (with IndexType, the distance and vector-type enums, and the index / unique_index / search_index / bm25_index / mtree_index / hnsw_index builders) moved to schema::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, and diff_schemas with their own parser, so the full schema a consumer declares can be compared against what a database actually holds. parse_table_full names the two-level composition (INFO FOR DB for mode and permissions, INFO FOR TABLE for 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 in option<...> (including record targets: option<record<blob>>) so a SCHEMAFULL column can accept NONE. The INFO FOR TABLE parser 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::graph gained a conditions argument on traverse, traverse_raw, traverse_with_depth, get_outgoing_edges, get_incoming_edges, get_related_records, and shortest_path. Each entry renders through Query::where_ and multiple entries combine with AND, so a traversal can carry a tenant guard or any other row-level predicate. Restores parity with the sibling ports, which have accepted conditions on 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 owned Raw(String) | Op(Operator) carrier with From impls for &str, String, &String, Operator, and &Operator, plus WhereCondition for both Condition and &Condition. WhereCondition takes self by value and so cannot be used behind a trait object; Condition is what lets one slice mix raw fragments and operators, matching the str | Operator union the sibling ports accept.

Changed

  • DatabaseClient clones now share ONE engine session. The SDK mints a session per Surreal clone and announces it with lifecycle events the remote router can lose under concurrency, which surfaced as intermittent Session not found failures in any service that clones its client per request (an axum state extraction does exactly that). The inner handle now rides an Arc, so clones share the service session and session churn stops entirely. Code that wants an independent session asks for one: caller_session for 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's Clone.

  • 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 required Option<T> parameter (as create_relation's data: Option<Value> already does). Existing call sites migrate by passing None, which leaves the emitted SurrealQL unchanged. count_related is deliberately not included, because surql-py does not filter it either, and diverging would break the 1:1 contract.

  • Graph helpers compose through Query instead of format!. Every SELECT-shaped helper now builds its statement with Query::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_path emits parenthesised predicates. Now that the identity check goes through the builder, the rendered clause is WHERE (id = <to>) [AND (…)] rather than WHERE id = <to>. Semantically identical; noted because it changes the exact statement text.

Fixed

  • The guides document what this release adds. Nullable fields and OVERWRITE rendering are in the schema guide, reading a live database back through parse_db_info and parse_table_full is in the migrations guide. Two examples that predate this release are corrected while passing: SchemaSnapshot::from_registry does not exist and never did, and a SchemaSnapshot struct 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.toml names 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 running cargo audit sees 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 render OVERWRITE forms: a modified field re-defines with OVERWRITE, and a permissions change carries the owning table's FULL definition, because a permissions-only DEFINE TABLE would silently reset the table's mode.

  • OVERWRITE rendering across the schema layer. Tables, fields, indexes, events, analyzers, and access methods gain to_surql_overwrite, and generate_table_sql_overwrite renders a table's full statement set with it. IF NOT EXISTS creates 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_session opens 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 applying PERMISSIONS to 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 that PERMISSIONS would not filter), and returns a client whose drop ends the session.

  • Connection credentials reach embedded engines at build time. username/password now 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_expr accept 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.

  • FLEXIBLE now renders immediately after the TYPE clause. 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 with READONLY, DEFAULT, VALUE, or ASSERT failed to apply. Verified against v3.0.5: the after-TYPE position is accepted in every combination, including option<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 (emit TYPE file / TYPE bytes) plus file_field(name) / bytes_field(name) builders; the INFO FOR ... field parser round-trips both.
  • Schema: new schema::bucket module — BucketDefinition with bucket_schema(name, backend) / memory_bucket(name) / file_bucket(name, path) builders, rendering DEFINE BUCKET [IF NOT EXISTS|OVERWRITE] … BACKEND "…" [READONLY] [PERMISSIONS …] [COMMENT "…"], REMOVE BUCKET …, and ALTER BUCKET [IF EXISTS] … (READONLY/DROP READONLY, BACKEND/DROP BACKEND, PERMISSIONS, COMMENT/DROP COMMENT). Exposed via generate_bucket_sql[_with_options].
  • Migrations: DiffOperation::{AddBucket, DropBucket, ModifyBucket}, a bucket field on SchemaDiff, diff_buckets(code, db), and bucket support threaded through SchemaSnapshot, VersionedSnapshot, the SchemaRegistry (register_bucket / get_registered_buckets), the initial-migration generator, drift detection, and schema generate.
  • Parser: parse_bucket reconstructs BucketDefinition from INFO FOR DB (bu / buckets) into DatabaseInfo::buckets.
  • FileRef value 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), while Display always renders a single-slash pointer (bucket:/a.txt) for any input. serde round-trips the structured form ({ bucket, key: "/a.txt" }) and accepts the f"bucket:/key" literal the SDK emits. The Rust SDK decodes file values (in head/file::list/record fields) straight to that literal, so — unlike the Python port — no file::bucket/file::key projection is needed.
  • Runtime API: DatabaseClient::bucket(name) returns a Bucket handle with put / 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 parameterised type::file($bucket, $key) constructor with bound params (never string-interpolated). Binary payloads bind as a native surrealdb::types::Value::Bytes via the new DatabaseClient::query_with_surreal_vars (no base64) — the JSON bind path cannot carry raw bytes. Data is accepted as a FileData::Text|Bytes enum.
  • CLI: new surql bucket group — define / list / rm plus file ops put / get / delete / exists / files.
  • Buckets require the server's SURREAL_CAPS_ALLOW_EXPERIMENTAL=files environment variable (the feature is hidden and not enabled by --allow-all; the --allow-experimental files flag form is broken). Live round-trip coverage is in tests/integration_files.rs (embedded probe + an #[ignore]d server test gated on SURREAL_FILES_URL), verified against SurrealDB 3.1.3.

  • Sessions documented as unsupported. The Rust surrealdb crate has no multiplexed-session API, so surql-rs deliberately ships none (unlike the Python / TypeScript ports). The new connection::session module documents this and advises a separate DatabaseClient per 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 ANALYZER in code with analyzer(name) / standard_analyzer(name) (AnalyzerDefinition + Tokenizer + TokenFilter, rendered via generate_analyzer_sql / generate_analyzer_sql_with_options); build a BM25-scored full-text index with bm25_index(name, columns, analyzer) (or search_index(...).with_analyzer(...).with_bm25().with_highlights()); and run the lexical query with Query::fulltext_search(field, reference, query) + Query::search_score(reference, alias), or the fulltext_search_query(...) helper. Pair it with vector_search and fuse the two result orders by rank (Reciprocal Rank Fusion). Verified end-to-end against an embedded SurrealDB engine in tests/integration_fulltext.rs.

Fixed

  • Full-text index now emits the SurrealDB 3.x FULLTEXT keyword. The full- text index keyword was renamed from SEARCH to FULLTEXT in 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 emit FULLTEXT, and the INFO FOR TABLE index parser recognises both spellings. See docs/v3-patterns.md §9 — including the note that the v3 streaming executor's full-text scan returns rows in BM25 relevance order but search::score is 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 was Already connected instead of the actual failure (e.g. There was a problem with authentication), and retries 2..n never re-attempted the failing step at all. DatabaseClient now tracks engine-level connection state: a retry — or a connect on 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 PERMISSIONS now render correctly. TableDefinition emitted a malformed DEFINE FIELD PERMISSIONS FOR {action} ON TABLE ... per action, which SurrealDB rejects (Unexpected token FOR). Table permissions now render inline on the DEFINE TABLE statement (... PERMISSIONS FOR select WHERE ... FOR create WHERE ...), the only valid placement. Affects to_surql_with_options / to_surql_all_with_options and generate_table_sql.
  • Edge table PERMISSIONS were silently dropped. EdgeDefinition ignored its permissions entirely; they now render inline on the DEFINE TABLE ... TYPE RELATION statement.
  • Migration diff renders a permissions change as valid SurrealQL. A ModifyPermissions diff emitted the same malformed DEFINE FIELD PERMISSIONS form; it now emits a single DEFINE TABLE <t> PERMISSIONS ... statement. (A SCHEMAFULL table re-defined this way falls back to SCHEMALESS; full-mode fidelity is a follow-up once the diff carries the table mode.)

Added

  • Expression-valued UPDATE ... SET for atomic read-modify-writes. Query::update_set begins an UPDATE <target> SET ... whose assignments are supplied via set (literal) or set_expr (expression-valued), combinable with where_ and RETURN. Expression now implements the standard arithmetic operators (+ - * / over anything Into<Expression>, with From<i64|i32|f64> for numeric literals), so a SET value 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_none operators (field IS NONE) — the correct guard for an absent optional field, which SurrealDB reports as NONE, not NULL.
  • AccessDefinition::to_surql_with_options(if_not_exists) and generate_access_sql_with_options(access, if_not_exists) to emit DEFINE 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. FieldDefinition gains a target_table field, and record_field(name, Some("user")), the new target_table(...) builder setter, and with_target_table(...) all render TYPE record<user>. A canonical type::record("X", $value) coercion on a RECORD field is auto-lifted into target_table at build time, dropping the now redundant VALUE clause. The DEFINE FIELD parser reads record<table> back into target_table so 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 openssl from 0.10.79 to 0.10.80 via cargo update, closing CVE-2026-45784 (medium severity, potential out-of-bounds write in CipherCtxRef::cipher_update_inplace for AES-KW-PAD ciphers). The crate's default client-rustls backend never links openssl; the bump only affects consumers that opt into the client / client-tls feature.

Fixed

  • Daily Security Audit workflow no longer fails on every scheduled run. Replaced the deprecated Node.js 20 rustsec/audit-check@v2.0.0 action with a direct taiki-e/install-action + cargo audit invocation. 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 through surrealdb and are not actionable from this repo.

Changed

  • CI workflow (ci.yml) now runs the stable Rust toolchain only on push and pull-request triggers. beta toolchain 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-ignore filters to ci.yml and coverage.yml so pure documentation, LICENSE, or .editorconfig / .gitignore changes no longer trigger a full compile + clippy + test run. docs.yml already handles documentation rebuilds.
  • Dependabot auto-merge workflow now uses dependabot/fetch-metadata@v3 and lewagon/wait-on-check-action@v1.7.0, the latest stable majors of both actions.
  • docs/features.md and docs/migration.md corrected: the default feature has been client-rustls since 0.2.3, not client.

Added

  • docs/connection-management.md documents the task-scoped current client, ConnectionRegistry, AuthManager, StreamingManager / LiveQuery, and Transaction.
  • docs/caching.md documents CacheManager, the MemoryCache and RedisCache backends, the cached / cached_with / cache_key_for helpers, and the invalidation surface.
  • docs/orchestration.md documents EnvironmentConfig / EnvironmentRegistry, DeploymentPlan, DeploymentCoordinator, the four built-in DeploymentStrategy implementations (Sequential, Parallel, Rolling, Canary), DeploymentResult, and check_environment_health / verify_connectivity.
  • docs/migration.md now carries an Upgrading 0.2.5 -> 0.2.6 section.
  • mkdocs.yml navigation 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) in surql::schema::parser — counterpart to [parse_table_info] for graph-edge tables defined via edge_schema / [EdgeDefinition]. Edge mode is detected from the DEFINE TABLE statement: TYPE RELATION resolves to EdgeMode::Relation, SCHEMAFULL to EdgeMode::Schemafull, anything else to EdgeMode::Schemaless. FROM <table> and TO <table> are extracted independently so a malformed live definition that lost one clause surfaces as missing-endpoint drift instead of a parse failure. On Relation-mode edges the auto-emitted in and out field declarations SurrealDB stores are stripped on parse — they are implicit when TYPE RELATION is set, so the code-side EdgeDefinition does not declare them and round-trip diffs were flagging them as orphan additions. Per-action PERMISSIONS round-trip via the new parse_table_permissions helper.

  • parse_table_permissions(definition) in surql::schema::parser — extracts the per-action PERMISSIONS rules from a DEFINE TABLE statement string. Returns None for the trivial NONE / FULL postures (the code-side helpers have no representation for those) and for definitions without a PERMISSIONS clause. 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 Rust regex crate does not support lookahead, so the body is split on FOR boundaries 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 the DEFINE TABLE <name> ... statement string, fetched from INFO FOR DB's tables.<name> entry. SurrealDB v3's INFO FOR TABLE does not include the table-level DEFINE TABLE statement, so table mode and PERMISSIONS cannot be recovered from it alone. Without define_table the parser falls back to the legacy tb key inside the response (the v1 / v2 shape) and table mode defaults to Schemaless on v3.

  • strip_brackets(value) in surql::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 bare table:id shape were calling value.replace('⟨', "").replace('⟩', "") themselves at every API boundary; strip_brackets centralises that strip and also accepts the legacy ASCII < … > form. None is 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 one UPSERT <target> CONTENT { … } statement per item on the supplied [Transaction] buffer; the per-record statements inherit the surrounding BEGIN TRANSACTION / COMMIT TRANSACTION framing so a single bad record rolls back the entire batch on commit instead of leaving the database half-seeded. Transaction::execute queues 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). Both upsert_many and upsert_many_in_tx accept an optional conflict_fields slice that emits an inline-value WHERE … AND … clause appended to each UPSERT.

Fixed

  • build_upsert_query emitted UPSERT INTO <table> [ {…}, {…} ], which SurrealDB v3 rejects with a parse error — v3 wants a single record-id or table target after UPSERT, not an array literal. The renderer now emits one UPSERT <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_query conflict_fields emitted WHERE field = $item.field, which has no $item binding in scope at the call site (and the rendered string is also fed verbatim to Transaction::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::Display emitted ASCII <id> brackets for ids that could not be rendered bare. SurrealDB v3 rejects ASCII < / > in record-id positions with Unexpected token '<', expected a record-id key; the output now uses the v3-correct unicode escape syntax ⟨id⟩ (U+27E8 / U+27E9). RecordID::parse accepts both forms on input so legacy wire payloads still round-trip cleanly. Breaking for callers that asserted on the exact Display output; the SQL shape is identical otherwise.

  • RecordID::needs_angle_brackets accepted leading-digit ids bare (chunk:1abc). The pre-0.2.5 simple_id_pattern was [A-Za-z0-9_]+, which let 1abc slip through and produced a literal v3 rejects with Unexpected token. The new identifier_id_pattern is [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_many no longer routes through UPSERT <table> CONTENT $data for items that lack an id field. The autocommit path always pins the target — data.id when present, <table> otherwise — and strips id from the bound payload so v3 does not reject the duplicate field.

  • CI workflow set hardened against runaway runs:

  • docs.yml switched from the global group: pages concurrency 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 with cancel-in-progress: true.
  • ci.yml and coverage.yml gained per-ref concurrency groups so rapid pushes to a PR cancel the in-progress run. The redundant push: branches: ['release/**'] triggers were dropped — release branches only ever receive PRs that already fired the workflow via pull_request, so the push trigger was pure duplicated work.
  • audit.yml, dep-review.yml, and pr-title.yml gained 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 covering parse_edge_info, parse_table_permissions, strip_brackets, unicode-bracket RecordID::Display, and the per-record build_upsert_query shape).
  • All integration tests compile.

[0.2.4] - 2026-05-02

Added

  • client-wasm feature (Oneiriq/surql-rs#115). Wasm-friendly client surface that compiles cleanly to wasm32-unknown-unknown. Pulls surrealdb with protocol-ws + kv-mem only -- no rustls / native-tls / reqwest, since browsers terminate TLS at the WebSocket layer and kv-mem lets wasm callers run an embedded engine for local state and tests. Exposes the same DatabaseClient / executor / crud / graph / batch API as client-rustls.
  • [target.'cfg(target_arch = "wasm32")'.dependencies] block in Cargo.toml that overrides tokio to the wasm-buildable subset (sync, macros, rt, time) and pulls getrandom 0.3 with the wasm_js feature so ulid / rand_core link on wasm.
  • .cargo/config.toml with the --cfg=getrandom_backend="wasm_js" rustflag required by getrandom 0.3 on wasm32-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 so cc-rs can hand ring 0.17's build script a wasm-capable clang (Apple's /usr/bin/clang has no wasm32 backend).

Changed

  • The optional tokio dependency moved from a top-level [dependencies] declaration with features = ["full"] to two target-specific declarations: native targets keep the historical ["full"] feature set, while wasm32-* 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-wasm now succeeds on a system with a wasm-capable clang in scope. Unblocks Oneiriq/pixel-stroke#236 (web-build of pixel-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 pulled surrealdb/native-tls and reqwest/default-tls and therefore openssl-sys into the dependency graph. The historical native-tls backend is still available via the client feature (now also exposed under the client-tls alias) for consumers that need the system OpenSSL stack.
  • The cli and orchestration features now depend on client-rustls instead of client so that cargo install oneiriq-surql --features cli and other typical builds no longer compile against openssl-sys.

Security

  • Drops the openssl-sys transitive 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 the client-tls alias) still link the system OpenSSL stack and remain subject to upstream rust-openssl advisories.

[0.2.2] - 2026-04-21

Added

  • client-rustls feature (Oneiriq/surql-rs#97). Same surface as the default client feature, but with a pure-Rust TLS stack (rustls + webpki-roots) instead of native-tls. Enables building on runners that do not have libssl-dev / the system OpenSSL headers installed. See docs/features.md for the trade-offs and docs/migration.md for a switching guide.

Changed

  • The client feature now explicitly selects surrealdb/native-tls and reqwest/default-tls. Behaviour is unchanged for existing consumers (the implicit TLS stack was already native-tls), but the TLS backend is no longer inherited from upstream defaults -- it is pinned by the feature flag. No API changes.
  • Optional surrealdb and reqwest dependencies are declared with default-features = false so the TLS backend is selected exclusively by client / 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::record rename, datetime coercion, unrolled graph depth, rejected UPSERT INTO [...], buffered transactions, SurrealValue avoidance).
  • 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.yml nav with the new pages and docs.rs/oneiriq-surql reference link.
  • Fixed pre-existing rustdoc intra-doc link warnings so cargo doc --no-deps --all-features succeeds under RUSTDOCFLAGS="-D warnings".

No API changes.

[0.1.0 - 0.2.0] see releases

Added

  • migration::versioning -- VersionedSnapshot, VersionGraph, and compare_snapshots for 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} -- .surql file-format migrations with -- @metadata / -- @up / -- @down section markers and SHA-256 checksum.
  • schema::{visualize, themes, utils} -- Mermaid / GraphViz / ASCII diagrams with modern / dark / forest / minimal themes.
  • schema::parser -- parses SurrealDB INFO FOR DB / INFO FOR TABLE responses 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-safe SchemaRegistry.
  • schema::{fields, table, edge, access} -- code-first schema DSL.
  • query::{builder, helpers} -- immutable Query with 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 with Context chaining 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.