Skip to content

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.

Use one statement per query, queryOne, execute, or transaction operation.

  • Reads use SELECT, including read-only common table expressions.
  • Swift query and queryOne accept only read-only statements. In a write transaction, the SDK checks this before SQLite runs the statement. A write with RETURNING fails this check.
  • Writes use ordinary INSERT, UPDATE, and DELETE statements.
  • 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.

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.

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.

The Swift SDK selects captured fields from the statement and the capture context:

  • An ordinary INSERT captures the writable columns that the statement names. Without a column list, it captures all writable columns.
  • An ordinary UPDATE captures each writable field whose value changes. This includes a change that a trigger on a local table makes to a synced row.
  • authoredWriteTransaction keeps its named-column mask for the complete transaction. An UPDATE captures only the changed fields that the mask names for its table.
  • While that mask is active, an UPDATE of 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.

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.SynchroClient
import 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}")
}
}

The SDK validates the group before the local commit. It applies these rules in this order:

  1. No delete entry for a row is followed by a later entry for the same row in the group.
  2. The SDK normalizes the change chain of each row. It merges only entries of the same group.
  3. The normalized group has at most 1000 mutations.
  4. Each normalized mutation has at most 65,536 octets in canonical form and at most 256 authored columns.
  5. 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.

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_row and then reapplies later local intent for that row.
  • Every other member gets atomic_batch_rejected. Its local row stays, and inspectRejectedMutations reports 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.

The atomic API requires the Synchro 0.4.0 extension and adapter:

  1. Install the 0.4.0 extension and adapter on the server.
  2. 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.

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.

  • 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-513 and clients/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