SoliDB 2.1: transactions that mean it, sync conflicts you can see, roles that stop at a database
2.1.0 is a release about things SoliDB said it did and did not quite do. A driver transaction that committed on the spot. Two writes of the same unique value that both went through. A custom role that granted nothing. Sync conflict endpoints that answered “not implemented”. Each is fixed here, with the request that shows it, and a few features that fell out of fixing them: database-limited roles, writing queries with COLLECT inside a transaction, and OPTIONS on sharded collections.
The transaction and role responses on this page were captured from a 2.1.0 build on a throwaway server. The sync section describes behaviour pinned by the test suite rather than a captured session, and says so where it matters.
A driver transaction that was not one
The native driver has a transaction_command that wraps another command in an open transaction. Until 2.1.0 it checked that the transaction existed and then ran the inner command normally. An insert sent through it was committed at once, and rollback_transaction then had nothing to undo. Nothing failed, which is why it survived: the happy path (begin, insert, commit) looks identical either way.
Now insert, update and delete are staged on the transaction and applied at commit, and a rollback discards them. A read-only query runs against committed data, as it does over HTTP. Anything else is refused with a transaction_error instead of quietly running outside the transaction. The dispatcher still authorizes the wrapper by the permission of the command inside it, so a Read-only session cannot stage a write; that is now a test rather than a property nobody had looked at.
Writing queries can use the whole language
Over HTTP, POST /_api/database/{db}/transaction/{id}/query ran read-only queries on the normal executor but pushed anything that wrote through a small hand-written pipeline that understood FOR, LET and FILTER, and refused JOIN, COLLECT, graph traversals and windows. So a transaction could read with an aggregate but not write one.
2.1.0 deletes that pipeline. The query runs on the ordinary executor with its writes redirected to the transaction, so every clause works, and RETURN NEW returns rows instead of []. Here is a COLLECT that writes, inside a transaction, on a real server:
# orders holds {a:5}, {a:7}, {b:3} curl -s -X POST $B/_api/database/v/transaction/$TX/query -d '{"query": "FOR o IN orders COLLECT c = o.cust AGGREGATE s = SUM(o.amt) INSERT {_key: c, total: s} INTO totals RETURN NEW.total"}' {"message":"2 operation(s) staged in transaction. Commit to apply changes.", "mutationCount":2,"result":[12.0,3.0]} # outside the transaction, before commit: FOR t IN totals RETURN t → "result":[] # after commit: FOR t IN totals SORT t._key RETURN [t._key, t.total] → "result":[["a",12.0],["b",3.0]]
Two limits remain, and both are refusals rather than surprises. OPTIONS (overwriteMode, keepNull, mergeObjects, ignoreErrors) and REPLACE need a read-modify-write that a staged operation cannot express, so a transactional query that uses them is rejected. And reads inside a transaction see committed data, not the transaction’s own staged writes; a uniqueness check done by reading first therefore proves nothing.
Two writes, one unique value
That last sentence hid a bug. Even without reading first, a unique index did not protect you inside a transaction. Every staged write was checked against committed data, and committed data does not contain the transaction’s earlier writes, so two inserts of the same email both passed and both committed. The old guidance in our own notes was “transactions do not check unique indexes”, which was accurate and is now wrong:
# users has a unique index on email
POST …/transaction/$TX/query INSERT {_key:"u1", email:"a@x.io"} INTO users → staged
POST …/transaction/$TX/query INSERT {_key:"u2", email:"a@x.io"} INTO users → staged
POST …/transaction/$TX/commit
{"code":400,"type":"InvalidDocument",
"error":"Unique constraint violated: 'uniq_email:046140782e696f00' is claimed by both 'u1' and 'u2' in this transaction"}
FOR u IN users RETURN u._key → "result":[]Commit staging now remembers which staged document holds each unique value, releases it when that document is updated or deleted, and refuses a second claimant. The commit fails as a whole and writes nothing. One conservative edge remains: if a transaction deletes the document that holds a value and inserts another with the same value, the check against committed data still sees the original and refuses. Do those in two transactions.
Sync conflicts you can list and resolve
GET /_api/sync/conflicts and POST /_api/sync/resolve have existed for a long time and answered “not implemented”, and push applied whatever arrived last. That was the honest answer, because detecting a conflict needs to know what the client had seen, and documents carry no version vector.
2.1.0 gets there without changing how documents are stored. A pulled change arrives with a vector {node: log_sequence}, so a client’s vector already says how far into the server’s log it has read. For each document it has synced, the server now records the log sequence and _rev of its last write, and the device that made it. A pushed change is a conflict when the document changed after the client’s last-seen sequence:
- another device pushed to it and this client has not pulled that write; or
- an ordinary write (the API, a query, a script) changed it since the last sync and this client has not pulled that either.
A conflicting change is not applied. It comes back under conflicts in the push response and is held until you settle it: resolution: "local" keeps the server’s document, "remote" applies the client’s change, "merged" stores the merged_data you send. After resolving, the same device’s next push is not a conflict.
| Case | Result |
|---|---|
| Device A pushes, device B (which never pulled) pushes the same document | B’s change is held; A’s stays |
| Device B pulled past A’s write, then edits | Applied |
| Device A pushes twice in a row | Applied both times; a device never conflicts with itself |
| Someone edits through the API after A’s sync; A pushes without pulling | Held, even though it is the same device |
| A change with no vector, or a document the server has never synced | Last write wins, as before |
Three details are about safety rather than convenience. Pushing needs Write on a database, not Read, so the listing shows the server’s copy of the document only to a caller who could have read it anyway. The bookkeeping lives in two collections, _sync_conflicts and _sync_versions, that are not readable or writable by name, because a conflict row carries the change that will be applied on resolution and a forged one would write into any collection the resolver can reach. And the bookkeeping stays on the node that took the push; it is not replicated.
One decision worth flagging: the server does not pick a winner for you. There is no “last write wins” or “merge” strategy in the resolver; every conflict waits for an explicit answer. The Lua conflict resolver that once stood in for a custom strategy stays gone, for the reason it was removed.
Delta pushes work too now. A change flagged is_delta carries an RFC 6902 patch that is applied atomically to the stored document; a patch for a document the server does not have, or one that does not apply, is refused and changes nothing. A patch cannot rename the document or forge _id, _rev or _created_at, and eight concurrent patches of one document lose none of their edits.
Custom roles, and roles that stop at a database
This one is a bug first. A role created with POST /_api/auth/roles was stored with its name in _key. When permissions were resolved, the stored role was deserialized from the document body, which does not include _key, so it failed to load and was skipped without a message. Only the three built-in roles, which are resolved from a fallback, ever had any effect. A user holding a custom role showed the role in /_api/auth/me and had no permissions at all. That is fixed for global and limited assignments alike.
The feature that surfaced it is assigning a role to one database. POST /_api/auth/users/{user}/roles has always accepted a database, and until now it was refused, because nothing downstream could express the limit and honouring it as a global grant would have been the opposite of the request. Now the assignment grants the role’s actions on that database only: a global permission of the role is narrowed to it, and a permission the role already limits to another database is dropped. It never grants instance-level operations. Here is a user with the built-in editor role limited to database v:
POST /_api/auth/users/bob/roles {"role":"editor","database":"v"}
{"username":"bob","role":"editor","database":"v", …} [201]
bob reads v → 200
bob writes v → 200
bob reads other → 403
bob writes other → 403
bob creates a database → 403A limited assignment travels through tokens and /_api/auth/me as role@database. It never equals a bare role name, so nothing that tests for admin can mistake admin@v for the global role; the price is that role names can no longer contain @. The database must exist when you assign, so a typo cannot silently grant nothing, and editing a role updates its limited assignments too. Rows written earlier with a database used to grant nothing; they now grant exactly what was asked, which is narrower than anything they could have granted before.
Smaller fixes worth knowing
| Area | What changed |
|---|---|
| Resharding | It never asked remote nodes whether a migrated batch had arrived, and when verification found nothing it trusted the batch and deleted the local originals. Batches it cannot confirm are now kept for the next pass. |
| Truncate | It left version history behind, so an AS OF read could resurrect the deleted documents, and left pending-embedding markers that kept the worker busy. Both are cleared. |
| Vector quantization | quantize saved the setting and returned zeroed stats without touching the index, and dequantize did nothing. Both act now and the response reports real sizes. |
| Driver commands | prune_collection (new older_than), get_collection_sharding, repair_collection, geo_within polygon search, vector_search with a filter, and columnar filter and order_by used to answer “not supported”. create_columnar always failed and no longer does. The added older_than field is optional on the wire, and a test decodes a client message without it. |
| Triggers | The filter field was stored and ignored. It is an SDBQL expression over doc, old and event; the trigger fires only when it is truthy, and a filter that errors does not fire. |
| Restore | solidb-restore reads SQL dumps: INSERT INTO t (cols) VALUES … from mysqldump, pg_dump and sqlite. An insert with no column list is reported and skipped rather than guessed at. |
| SSRF guard | DNS lookups time out after 5 seconds, at most 32 run at once, webhook delivery resolves once on the blocking pool, and the OLLAMA_URL verdict is cached for 30 seconds and warmed when the URL is written. |
| Queries | FILTER doc._id == … is a primary-key lookup. Sliding-window streams drop expired events when the window fires. Cluster heartbeats carry real CPU and memory, and nodes marked suspected or dead are logged. |
Upgrading
Nothing needs a migration, and nothing in storage changes. Three behaviours can differ on the day you upgrade:
- Custom roles start working. If you have users holding a role you created, they now receive its permissions, which is what you configured, and possibly more than they have been getting. Read
GET /_api/auth/rolesbefore you upgrade. - Existing database-limited assignments start granting. They granted nothing before; now they grant the role on that database.
- Sync clients that send a vector can see conflicts. Clients that send none behave as before. If a client of yours ignores the
conflictsarray in the push response, its change is now held rather than applied when it is concurrent; wire upGET /_api/sync/conflictsbefore you rely on it.
Upgrade every node of a cluster before running a REPLACE on a sharded collection. The full list, with the commit-level detail, is in the changelog; the sync flow is in the offline sync guide, and transactions in the transactions guide.