From 69f762c4107a9202d1eb637c5824b742cc1817cd Mon Sep 17 00:00:00 2001 From: Gary Bezruchko Date: Sun, 11 Oct 2026 22:04:50 +0200 Subject: [PATCH] Return each stored document's blob references from SyncQueries --- docs/files-guide.md | 5 + docs/server-guide.md | 10 +- docs/sync-files-design.md | 13 +- docs/sync-server-design.md | 3 + .../reflector/sync/server/StoredDocument.kt | 8 + .../voir/reflector/sync/server/SyncQueries.kt | 7 +- .../sync/server/postgres/BlobReferences.kt | 41 +++- .../server/postgres/PostgresSyncQueries.kt | 45 +++-- .../sync/server/postgres/DocumentBlobsTest.kt | 182 ++++++++++++++++++ .../sync/server/postgres/PostgresFixture.kt | 47 ++++- 10 files changed, 341 insertions(+), 20 deletions(-) create mode 100644 server-sdk/src/test/kotlin/dev/voir/reflector/sync/server/postgres/DocumentBlobsTest.kt diff --git a/docs/files-guide.md b/docs/files-guide.md index 48f243b..cdf6155 100644 --- a/docs/files-guide.md +++ b/docs/files-guide.md @@ -100,6 +100,11 @@ a client older than files says nothing and is respected, but an application that forgets to answer for one entity type says "references nothing", and from the server the two are identical. +A document read through `module.queries` carries the files it references in `StoredDocument.blobs`. +A host that copies documents out and pushes them back later — a backup and its restore — sends that +list as the `blobs` of each upsert. The files have to be registered in the collection before the push +that names them, or the group is refused with `BLOB_MISSING`. + ## Client Three things: say where the bytes live, say which files a document points at, and hand the engine a diff --git a/docs/server-guide.md b/docs/server-guide.md index a1226a6..a0716bd 100644 --- a/docs/server-guide.md +++ b/docs/server-guide.md @@ -293,7 +293,15 @@ public interface SyncQueries { ``` This is the read side for host screens and reports. It reflects the same rows the clients -synchronise, without going through the protocol. +synchronise, without going through the protocol. Deleted entities are not returned. + +Each `StoredDocument` carries, besides the body, the `blobs` it currently references, ordered by +identifier — the set the clients declared, which the module stores and never derives from the body. +A page reads them for all of its documents in one extra statement, inside the same transaction as +the bodies. If you copy documents out — a backup, an export you will import again — keep that list +with each one and send it back as the `blobs` of the `PushOperation.Upsert` that restores it: a +document pushed without it references nothing, and its files are collected once the retention window +passes. ## Erasing a scope diff --git a/docs/sync-files-design.md b/docs/sync-files-design.md index 2fb13ac..3241ad4 100644 --- a/docs/sync-files-design.md +++ b/docs/sync-files-design.md @@ -1196,6 +1196,14 @@ cursor, because every page of one snapshot has to describe the same moment; this promise — it is an administrative view of a table that is still being written — so the position is the whole of it. +`StoredDocument`, from `document` and `documents`, carries `blobs`: the entity's current rows in +`sync.blob_refs`, ordered by blob identifier, empty when there are none. A page reads them for all of +its documents in one statement, filtered by collection, entity type and the page's identifiers — +the leading columns of `pk_blob_refs` — inside the transaction that reads the bodies. The host needs +them because references are a declaration rather than something derivable from the body: a host +that copies documents out and pushes them back has to declare them again, or the restored documents +reference nothing and their files are collected after the retention window. + ## 13. Where the tests are The mechanism here has three seams that nothing above them can check, and each has a suite of its @@ -1203,8 +1211,9 @@ own rather than being covered incidentally. `server-sdk` tests the module against real PostgreSQL and a fake bucket: the schema and its cascades (`BlobSchemaTest`), registration, verification and the once-per-file acknowledgement -(`BlobServiceTest`), reference bookkeeping inside the counter lock (`BlobReferenceTest`), and the -collector, the promotion sweep and purge (`BlobMaintenanceTest`). +(`BlobServiceTest`), reference bookkeeping inside the counter lock (`BlobReferenceTest`), the +references the host reads back with each document, and the statement count of a page +(`DocumentBlobsTest`), and the collector, the promotion sweep and purge (`BlobMaintenanceTest`). `client-sdk` tests the two halves that are invisible from outside: the queries that decide what is work (`SyncBlobDaoTest`), reconciliation, which produces only rows (`BlobReconciliationTest`), and diff --git a/docs/sync-server-design.md b/docs/sync-server-design.md index 134833e..5c8ce4a 100644 --- a/docs/sync-server-design.md +++ b/docs/sync-server-design.md @@ -106,6 +106,9 @@ interface SyncQueries { } ``` +A `StoredDocument` is the body, its version and when it was last written, and the blobs it currently +references (see [sync-files-design.md §12.1](sync-files-design.md#121-what-the-host-can-read-back)). + And the removals the host schedules or orders, which are not protocol operations at all: ```kotlin diff --git a/server-sdk/src/main/kotlin/dev/voir/reflector/sync/server/StoredDocument.kt b/server-sdk/src/main/kotlin/dev/voir/reflector/sync/server/StoredDocument.kt index 71d708a..6be13d9 100644 --- a/server-sdk/src/main/kotlin/dev/voir/reflector/sync/server/StoredDocument.kt +++ b/server-sdk/src/main/kotlin/dev/voir/reflector/sync/server/StoredDocument.kt @@ -1,5 +1,6 @@ package dev.voir.reflector.sync.server +import dev.voir.reflector.sync.protocol.BlobId import dev.voir.reflector.sync.protocol.EntityId import dev.voir.reflector.sync.protocol.EntityType import dev.voir.reflector.sync.protocol.EntityVersion @@ -16,6 +17,12 @@ import kotlin.time.Instant * @property data Stored document. The module treats it as opaque JSON: it stores bodies but models * nothing about them. * @property updatedAt When the document was last written. + * @property blobs Blobs the document currently references, ordered by identifier; empty when it + * references none. This is the set the last write that declared one left behind — an upsert that + * declared nothing keeps the previous set — because the module never derives references from the + * document itself. A host that copies documents elsewhere and later pushes them back has to + * declare this set with them: a document re-created without a declaration references nothing, + * and the files it names are collected once the retention window passes. */ public data class StoredDocument( public val entityType: EntityType, @@ -23,4 +30,5 @@ public data class StoredDocument( public val version: EntityVersion, public val data: JsonObject, public val updatedAt: Instant, + public val blobs: List = emptyList(), ) diff --git a/server-sdk/src/main/kotlin/dev/voir/reflector/sync/server/SyncQueries.kt b/server-sdk/src/main/kotlin/dev/voir/reflector/sync/server/SyncQueries.kt index 8c4ac70..6191677 100644 --- a/server-sdk/src/main/kotlin/dev/voir/reflector/sync/server/SyncQueries.kt +++ b/server-sdk/src/main/kotlin/dev/voir/reflector/sync/server/SyncQueries.kt @@ -16,7 +16,7 @@ import dev.voir.reflector.sync.protocol.ScopeId */ public interface SyncQueries { /** - * Reads one stored document. + * Reads one stored document, together with the blobs it currently references. * * @param scope Authorised scope the collection belongs to. * @param collection Collection the entity belongs to. @@ -32,7 +32,10 @@ public interface SyncQueries { ): StoredDocument? /** - * Reads a page of stored documents of one type. + * Reads a page of stored documents of one type, each with the blobs it currently references. + * + * Every document of the page and its references are read in one transaction, so a document's + * [StoredDocument.blobs] describe the same moment as its body. * * @param scope Authorised scope the collection belongs to. * @param collection Collection to read. diff --git a/server-sdk/src/main/kotlin/dev/voir/reflector/sync/server/postgres/BlobReferences.kt b/server-sdk/src/main/kotlin/dev/voir/reflector/sync/server/postgres/BlobReferences.kt index 9997a20..5b10467 100644 --- a/server-sdk/src/main/kotlin/dev/voir/reflector/sync/server/postgres/BlobReferences.kt +++ b/server-sdk/src/main/kotlin/dev/voir/reflector/sync/server/postgres/BlobReferences.kt @@ -1,6 +1,9 @@ package dev.voir.reflector.sync.server.postgres import dev.voir.reflector.sync.protocol.BlobId +import dev.voir.reflector.sync.protocol.EntityId +import dev.voir.reflector.sync.protocol.EntityType +import org.jetbrains.exposed.v1.core.SortOrder import org.jetbrains.exposed.v1.core.and import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.core.exists @@ -17,7 +20,8 @@ import kotlin.time.Instant import kotlin.uuid.Uuid /** - * What each entity's document points at, maintained inside the transaction that writes the batch. + * What each entity's document points at, maintained inside the transaction that writes the batch + * and read back for the host through [SyncQueries][dev.voir.reflector.sync.server.SyncQueries]. * * References are state-based like everything else in this protocol: a client sends the whole set it * declares, and that set replaces what was stored. There is no "reference added" or "reference @@ -60,6 +64,41 @@ internal class BlobReferences { return declared - known } + /** + * Reads the blobs each of the given entities of one type currently points at. + * + * One statement for the whole set, so that a page of documents costs one extra query rather + * than one per document. It is served by the primary key, whose leading columns are exactly the + * filter and whose trailing ones are the order. + * + * @param collectionRowId Row identifier of the collection. + * @param entityType Type every one of [entityIds] has. + * @param entityIds Entities to read the references of. + * @return Each entity's blobs ordered by identifier; an entity that points at nothing is absent. + */ + fun of( + collectionRowId: Uuid, + entityType: EntityType, + entityIds: Collection, + ): Map> { + if (entityIds.isEmpty()) { + return emptyMap() + } + return BlobRefsTable + .select(BlobRefsTable.entityId, BlobRefsTable.blobId) + .where { + (BlobRefsTable.collection eq collectionRowId) and + (BlobRefsTable.entityType eq entityType.value) and + (BlobRefsTable.entityId inList entityIds.map { it.value }) + }.orderBy(BlobRefsTable.entityId to SortOrder.ASC, BlobRefsTable.blobId to SortOrder.ASC) + // Materialised first: `Query` has a `groupBy` of its own, which is SQL's. + .toList() + .groupBy( + keySelector = { EntityId(it[BlobRefsTable.entityId]) }, + valueTransform = { BlobId(it[BlobRefsTable.blobId]) }, + ) + } + /** * Replaces the references of the entities a batch wrote, and settles what became garbage. * diff --git a/server-sdk/src/main/kotlin/dev/voir/reflector/sync/server/postgres/PostgresSyncQueries.kt b/server-sdk/src/main/kotlin/dev/voir/reflector/sync/server/postgres/PostgresSyncQueries.kt index 5d0bbcc..e66c2e5 100644 --- a/server-sdk/src/main/kotlin/dev/voir/reflector/sync/server/postgres/PostgresSyncQueries.kt +++ b/server-sdk/src/main/kotlin/dev/voir/reflector/sync/server/postgres/PostgresSyncQueries.kt @@ -12,6 +12,7 @@ import dev.voir.reflector.sync.server.StoredBlob import dev.voir.reflector.sync.server.StoredDocument import dev.voir.reflector.sync.server.SyncConfig import dev.voir.reflector.sync.server.SyncQueries +import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.SortOrder import org.jetbrains.exposed.v1.core.and import org.jetbrains.exposed.v1.core.eq @@ -38,9 +39,15 @@ internal class PostgresSyncQueries( private val config: SyncConfig, private val clock: Clock, ) : SyncQueries { + /** Resolves a scope's collection to its row; a read never creates one. */ private val collections = CollectionRows(config, clock) + + /** Reads what the module knows about files. */ private val blobs = BlobRows() + /** Reads which files each document points at. */ + private val references = BlobReferences() + override fun document( scope: ScopeId, collection: CollectionId, @@ -49,15 +56,16 @@ internal class PostgresSyncQueries( ): StoredDocument? = transaction(database) { val row = collections.find(scope, collection) ?: return@transaction null - EntitiesTable - .selectAll() - .where { - (EntitiesTable.collection eq row.id) and - (EntitiesTable.entityType eq type.value) and - (EntitiesTable.entityId eq id.value) and - (EntitiesTable.isDeleted eq false) - }.singleOrNull() - ?.let(::toDocument) + val entity = + EntitiesTable + .selectAll() + .where { + (EntitiesTable.collection eq row.id) and + (EntitiesTable.entityType eq type.value) and + (EntitiesTable.entityId eq id.value) and + (EntitiesTable.isDeleted eq false) + }.singleOrNull() ?: return@transaction null + toDocument(entity, references.of(row.id, type, listOf(id))[id].orEmpty()) } override fun documents( @@ -89,7 +97,11 @@ internal class PostgresSyncQueries( .toList() val hasMore = rows.size > capped - val documents = rows.take(capped).map(::toDocument) + val found = rows.take(capped) + // One read for the references of the whole page, in the same transaction, so that each + // document's set belongs to the same moment as its body. + val blobsOf = references.of(row.id, type, found.map { EntityId(it[EntitiesTable.entityId]) }) + val documents = found.map { toDocument(it, blobsOf[EntityId(it[EntitiesTable.entityId])].orEmpty()) } DocumentPage( documents = documents, nextPage = @@ -144,12 +156,23 @@ internal class PostgresSyncQueries( ) } - private fun toDocument(row: org.jetbrains.exposed.v1.core.ResultRow): StoredDocument = + /** + * Maps a live entity row to what the host is handed. + * + * @param row Row of [EntitiesTable] for an entity that is not deleted. + * @param blobs Blobs the entity currently references, already ordered by identifier. + * @return Stored document. + */ + private fun toDocument( + row: ResultRow, + blobs: List, + ): StoredDocument = StoredDocument( entityType = EntityType(row[EntitiesTable.entityType]), entityId = EntityId(row[EntitiesTable.entityId]), version = SyncSequences.version(row[EntitiesTable.version]), data = checkNotNull(row[EntitiesTable.data]) { "a live entity must have a document" }, updatedAt = row[EntitiesTable.updatedAt], + blobs = blobs, ) } diff --git a/server-sdk/src/test/kotlin/dev/voir/reflector/sync/server/postgres/DocumentBlobsTest.kt b/server-sdk/src/test/kotlin/dev/voir/reflector/sync/server/postgres/DocumentBlobsTest.kt new file mode 100644 index 0000000..6ad9e57 --- /dev/null +++ b/server-sdk/src/test/kotlin/dev/voir/reflector/sync/server/postgres/DocumentBlobsTest.kt @@ -0,0 +1,182 @@ +package dev.voir.reflector.sync.server.postgres + +import dev.voir.reflector.sync.protocol.BlobId +import dev.voir.reflector.sync.protocol.ClientId +import dev.voir.reflector.sync.protocol.EntityId +import dev.voir.reflector.sync.protocol.EntityVersion +import dev.voir.reflector.sync.protocol.GroupId +import dev.voir.reflector.sync.protocol.blob.BlobContentType +import dev.voir.reflector.sync.protocol.blob.BlobDescriptor +import dev.voir.reflector.sync.protocol.push.PushGroup +import dev.voir.reflector.sync.protocol.push.PushGroupResult +import dev.voir.reflector.sync.protocol.push.PushOperation +import dev.voir.reflector.sync.protocol.push.PushRequest +import dev.voir.reflector.sync.server.BlobConfig +import dev.voir.reflector.sync.server.DocumentPage +import dev.voir.reflector.sync.server.StoredDocument +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.jetbrains.exposed.v1.jdbc.Database +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Instant +import kotlin.uuid.Uuid + +/** + * What the host reads back about the files a stored document references. + * + * The case this exists for is a host that copies documents out — a backup — and pushes them back + * later: the references are the client's declaration and cannot be derived from the body, so unless + * the read hands them over, a restored document would reference nothing and its files would be + * collected. Both reads are covered, since a backup pages through `documents` while a screen that + * shows one record calls `document`. + */ +class DocumentBlobsTest { + private val statements = AtomicInteger() + private val storage = FakeBlobStorage() + private val module = module(PostgresFixture.database) + private val scope = PostgresFixture.scope + private val ledger = PostgresFixture.ledger + private val wallet = PostgresFixture.wallet + private val client = ClientId(Uuid.parse("00000000-0000-7000-8000-0000000000c1")) + + // Fixed identifiers whose order is known, so that the order of what is read back is asserted + // rather than assumed: wherever both are declared, they are declared out of order. + private val first = BlobId(Uuid.parse("00000000-0000-7000-8000-b00000000001")) + private val second = BlobId(Uuid.parse("00000000-0000-7000-8000-b00000000002")) + + @BeforeTest + fun clean() { + PostgresFixture.reset() + } + + @Test + fun `a document reads back the blobs it was pushed with, ordered by identifier`() { + register(listOf(first, second)) + + push(upsert(1, blobs = listOf(second, first))) + + assertEquals(listOf(first, second), document(1).blobs) + assertEquals(listOf(first, second), page().single().blobs) + } + + @Test + fun `a later declaration replaces what is read back`() { + register(listOf(first, second)) + push(upsert(1, blobs = listOf(second, first))) + + push(upsert(1, base = EntityVersion("1"), blobs = listOf(first))) + + assertEquals(listOf(first), document(1).blobs) + assertEquals(listOf(first), page().single().blobs) + } + + @Test + fun `an upsert that declares nothing keeps the references already stored`() { + register(listOf(first, second)) + push(upsert(1, blobs = listOf(second, first))) + + push(upsert(1, base = EntityVersion("1"), blobs = null, data = body("Card"))) + + val stored = document(1) + assertEquals(body("Card"), stored.data, "the write itself landed") + assertEquals(listOf(first, second), stored.blobs) + assertEquals(listOf(first, second), page().single().blobs) + } + + @Test + fun `a document that references nothing reads back empty`() { + register(listOf(first)) + push(upsert(1, blobs = null), upsert(2, blobs = emptyList()), upsert(3, blobs = listOf(first))) + + assertEquals(emptyList(), document(1).blobs) + assertEquals(emptyList(), document(2).blobs) + assertEquals( + listOf(emptyList(), emptyList(), listOf(first)), + page().map { it.blobs }, + "each document of a page gets its own references and nobody else's", + ) + } + + @Test + fun `a page reads the references of all its documents in one statement`() { + val counted = module(PostgresFixture.countingDatabase(statements)) + register(listOf(first, second)) + push( + upsert(1, blobs = listOf(first)), + upsert(2, blobs = listOf(second)), + upsert(3, blobs = listOf(first, second)), + upsert(4, blobs = listOf(second)), + ) + + // A first read on a fresh connection may ask the database about itself; that is not what + // this test is about, so it is paid before anything is counted. + counted.queries.documents(scope, ledger, wallet, page = null, limit = 1) + + val (one, forOne) = statementsOf { counted.queries.documents(scope, ledger, wallet, page = null, limit = 1) } + val (four, forFour) = statementsOf { counted.queries.documents(scope, ledger, wallet, page = null, limit = 4) } + + assertEquals(1, one.documents.size) + assertEquals(listOf(listOf(first), listOf(second), listOf(first, second), listOf(second)), four.documents.map { it.blobs }) + assertTrue(forOne > 0, "the counter has to see the reads at all for the comparison to mean anything") + assertEquals(forOne, forFour, "a page of four documents must cost what a page of one does") + } + + private fun module(database: Database) = + PostgresFixture.module( + database = database, + clock = MutableClock(Instant.fromEpochMilliseconds(1_700_000_000_000)), + blobs = BlobConfig(), + blobStorage = storage, + ) + + private fun statementsOf(read: () -> DocumentPage): Pair { + val before = statements.get() + val result = read() + return result to statements.get() - before + } + + private fun register(ids: List) { + val service = assertNotNull(module.blobs) + ids.forEach { id -> + service.register(scope, ledger, BlobDescriptor(id, BlobContentType("image/jpeg"), SIZE)) + storage.put(storage.keyOf(scope, ledger, id), SIZE) + service.markUploaded(scope, ledger, id) + } + } + + private fun document(index: Int): StoredDocument = + assertNotNull(module.queries.document(scope, ledger, wallet, entity(index))) + + private fun page(): List = + module.queries + .documents(scope, ledger, wallet, page = null, limit = 100) + .documents + + private fun push(vararg ops: PushOperation) { + val result = + module.service.push(scope, ledger, PushRequest(client, listOf(PushGroup(GroupId(Uuid.random()), ops.toList())))) + assertIs(result.results.single()) + } + + private fun upsert( + index: Int, + base: EntityVersion? = null, + blobs: List?, + data: JsonObject = body("Cash"), + ) = PushOperation.Upsert(wallet, entity(index), base, data, blobs) + + private fun body(title: String) = buildJsonObject { put("title", title) } + + private fun entity(index: Int) = EntityId(Uuid.parse("00000000-0000-7000-8000-1%011d".format(index))) + + private companion object { + const val SIZE = 2_418_123L + } +} diff --git a/server-sdk/src/test/kotlin/dev/voir/reflector/sync/server/postgres/PostgresFixture.kt b/server-sdk/src/test/kotlin/dev/voir/reflector/sync/server/postgres/PostgresFixture.kt index adfa979..91d7b4f 100644 --- a/server-sdk/src/test/kotlin/dev/voir/reflector/sync/server/postgres/PostgresFixture.kt +++ b/server-sdk/src/test/kotlin/dev/voir/reflector/sync/server/postgres/PostgresFixture.kt @@ -18,6 +18,10 @@ import dev.voir.reflector.sync.server.syncConfig import org.jetbrains.exposed.v1.jdbc.Database import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.testcontainers.postgresql.PostgreSQLContainer +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Proxy +import java.sql.Connection +import java.util.concurrent.atomic.AtomicInteger import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.days @@ -48,12 +52,15 @@ object PostgresFixture { }, ) - /** Database the module writes to; migrations have already been applied. */ - val database: Database by lazy { + /** The connection pool, handed out only once the module's migrations have been applied. */ + private val migrated: HikariDataSource by lazy { SyncMigrations.migrate(dataSource) - Database.connect(dataSource) + dataSource } + /** Database the module writes to; migrations have already been applied. */ + val database: Database by lazy { Database.connect(migrated) } + /** Scope every test writes to. */ val scope: ScopeId = ScopeId("user-1") @@ -66,6 +73,35 @@ object PostgresFixture { /** Entity type the test collection accepts. */ val wallet: EntityType = EntityType("wallet") + /** + * The shared database, reached through connections that count every statement they are asked for. + * + * The module opens its own transactions, so the connection is the one place a test can stand to + * see how many statements an operation costs — which is what guards a read against turning into + * one query per row. + * + * @param counter Incremented once for every statement prepared or created. + * @return Database over the same container, with migrations already applied. + */ + fun countingDatabase(counter: AtomicInteger): Database = + Database.connect(getNewConnection = { counting(migrated.connection, counter) }) + + /** Wraps [connection] so that every statement it hands out is counted, and nothing else changes. */ + private fun counting( + connection: Connection, + counter: AtomicInteger, + ): Connection = + Proxy.newProxyInstance(Connection::class.java.classLoader, arrayOf(Connection::class.java)) { _, method, args -> + if (method.name in STATEMENT_FACTORIES) { + counter.incrementAndGet() + } + try { + method.invoke(connection, *args.orEmpty()) + } catch (e: InvocationTargetException) { + throw e.targetException + } + } as Connection + /** * Builds a module over the shared database. * @@ -79,6 +115,7 @@ object PostgresFixture { * @param blobs Configuration of files, or `null` to assemble a module that serves none. * @param blobStorage The host's object storage; required exactly when [blobs] is given. * @param blobListeners Told when a blob's bytes are accepted. + * @param database Database the module writes to; the shared one unless a test needs to watch it. * @return Freshly assembled module. */ fun module( @@ -92,6 +129,7 @@ object PostgresFixture { blobs: BlobConfig? = null, blobStorage: BlobStorage? = null, blobListeners: List = emptyList(), + database: Database = this.database, ): SyncModule = SyncModule.create( database = database, @@ -127,6 +165,9 @@ object PostgresFixture { private const val POOL_SIZE = 8 + /** The methods of [Connection] that hand out a statement. */ + private val STATEMENT_FACTORIES = setOf("prepareStatement", "createStatement", "prepareCall") + /** Small enough that a test can exceed it on purpose. */ const val MAX_DOCUMENT_BYTES: Int = 4096 }