Application SQL limits
The client SQL API accepts application reads and writes, but it is not an unrestricted SQLite connection. The SDK must preserve change capture and its internal synchronization state.
Supported statement shape
Section titled “Supported statement shape”Use one statement per query, queryOne, execute, or transaction operation.
- Reads use
SELECT, including read-only common table expressions. - Swift
queryandqueryOneaccept only read-only statements. In a write transaction, the SDK checks this before SQLite runs the statement. A write withRETURNINGfails this check. - Writes use ordinary
INSERT,UPDATE, andDELETEstatements. - Writes to Synchro metadata, queue tables, capture triggers, and synced-table DDL are rejected.
- Application writes to a synced table require its generated capture triggers to remain installed.
- Use the SDK transaction method to group multiple application writes. Do not send a transaction-control statement through the application SQL API.
Conflict handling
Section titled “Conflict handling”Do not use INSERT OR REPLACE or INSERT OR IGNORE for synced application writes.
Kotlin rejects the OR conflict-clause form before SQLite compiles the statement. Swift passes statement parsing to SQLite, but OR REPLACE has SQLite replacement semantics: a conflicting row can invoke delete handling and then insert handling. That operation is not a Synchro update and can produce multiple change-capture effects instead of one update.
The same restriction applies to UPDATE OR ... on Kotlin. The client APIs do not provide a cross-platform guarantee for SQLite conflict algorithms. Use ordinary INSERT, UPDATE, or DELETE statements and handle uniqueness errors in application code.
SQLite UPSERT syntax with ON CONFLICT ... DO UPDATE or ON CONFLICT ... DO NOTHING is not a portable Synchro application-write contract. The Kotlin parser does not reject every trailing ON CONFLICT form, and Swift relies on SQLite compilation. Do not use these forms when the same SQL must run on both SDKs with defined change-capture behavior.
The ON CONFLICT DO UPDATE statements visible in the client implementation maintain Synchro-owned queue and metadata tables. They are internal implementation details, not application SQL examples.
Savepoints and transaction control
Section titled “Savepoints and transaction control”The Kotlin application SQL parser rejects SAVEPOINT, RELEASE, ROLLBACK, BEGIN, and COMMIT statements. Swift application writes already run inside the SDK-managed GRDB transaction. While that write runs, the Swift SQLite authorizer rejects these statements and END. This rule applies to execute, query, and queryOne.
Use client.transaction { ... } on Kotlin or client.writeTransaction { ... } on Swift for one local transaction of application statements. If the closure throws, the SDK rolls back the managed transaction. Do not issue SAVEPOINT, RELEASE, ROLLBACK TO, BEGIN, or COMMIT through application SQL.
A local transaction does not make the server apply its mutations together. The server can apply some mutations and reject others. Use an atomic write transaction when the server must apply all mutations or none.
These limits apply to the public client SQL surfaces, including the React Native bridge. React Native forwards SQL to the native SDK and does not add a separate SQL engine.
Swift change capture
Section titled “Swift change capture”The Swift SDK selects captured fields from the statement and the capture context:
- An ordinary
INSERTcaptures the writable columns that the statement names. Without a column list, it captures all writable columns. - An ordinary
UPDATEcaptures each writable field whose value changes. This includes a change that a trigger on a local table makes to a synced row. authoredWriteTransactionkeeps its named-column mask for the complete transaction. AnUPDATEcaptures only the changed fields that the mask names for its table.- While that mask is active, an
UPDATEof another synced table creates no update mutation. An empty column list also creates no update mutation. - A soft-delete transition records a delete mutation without field values. A mask does not change this rule.
An INSERT that names no writable column is a key-only create. Swift and Kotlin capture it as an insert with empty columns, and the server supplies the other values. An INSERT into a synced table without its own capture context aborts, because its authored fields are unknown. A trigger on a local table that inserts a synced row is one example.
The SDK removes an inferred INSERT context after its statement. It removes an authored mask when the authored transaction callback ends. If a removal fails, the SDK throws a database error. A managed write with remaining capture context fails and rolls back. This also occurs when the callback caught the earlier error.
Atomic write transactions
Section titled “Atomic write transactions”atomicWriteTransaction runs one application write transaction. The server applies all of its synced mutations or none of them.
| Surface | API |
|---|---|
| Swift | func atomicWriteTransaction<T>(_ block: (ApplicationTransaction) throws -> T) throws -> T on SynchroClient |
| Kotlin | fun <T> atomicWriteTransaction(block: (ApplicationTransaction) -> T): T on SynchroClient |
| React Native | atomicWriteTransaction<T>(fn) on SynchroClient |
The API opens its transaction through the same path as writeTransaction. Nesting behavior is therefore identical to writeTransaction:
- A nested atomic call joins the enclosing group. It does not start a second group.
- Kotlin permits a nested call.
- The Swift transaction surface does not expose a nested transaction API.
A group that captures no synced rows commits as an ordinary write.
Swift example:
import Synchro
func publishReview(client: SynchroClient, noteID: String, reviewID: String) throws { do { try client.atomicWriteTransaction { transaction in try transaction.execute( "UPDATE notes SET body = ? WHERE id = ?", params: ["Reviewed", noteID] ) try transaction.execute( "INSERT INTO notes (id, owner_id, body) VALUES (?, ?, ?)", params: [reviewID, "alice", "Review of \(noteID)"] ) } } catch SynchroError.atomicGroupInvalid(let reason) { print("Atomic group rolled back: \(reason)") }}Kotlin example:
import com.trainstar.synchro.SynchroClientimport com.trainstar.synchro.SynchroError
fun publishReview(client: SynchroClient, noteID: String, reviewID: String) { try { client.atomicWriteTransaction { transaction -> transaction.execute( "UPDATE notes SET body = ? WHERE id = ?", arrayOf("Reviewed", noteID), ) transaction.execute( "INSERT INTO notes (id, owner_id, body) VALUES (?, ?, ?)", arrayOf(reviewID, "alice", "Review of $noteID"), ) } } catch (failure: SynchroError.AtomicGroupInvalid) { println("Atomic group rolled back: ${failure.reason}") }}Commit-time validation
Section titled “Commit-time validation”The SDK validates the group before the local commit. It applies these rules in this order:
- No
deleteentry for a row is followed by a later entry for the same row in the group. - The SDK normalizes the change chain of each row. It merges only entries of the same group.
- The normalized group has at most 1000 mutations.
- Each normalized mutation has at most 65,536 octets in canonical form and at most 256 authored columns.
- The worst-case push request body and its RFC 8785 form are each at most 1,048,576 octets.
A failure throws a typed error and rolls back the local transaction. No row change and no queue entry remains.
| Surface | Typed error |
|---|---|
| Swift | SynchroError.atomicGroupInvalid(reason: AtomicGroupInvalidReason) |
| Kotlin | SynchroError.AtomicGroupInvalid(reason: AtomicGroupInvalidReason) |
| React Native | The promise rejects with code atomic_group_invalid |
AtomicGroupInvalidReason has four cases:
| Rule | Swift case | Kotlin case |
|---|---|---|
| 1 | deleteFollowedByWrite |
DELETE_FOLLOWED_BY_WRITE |
| 3 | tooManyMutations |
TOO_MANY_MUTATIONS |
| 4 | mutationTooLarge |
MUTATION_TOO_LARGE |
| 5 | requestTooLarge |
REQUEST_TOO_LARGE |
Rule 2 has no failure case. The worst-case measure uses the maximum size of each value that is not known at commit time. The sealed request therefore always fits the push limits.
Failed groups
Section titled “Failed groups”When one mutation in a group does not apply, the server applies no mutation of the group. The SDK uses the ordinary outcome paths and adds no revert path:
- The failing mutation keeps its own status and code.
- A failing conflict follows the ordinary conflict path. The SDK applies
server_rowand then reapplies later local intent for that row. - Every other member gets
atomic_batch_rejected. Its local row stays, andinspectRejectedMutationsreports the mutation.
After a failed group with a conflict, local state holds the server row for the conflicting member. It holds local values for every other member. The SDK does not revert those local values. Use inspectRejectedMutations to find them and write new intent.
Server version requirement
Section titled “Server version requirement”The atomic API requires the Synchro 0.4.0 extension and adapter:
- Install the
0.4.0extension and adapter on the server. - Release the client code that calls
atomicWriteTransaction.
A 0.4.0 client that never calls the atomic API works with a 0.3.x server. A 0.3.x server answers an atomic batch with 400 invalid_request. The client keeps the sealed batch and reports the failure. It does not lose data.
React Native lossless SQLite values
Section titled “React Native lossless SQLite values”JavaScript numbers cannot represent every SQLite INTEGER value. JavaScript
also has no native SQLite BLOB type. Use tagged bind values for those cases:
const maximumInt64 = { type: 'int64', value: '9223372036854775807' } as const;const binaryValue = { type: 'bytes', base64: 'AP8' } as const;
const rows = await client.query( 'SELECT ? AS sequence, ? AS payload', [maximumInt64, binaryValue]);Queries return integers outside JavaScript’s safe range with the int64 tag.
BLOB values use the bytes tag. Integers within the safe range return as numbers.
int64.value is a canonical signed decimal string in the inclusive int64
range. bytes.base64 is unpadded canonical base64url. Do not replace either
tag with a JavaScript number, a Buffer, or an ordinary object. null,
string, boolean, safe integer JavaScript numbers, and finite non-integer
JavaScript numbers remain direct bind values. Use the int64 tag for every
integer outside the JavaScript safe-integer range.
Source evidence
Section titled “Source evidence”- Kotlin statement parsing:
clients/kotlin/synchro/src/main/kotlin/com/trainstar/synchro/ApplicationSql.kt:172-237,290-324 - Kotlin managed application transaction:
clients/kotlin/synchro/src/main/kotlin/com/trainstar/synchro/SynchroDatabase.kt:793-835,1110-1154 - Swift application authorizer and transaction surface:
clients/swift/Sources/Synchro/ApplicationDatabase.swift:8-85,87-325 - Swift managed application writes and capture context:
clients/swift/Sources/Synchro/ApplicationDatabase.swift:358-513andclients/swift/Sources/Synchro/Database.swift:120-125,194-235 - Swift synced-table capture triggers:
clients/swift/Sources/Synchro/SQLiteSchema.swift:167-217,255-370 - React Native client boundary:
docs/src/content/docs/clients/overview.mdx:28-39 - Atomic write group contract:
docs/src/content/docs/spec/02-client-contract.mdx:325-406 - Atomic batch wire contract:
docs/src/content/docs/spec/01-wire-protocol.mdx:705-719