diff --git a/bramble-core/src/main/java/org/briarproject/bramble/db/Database.java b/bramble-core/src/main/java/org/briarproject/bramble/db/Database.java
index 4cf6043d4d49092a49c270edda86a7f8fa34b272..9755305418e190fc05795bf8ad45c872b1b3b335 100644
--- a/bramble-core/src/main/java/org/briarproject/bramble/db/Database.java
+++ b/bramble-core/src/main/java/org/briarproject/bramble/db/Database.java
@@ -651,6 +651,13 @@ interface Database<T> {
 	 * Updates the transmission count and the lastSentTme of the given message
 	 * with respect to the given contact.
 	 */
+	void updateLastSentTime(T txn, ContactId c, MessageId m, int maxLatency,
+			long time)
+			throws DbException;
+
+	void updateLastSentTime(T txn, ContactId c, MessageId m, int maxLatency)
+			throws DbException;
+
 	void updateLastSentTime(T txn, ContactId c, MessageId m)
 			throws DbException;
 
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/db/H2DatabaseB.java b/bramble-core/src/main/java/org/briarproject/bramble/db/H2DatabaseB.java
new file mode 100644
index 0000000000000000000000000000000000000000..53c429dffd2e40b39abfb15518385614b6daf635
--- /dev/null
+++ b/bramble-core/src/main/java/org/briarproject/bramble/db/H2DatabaseB.java
@@ -0,0 +1,107 @@
+package org.briarproject.bramble.db;
+
+import org.briarproject.bramble.api.crypto.SecretKey;
+import org.briarproject.bramble.api.db.DatabaseConfig;
+import org.briarproject.bramble.api.db.DbException;
+import org.briarproject.bramble.api.db.MigrationListener;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.system.Clock;
+import org.briarproject.bramble.util.StringUtils;
+
+import java.io.File;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.SQLException;
+import java.util.Properties;
+
+import javax.annotation.Nullable;
+import javax.inject.Inject;
+
+/**
+ * Contains all the H2-specific code for the database.
+ */
+@NotNullByDefault
+class H2DatabaseB extends JdbcDatabaseOrig {
+
+	private static final String HASH_TYPE = "BINARY(32)";
+	private static final String SECRET_TYPE = "BINARY(32)";
+	private static final String BINARY_TYPE = "BINARY";
+	private static final String COUNTER_TYPE = "INT NOT NULL AUTO_INCREMENT";
+	private static final String STRING_TYPE = "VARCHAR";
+
+	private final DatabaseConfig config;
+	private final String url;
+
+	@Nullable
+	private volatile SecretKey key = null;
+
+	@Inject
+	H2DatabaseB(DatabaseConfig config, Clock clock) {
+		super(HASH_TYPE, SECRET_TYPE, BINARY_TYPE, COUNTER_TYPE, STRING_TYPE,
+				clock);
+		this.config = config;
+		File dir = config.getDatabaseDirectory();
+		String path = new File(dir, "db").getAbsolutePath();
+		url = "jdbc:h2:split:" + path + ";CIPHER=AES;MULTI_THREADED=1"
+				+ ";WRITE_DELAY=0";
+	}
+
+	@Override
+	public boolean open(SecretKey key, @Nullable MigrationListener listener)
+			throws DbException {
+		this.key = key;
+		boolean reopen = !config.getDatabaseDirectory().mkdirs();
+		super.open("org.h2.Driver", reopen, key, listener);
+		return reopen;
+	}
+
+	@Override
+	public void close() throws DbException {
+		// H2 will close the database when the last connection closes
+		try {
+			super.closeAllConnections();
+		} catch (SQLException e) {
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public long getFreeSpace() {
+		File dir = config.getDatabaseDirectory();
+		long maxSize = config.getMaxSize();
+		long free = dir.getFreeSpace();
+		long used = getDiskSpace(dir);
+		long quota = maxSize - used;
+		return Math.min(free, quota);
+	}
+
+	private long getDiskSpace(File f) {
+		if (f.isDirectory()) {
+			long total = 0;
+			File[] children = f.listFiles();
+			if (children != null)
+				for (File child : children) total += getDiskSpace(child);
+			return total;
+		} else if (f.isFile()) {
+			return f.length();
+		} else {
+			return 0;
+		}
+	}
+
+	@Override
+	protected Connection createConnection() throws SQLException {
+		SecretKey key = this.key;
+		if (key == null) throw new IllegalStateException();
+		Properties props = new Properties();
+		props.setProperty("user", "user");
+		// Separate the file password from the user password with a space
+		String hex = StringUtils.toHexString(key.getBytes());
+		props.put("password", hex + " password");
+		return DriverManager.getConnection(getUrl(), props);
+	}
+
+	String getUrl() {
+		return url;
+	}
+}
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/db/H2DatabaseC.java b/bramble-core/src/main/java/org/briarproject/bramble/db/H2DatabaseC.java
new file mode 100644
index 0000000000000000000000000000000000000000..d55bcc93d02e8d68cb7ff6ec9a3f793bd5177192
--- /dev/null
+++ b/bramble-core/src/main/java/org/briarproject/bramble/db/H2DatabaseC.java
@@ -0,0 +1,107 @@
+package org.briarproject.bramble.db;
+
+import org.briarproject.bramble.api.crypto.SecretKey;
+import org.briarproject.bramble.api.db.DatabaseConfig;
+import org.briarproject.bramble.api.db.DbException;
+import org.briarproject.bramble.api.db.MigrationListener;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.system.Clock;
+import org.briarproject.bramble.util.StringUtils;
+
+import java.io.File;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.SQLException;
+import java.util.Properties;
+
+import javax.annotation.Nullable;
+import javax.inject.Inject;
+
+/**
+ * Contains all the H2-specific code for the database.
+ */
+@NotNullByDefault
+class H2DatabaseC extends JdbcDatabaseUnion {
+
+	private static final String HASH_TYPE = "BINARY(32)";
+	private static final String SECRET_TYPE = "BINARY(32)";
+	private static final String BINARY_TYPE = "BINARY";
+	private static final String COUNTER_TYPE = "INT NOT NULL AUTO_INCREMENT";
+	private static final String STRING_TYPE = "VARCHAR";
+
+	private final DatabaseConfig config;
+	private final String url;
+
+	@Nullable
+	private volatile SecretKey key = null;
+
+	@Inject
+	H2DatabaseC(DatabaseConfig config, Clock clock) {
+		super(HASH_TYPE, SECRET_TYPE, BINARY_TYPE, COUNTER_TYPE, STRING_TYPE,
+				clock);
+		this.config = config;
+		File dir = config.getDatabaseDirectory();
+		String path = new File(dir, "db").getAbsolutePath();
+		url = "jdbc:h2:split:" + path + ";CIPHER=AES;MULTI_THREADED=1"
+				+ ";WRITE_DELAY=0";
+	}
+
+	@Override
+	public boolean open(SecretKey key, @Nullable MigrationListener listener)
+			throws DbException {
+		this.key = key;
+		boolean reopen = !config.getDatabaseDirectory().mkdirs();
+		super.open("org.h2.Driver", reopen, key, listener);
+		return reopen;
+	}
+
+	@Override
+	public void close() throws DbException {
+		// H2 will close the database when the last connection closes
+		try {
+			super.closeAllConnections();
+		} catch (SQLException e) {
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public long getFreeSpace() {
+		File dir = config.getDatabaseDirectory();
+		long maxSize = config.getMaxSize();
+		long free = dir.getFreeSpace();
+		long used = getDiskSpace(dir);
+		long quota = maxSize - used;
+		return Math.min(free, quota);
+	}
+
+	private long getDiskSpace(File f) {
+		if (f.isDirectory()) {
+			long total = 0;
+			File[] children = f.listFiles();
+			if (children != null)
+				for (File child : children) total += getDiskSpace(child);
+			return total;
+		} else if (f.isFile()) {
+			return f.length();
+		} else {
+			return 0;
+		}
+	}
+
+	@Override
+	protected Connection createConnection() throws SQLException {
+		SecretKey key = this.key;
+		if (key == null) throw new IllegalStateException();
+		Properties props = new Properties();
+		props.setProperty("user", "user");
+		// Separate the file password from the user password with a space
+		String hex = StringUtils.toHexString(key.getBytes());
+		props.put("password", hex + " password");
+		return DriverManager.getConnection(getUrl(), props);
+	}
+
+	String getUrl() {
+		return url;
+	}
+}
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/db/H2DatabaseMin.java b/bramble-core/src/main/java/org/briarproject/bramble/db/H2DatabaseMin.java
new file mode 100644
index 0000000000000000000000000000000000000000..9d069ced8dc2dc14c0bcb494a33dddbac40a0ab6
--- /dev/null
+++ b/bramble-core/src/main/java/org/briarproject/bramble/db/H2DatabaseMin.java
@@ -0,0 +1,107 @@
+package org.briarproject.bramble.db;
+
+import org.briarproject.bramble.api.crypto.SecretKey;
+import org.briarproject.bramble.api.db.DatabaseConfig;
+import org.briarproject.bramble.api.db.DbException;
+import org.briarproject.bramble.api.db.MigrationListener;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.system.Clock;
+import org.briarproject.bramble.util.StringUtils;
+
+import java.io.File;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.SQLException;
+import java.util.Properties;
+
+import javax.annotation.Nullable;
+import javax.inject.Inject;
+
+/**
+ * Contains all the H2-specific code for the database.
+ */
+@NotNullByDefault
+class H2DatabaseMin extends JdbcDatabaseMin {
+
+	private static final String HASH_TYPE = "BINARY(32)";
+	private static final String SECRET_TYPE = "BINARY(32)";
+	private static final String BINARY_TYPE = "BINARY";
+	private static final String COUNTER_TYPE = "INT NOT NULL AUTO_INCREMENT";
+	private static final String STRING_TYPE = "VARCHAR";
+
+	private final DatabaseConfig config;
+	private final String url;
+
+	@Nullable
+	private volatile SecretKey key = null;
+
+	@Inject
+	H2DatabaseMin(DatabaseConfig config, Clock clock) {
+		super(HASH_TYPE, SECRET_TYPE, BINARY_TYPE, COUNTER_TYPE, STRING_TYPE,
+				clock);
+		this.config = config;
+		File dir = config.getDatabaseDirectory();
+		String path = new File(dir, "db").getAbsolutePath();
+		url = "jdbc:h2:split:" + path + ";CIPHER=AES;MULTI_THREADED=1"
+				+ ";WRITE_DELAY=0";
+	}
+
+	@Override
+	public boolean open(SecretKey key, @Nullable MigrationListener listener)
+			throws DbException {
+		this.key = key;
+		boolean reopen = !config.getDatabaseDirectory().mkdirs();
+		super.open("org.h2.Driver", reopen, key, listener);
+		return reopen;
+	}
+
+	@Override
+	public void close() throws DbException {
+		// H2 will close the database when the last connection closes
+		try {
+			super.closeAllConnections();
+		} catch (SQLException e) {
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public long getFreeSpace() {
+		File dir = config.getDatabaseDirectory();
+		long maxSize = config.getMaxSize();
+		long free = dir.getFreeSpace();
+		long used = getDiskSpace(dir);
+		long quota = maxSize - used;
+		return Math.min(free, quota);
+	}
+
+	private long getDiskSpace(File f) {
+		if (f.isDirectory()) {
+			long total = 0;
+			File[] children = f.listFiles();
+			if (children != null)
+				for (File child : children) total += getDiskSpace(child);
+			return total;
+		} else if (f.isFile()) {
+			return f.length();
+		} else {
+			return 0;
+		}
+	}
+
+	@Override
+	protected Connection createConnection() throws SQLException {
+		SecretKey key = this.key;
+		if (key == null) throw new IllegalStateException();
+		Properties props = new Properties();
+		props.setProperty("user", "user");
+		// Separate the file password from the user password with a space
+		String hex = StringUtils.toHexString(key.getBytes());
+		props.put("password", hex + " password");
+		return DriverManager.getConnection(getUrl(), props);
+	}
+
+	String getUrl() {
+		return url;
+	}
+}
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/db/JdbcDatabase.java b/bramble-core/src/main/java/org/briarproject/bramble/db/JdbcDatabase.java
index cef9bb09e313a26021cd2acee90e76a08103a0aa..652bd8fc46d8af30093bb4706c8244fa429d5f39 100644
--- a/bramble-core/src/main/java/org/briarproject/bramble/db/JdbcDatabase.java
+++ b/bramble-core/src/main/java/org/briarproject/bramble/db/JdbcDatabase.java
@@ -65,7 +65,6 @@ import static org.briarproject.bramble.api.sync.ValidationManager.State.PENDING;
 import static org.briarproject.bramble.api.sync.ValidationManager.State.UNKNOWN;
 import static org.briarproject.bramble.db.DatabaseConstants.DB_SETTINGS_NAMESPACE;
 import static org.briarproject.bramble.db.DatabaseConstants.SCHEMA_VERSION_KEY;
-import static org.briarproject.bramble.db.ExponentialBackoff.calculateExpiry;
 import static org.briarproject.bramble.util.LogUtils.logException;
 
 /**
@@ -1998,26 +1997,25 @@ abstract class JdbcDatabase implements Database<Connection> {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
 		try {
-			String sql = "SELECT lastSentTime, txCount FROM statuses"
-					+ " WHERE contactId = ? AND state = ?"
-					+ " AND groupShared = TRUE AND messageShared = TRUE"
-					+ " AND deleted = FALSE AND seen = FALSE"
-					+ " ORDER BY lastSentTime LIMIT 1";
+			String sql =
+					"SELECT (lastSentTime + ? * POWER(2, txCount)) AS expiry FROM statuses"
+							+ " WHERE contactId = ? AND state = ?"
+							+ " AND groupShared = TRUE AND messageShared = TRUE"
+							+ " AND deleted = FALSE AND seen = FALSE"
+							+ " ORDER BY expiry LIMIT 1";
 			ps = txn.prepareStatement(sql);
-			ps.setInt(1, c.getInt());
-			ps.setInt(2, DELIVERED.getValue());
+			ps.setInt(1, maxLatency);
+			ps.setInt(2, c.getInt());
+			ps.setInt(3, DELIVERED.getValue());
 			rs = ps.executeQuery();
 			long lastSentTime = Long.MAX_VALUE;
-			int txCount = Integer.MAX_VALUE;
 			if (rs.next()) {
 				lastSentTime = rs.getLong(1);
-				txCount = rs.getInt(2);
 				if (rs.next()) throw new AssertionError();
 			}
 			rs.close();
 			ps.close();
-			return lastSentTime == 0 ? 0 :
-					calculateExpiry(lastSentTime, maxLatency, txCount);
+			return lastSentTime;
 		} catch (SQLException e) {
 			tryToClose(rs);
 			tryToClose(ps);
@@ -2876,17 +2874,29 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
+	@Override
+	public void updateLastSentTime(Connection txn, ContactId c, MessageId m,
+			int maxLatency) throws DbException {
+		updateLastSentTime(txn, c, m);
+	}
+
 	@Override
 	public void updateLastSentTime(Connection txn, ContactId c, MessageId m)
 			throws DbException {
+		updateLastSentTime(txn, c, m, 0, clock.currentTimeMillis());
+	}
+
+	@Override
+	public void updateLastSentTime(Connection txn, ContactId c, MessageId m,
+			int maxLatency, long time)
+			throws DbException {
 		PreparedStatement ps = null;
 		try {
 			String sql = "UPDATE statuses SET lastSentTime = ?, "
 					+ "txCount = txCount + 1 WHERE messageId = ? "
 					+ "AND contactId = ?";
 			ps = txn.prepareStatement(sql);
-			long now = clock.currentTimeMillis();
-			ps.setLong(1, now);
+			ps.setLong(1, time);
 			ps.setBytes(2, m.getBytes());
 			ps.setInt(3, c.getInt());
 			int affected = ps.executeUpdate();
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/db/JdbcDatabaseMin.java b/bramble-core/src/main/java/org/briarproject/bramble/db/JdbcDatabaseMin.java
new file mode 100644
index 0000000000000000000000000000000000000000..7473aadef3ceb2395060fdc42009b10858635cff
--- /dev/null
+++ b/bramble-core/src/main/java/org/briarproject/bramble/db/JdbcDatabaseMin.java
@@ -0,0 +1,2976 @@
+package org.briarproject.bramble.db;
+
+import org.briarproject.bramble.api.contact.Contact;
+import org.briarproject.bramble.api.contact.ContactId;
+import org.briarproject.bramble.api.crypto.SecretKey;
+import org.briarproject.bramble.api.db.DataTooNewException;
+import org.briarproject.bramble.api.db.DataTooOldException;
+import org.briarproject.bramble.api.db.DbClosedException;
+import org.briarproject.bramble.api.db.DbException;
+import org.briarproject.bramble.api.db.Metadata;
+import org.briarproject.bramble.api.db.MigrationListener;
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.api.identity.AuthorId;
+import org.briarproject.bramble.api.identity.LocalAuthor;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.plugin.TransportId;
+import org.briarproject.bramble.api.settings.Settings;
+import org.briarproject.bramble.api.sync.ClientId;
+import org.briarproject.bramble.api.sync.Group;
+import org.briarproject.bramble.api.sync.Group.Visibility;
+import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.bramble.api.sync.Message;
+import org.briarproject.bramble.api.sync.MessageId;
+import org.briarproject.bramble.api.sync.MessageStatus;
+import org.briarproject.bramble.api.sync.ValidationManager.State;
+import org.briarproject.bramble.api.system.Clock;
+import org.briarproject.bramble.api.transport.IncomingKeys;
+import org.briarproject.bramble.api.transport.KeySet;
+import org.briarproject.bramble.api.transport.KeySetId;
+import org.briarproject.bramble.api.transport.OutgoingKeys;
+import org.briarproject.bramble.api.transport.TransportKeys;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.concurrent.locks.Condition;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.logging.Logger;
+
+import javax.annotation.Nullable;
+
+import static java.sql.Types.INTEGER;
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Level.WARNING;
+import static org.briarproject.bramble.api.db.Metadata.REMOVE;
+import static org.briarproject.bramble.api.sync.Group.Visibility.INVISIBLE;
+import static org.briarproject.bramble.api.sync.Group.Visibility.SHARED;
+import static org.briarproject.bramble.api.sync.Group.Visibility.VISIBLE;
+import static org.briarproject.bramble.api.sync.ValidationManager.State.DELIVERED;
+import static org.briarproject.bramble.api.sync.ValidationManager.State.PENDING;
+import static org.briarproject.bramble.api.sync.ValidationManager.State.UNKNOWN;
+import static org.briarproject.bramble.db.DatabaseConstants.DB_SETTINGS_NAMESPACE;
+import static org.briarproject.bramble.db.DatabaseConstants.SCHEMA_VERSION_KEY;
+import static org.briarproject.bramble.util.LogUtils.logException;
+
+/**
+ * A generic database implementation that can be used with any JDBC-compatible
+ * database library.
+ */
+@NotNullByDefault
+abstract class JdbcDatabaseMin implements Database<Connection> {
+
+	// Package access for testing
+	static final int CODE_SCHEMA_VERSION = 40;
+
+	// Rotation period offsets for incoming transport keys
+	private static final int OFFSET_PREV = -1;
+	private static final int OFFSET_CURR = 0;
+	private static final int OFFSET_NEXT = 1;
+
+	private static final String CREATE_SETTINGS =
+			"CREATE TABLE settings"
+					+ " (namespace _STRING NOT NULL,"
+					+ " settingKey _STRING NOT NULL,"
+					+ " value _STRING NOT NULL,"
+					+ " PRIMARY KEY (namespace, settingKey))";
+
+	private static final String CREATE_LOCAL_AUTHORS =
+			"CREATE TABLE localAuthors"
+					+ " (authorId _HASH NOT NULL,"
+					+ " formatVersion INT NOT NULL,"
+					+ " name _STRING NOT NULL,"
+					+ " publicKey _BINARY NOT NULL,"
+					+ " privateKey _BINARY NOT NULL,"
+					+ " created BIGINT NOT NULL,"
+					+ " PRIMARY KEY (authorId))";
+
+	private static final String CREATE_CONTACTS =
+			"CREATE TABLE contacts"
+					+ " (contactId _COUNTER,"
+					+ " authorId _HASH NOT NULL,"
+					+ " formatVersion INT NOT NULL,"
+					+ " name _STRING NOT NULL,"
+					+ " publicKey _BINARY NOT NULL,"
+					+ " localAuthorId _HASH NOT NULL,"
+					+ " verified BOOLEAN NOT NULL,"
+					+ " active BOOLEAN NOT NULL,"
+					+ " PRIMARY KEY (contactId),"
+					+ " FOREIGN KEY (localAuthorId)"
+					+ " REFERENCES localAuthors (authorId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_GROUPS =
+			"CREATE TABLE groups"
+					+ " (groupId _HASH NOT NULL,"
+					+ " clientId _STRING NOT NULL,"
+					+ " majorVersion INT NOT NULL,"
+					+ " descriptor _BINARY NOT NULL,"
+					+ " PRIMARY KEY (groupId))";
+
+	private static final String CREATE_GROUP_METADATA =
+			"CREATE TABLE groupMetadata"
+					+ " (groupId _HASH NOT NULL,"
+					+ " metaKey _STRING NOT NULL,"
+					+ " value _BINARY NOT NULL,"
+					+ " PRIMARY KEY (groupId, metaKey),"
+					+ " FOREIGN KEY (groupId)"
+					+ " REFERENCES groups (groupId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_GROUP_VISIBILITIES =
+			"CREATE TABLE groupVisibilities"
+					+ " (contactId INT NOT NULL,"
+					+ " groupId _HASH NOT NULL,"
+					+ " shared BOOLEAN NOT NULL,"
+					+ " PRIMARY KEY (contactId, groupId),"
+					+ " FOREIGN KEY (contactId)"
+					+ " REFERENCES contacts (contactId)"
+					+ " ON DELETE CASCADE,"
+					+ " FOREIGN KEY (groupId)"
+					+ " REFERENCES groups (groupId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_MESSAGES =
+			"CREATE TABLE messages"
+					+ " (messageId _HASH NOT NULL,"
+					+ " groupId _HASH NOT NULL,"
+					+ " timestamp BIGINT NOT NULL,"
+					+ " state INT NOT NULL,"
+					+ " shared BOOLEAN NOT NULL,"
+					+ " length INT NOT NULL,"
+					+ " raw BLOB," // Null if message has been deleted
+					+ " PRIMARY KEY (messageId),"
+					+ " FOREIGN KEY (groupId)"
+					+ " REFERENCES groups (groupId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_MESSAGE_METADATA =
+			"CREATE TABLE messageMetadata"
+					+ " (messageId _HASH NOT NULL,"
+					+ " groupId _HASH NOT NULL," // Denormalised
+					+ " state INT NOT NULL," // Denormalised
+					+ " metaKey _STRING NOT NULL,"
+					+ " value _BINARY NOT NULL,"
+					+ " PRIMARY KEY (messageId, metaKey),"
+					+ " FOREIGN KEY (messageId)"
+					+ " REFERENCES messages (messageId)"
+					+ " ON DELETE CASCADE,"
+					+ " FOREIGN KEY (groupId)"
+					+ " REFERENCES groups (groupId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_MESSAGE_DEPENDENCIES =
+			"CREATE TABLE messageDependencies"
+					+ " (groupId _HASH NOT NULL,"
+					+ " messageId _HASH NOT NULL,"
+					+ " dependencyId _HASH NOT NULL," // Not a foreign key
+					+ " messageState INT NOT NULL," // Denormalised
+					// Denormalised, null if dependency is missing or in a
+					// different group
+					+ " dependencyState INT,"
+					+ " FOREIGN KEY (groupId)"
+					+ " REFERENCES groups (groupId)"
+					+ " ON DELETE CASCADE,"
+					+ " FOREIGN KEY (messageId)"
+					+ " REFERENCES messages (messageId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_OFFERS =
+			"CREATE TABLE offers"
+					+ " (messageId _HASH NOT NULL," // Not a foreign key
+					+ " contactId INT NOT NULL,"
+					+ " PRIMARY KEY (messageId, contactId),"
+					+ " FOREIGN KEY (contactId)"
+					+ " REFERENCES contacts (contactId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_STATUSES =
+			"CREATE TABLE statuses"
+					+ " (messageId _HASH NOT NULL,"
+					+ " contactId INT NOT NULL,"
+					+ " groupId _HASH NOT NULL," // Denormalised
+					+ " timestamp BIGINT NOT NULL," // Denormalised
+					+ " length INT NOT NULL," // Denormalised
+					+ " state INT NOT NULL," // Denormalised
+					+ " groupShared BOOLEAN NOT NULL," // Denormalised
+					+ " messageShared BOOLEAN NOT NULL," // Denormalised
+					+ " deleted BOOLEAN NOT NULL," // Denormalised
+					+ " ack BOOLEAN NOT NULL,"
+					+ " seen BOOLEAN NOT NULL,"
+					+ " requested BOOLEAN NOT NULL,"
+					+ " lastSentTime BIGINT NOT NULL,"
+					+ " txCount INT NOT NULL,"
+					+ " PRIMARY KEY (messageId, contactId),"
+					+ " FOREIGN KEY (messageId)"
+					+ " REFERENCES messages (messageId)"
+					+ " ON DELETE CASCADE,"
+					+ " FOREIGN KEY (contactId)"
+					+ " REFERENCES contacts (contactId)"
+					+ " ON DELETE CASCADE,"
+					+ " FOREIGN KEY (groupId)"
+					+ " REFERENCES groups (groupId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_TRANSPORTS =
+			"CREATE TABLE transports"
+					+ " (transportId _STRING NOT NULL,"
+					+ " maxLatency INT NOT NULL,"
+					+ " PRIMARY KEY (transportId))";
+
+	private static final String CREATE_OUTGOING_KEYS =
+			"CREATE TABLE outgoingKeys"
+					+ " (transportId _STRING NOT NULL,"
+					+ " keySetId _COUNTER,"
+					+ " rotationPeriod BIGINT NOT NULL,"
+					+ " contactId INT NOT NULL,"
+					+ " tagKey _SECRET NOT NULL,"
+					+ " headerKey _SECRET NOT NULL,"
+					+ " stream BIGINT NOT NULL,"
+					+ " active BOOLEAN NOT NULL,"
+					+ " PRIMARY KEY (transportId, keySetId),"
+					+ " FOREIGN KEY (transportId)"
+					+ " REFERENCES transports (transportId)"
+					+ " ON DELETE CASCADE,"
+					+ " UNIQUE (keySetId),"
+					+ " FOREIGN KEY (contactId)"
+					+ " REFERENCES contacts (contactId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_INCOMING_KEYS =
+			"CREATE TABLE incomingKeys"
+					+ " (transportId _STRING NOT NULL,"
+					+ " keySetId INT NOT NULL,"
+					+ " rotationPeriod BIGINT NOT NULL,"
+					+ " contactId INT NOT NULL,"
+					+ " tagKey _SECRET NOT NULL,"
+					+ " headerKey _SECRET NOT NULL,"
+					+ " base BIGINT NOT NULL,"
+					+ " bitmap _BINARY NOT NULL,"
+					+ " periodOffset INT NOT NULL,"
+					+ " PRIMARY KEY (transportId, keySetId, periodOffset),"
+					+ " FOREIGN KEY (transportId)"
+					+ " REFERENCES transports (transportId)"
+					+ " ON DELETE CASCADE,"
+					+ " FOREIGN KEY (keySetId)"
+					+ " REFERENCES outgoingKeys (keySetId)"
+					+ " ON DELETE CASCADE,"
+					+ " FOREIGN KEY (contactId)"
+					+ " REFERENCES contacts (contactId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String INDEX_CONTACTS_BY_AUTHOR_ID =
+			"CREATE INDEX IF NOT EXISTS contactsByAuthorId"
+					+ " ON contacts (authorId)";
+
+	private static final String INDEX_GROUPS_BY_CLIENT_ID_MAJOR_VERSION =
+			"CREATE INDEX IF NOT EXISTS groupsByClientIdMajorVersion"
+					+ " ON groups (clientId, majorVersion)";
+
+	private static final String INDEX_MESSAGE_METADATA_BY_GROUP_ID_STATE =
+			"CREATE INDEX IF NOT EXISTS messageMetadataByGroupIdState"
+					+ " ON messageMetadata (groupId, state)";
+
+	private static final String INDEX_MESSAGE_DEPENDENCIES_BY_DEPENDENCY_ID =
+			"CREATE INDEX IF NOT EXISTS messageDependenciesByDependencyId"
+					+ " ON messageDependencies (dependencyId)";
+
+	private static final String INDEX_STATUSES_BY_CONTACT_ID_GROUP_ID =
+			"CREATE INDEX IF NOT EXISTS statusesByContactIdGroupId"
+					+ " ON statuses (contactId, groupId)";
+
+	private static final String INDEX_STATUSES_BY_CONTACT_ID_TIMESTAMP =
+			"CREATE INDEX IF NOT EXISTS statusesByContactIdTimestamp"
+					+ " ON statuses (contactId, timestamp)";
+
+	private static final Logger LOG =
+			Logger.getLogger(JdbcDatabaseMin.class.getName());
+
+	// Different database libraries use different names for certain types
+	private final String hashType, secretType, binaryType;
+	private final String counterType, stringType;
+	private final Clock clock;
+
+	// Locking: connectionsLock
+	private final LinkedList<Connection> connections = new LinkedList<>();
+
+	private int openConnections = 0; // Locking: connectionsLock
+	private boolean closed = false; // Locking: connectionsLock
+
+	@Nullable
+	protected abstract Connection createConnection() throws SQLException;
+
+	private final Lock connectionsLock = new ReentrantLock();
+	private final Condition connectionsChanged = connectionsLock.newCondition();
+
+	JdbcDatabaseMin(String hashType, String secretType, String binaryType,
+			String counterType, String stringType, Clock clock) {
+		this.hashType = hashType;
+		this.secretType = secretType;
+		this.binaryType = binaryType;
+		this.counterType = counterType;
+		this.stringType = stringType;
+		this.clock = clock;
+	}
+
+	protected void open(String driverClass, boolean reopen, SecretKey key,
+			@Nullable MigrationListener listener) throws DbException {
+		// Load the JDBC driver
+		try {
+			Class.forName(driverClass);
+		} catch (ClassNotFoundException e) {
+			throw new DbException(e);
+		}
+		// Open the database and create the tables and indexes if necessary
+		Connection txn = startTransaction();
+		try {
+			if (reopen) {
+				checkSchemaVersion(txn, listener);
+			} else {
+				createTables(txn);
+				storeSchemaVersion(txn, CODE_SCHEMA_VERSION);
+			}
+			createIndexes(txn);
+			commitTransaction(txn);
+		} catch (DbException e) {
+			abortTransaction(txn);
+			throw e;
+		}
+	}
+
+	/**
+	 * Compares the schema version stored in the database with the schema
+	 * version used by the current code and applies any suitable migrations to
+	 * the data if necessary.
+	 *
+	 * @throws DataTooNewException if the data uses a newer schema than the
+	 * current code
+	 * @throws DataTooOldException if the data uses an older schema than the
+	 * current code and cannot be migrated
+	 */
+	private void checkSchemaVersion(Connection txn,
+			@Nullable MigrationListener listener) throws DbException {
+		Settings s = getSettings(txn, DB_SETTINGS_NAMESPACE);
+		int dataSchemaVersion = s.getInt(SCHEMA_VERSION_KEY, -1);
+		if (dataSchemaVersion == -1) throw new DbException();
+		if (dataSchemaVersion == CODE_SCHEMA_VERSION) return;
+		if (CODE_SCHEMA_VERSION < dataSchemaVersion)
+			throw new DataTooNewException();
+		// Apply any suitable migrations in order
+		for (Migration<Connection> m : getMigrations()) {
+			int start = m.getStartVersion(), end = m.getEndVersion();
+			if (start == dataSchemaVersion) {
+				if (LOG.isLoggable(INFO))
+					LOG.info("Migrating from schema " + start + " to " + end);
+				if (listener != null) listener.onMigrationRun();
+				// Apply the migration
+				m.migrate(txn);
+				// Store the new schema version
+				storeSchemaVersion(txn, end);
+				dataSchemaVersion = end;
+			}
+		}
+		if (dataSchemaVersion != CODE_SCHEMA_VERSION)
+			throw new DataTooOldException();
+	}
+
+	// Package access for testing
+	List<Migration<Connection>> getMigrations() {
+		return Arrays.asList(new Migration38_39(), new Migration39_40());
+	}
+
+	private void storeSchemaVersion(Connection txn, int version)
+			throws DbException {
+		Settings s = new Settings();
+		s.putInt(SCHEMA_VERSION_KEY, version);
+		mergeSettings(txn, s, DB_SETTINGS_NAMESPACE);
+	}
+
+	private void tryToClose(@Nullable ResultSet rs) {
+		try {
+			if (rs != null) rs.close();
+		} catch (SQLException e) {
+			logException(LOG, WARNING, e);
+		}
+	}
+
+	private void tryToClose(@Nullable Statement s) {
+		try {
+			if (s != null) s.close();
+		} catch (SQLException e) {
+			logException(LOG, WARNING, e);
+		}
+	}
+
+	private void createTables(Connection txn) throws DbException {
+		Statement s = null;
+		try {
+			s = txn.createStatement();
+			s.executeUpdate(insertTypeNames(CREATE_SETTINGS));
+			s.executeUpdate(insertTypeNames(CREATE_LOCAL_AUTHORS));
+			s.executeUpdate(insertTypeNames(CREATE_CONTACTS));
+			s.executeUpdate(insertTypeNames(CREATE_GROUPS));
+			s.executeUpdate(insertTypeNames(CREATE_GROUP_METADATA));
+			s.executeUpdate(insertTypeNames(CREATE_GROUP_VISIBILITIES));
+			s.executeUpdate(insertTypeNames(CREATE_MESSAGES));
+			s.executeUpdate(insertTypeNames(CREATE_MESSAGE_METADATA));
+			s.executeUpdate(insertTypeNames(CREATE_MESSAGE_DEPENDENCIES));
+			s.executeUpdate(insertTypeNames(CREATE_OFFERS));
+			s.executeUpdate(insertTypeNames(CREATE_STATUSES));
+			s.executeUpdate(insertTypeNames(CREATE_TRANSPORTS));
+			s.executeUpdate(insertTypeNames(CREATE_OUTGOING_KEYS));
+			s.executeUpdate(insertTypeNames(CREATE_INCOMING_KEYS));
+			s.close();
+		} catch (SQLException e) {
+			tryToClose(s);
+			throw new DbException(e);
+		}
+	}
+
+	private void createIndexes(Connection txn) throws DbException {
+		Statement s = null;
+		try {
+			s = txn.createStatement();
+			s.executeUpdate(INDEX_CONTACTS_BY_AUTHOR_ID);
+			s.executeUpdate(INDEX_GROUPS_BY_CLIENT_ID_MAJOR_VERSION);
+			s.executeUpdate(INDEX_MESSAGE_METADATA_BY_GROUP_ID_STATE);
+			s.executeUpdate(INDEX_MESSAGE_DEPENDENCIES_BY_DEPENDENCY_ID);
+			s.executeUpdate(INDEX_STATUSES_BY_CONTACT_ID_GROUP_ID);
+			s.executeUpdate(INDEX_STATUSES_BY_CONTACT_ID_TIMESTAMP);
+			s.close();
+		} catch (SQLException e) {
+			tryToClose(s);
+			throw new DbException(e);
+		}
+	}
+
+	private String insertTypeNames(String s) {
+		s = s.replaceAll("_HASH", hashType);
+		s = s.replaceAll("_SECRET", secretType);
+		s = s.replaceAll("_BINARY", binaryType);
+		s = s.replaceAll("_COUNTER", counterType);
+		s = s.replaceAll("_STRING", stringType);
+		return s;
+	}
+
+	@Override
+	public Connection startTransaction() throws DbException {
+		Connection txn;
+		connectionsLock.lock();
+		try {
+			if (closed) throw new DbClosedException();
+			txn = connections.poll();
+		} finally {
+			connectionsLock.unlock();
+		}
+		try {
+			if (txn == null) {
+				// Open a new connection
+				txn = createConnection();
+				if (txn == null) throw new DbException();
+				txn.setAutoCommit(false);
+				connectionsLock.lock();
+				try {
+					openConnections++;
+				} finally {
+					connectionsLock.unlock();
+				}
+			}
+		} catch (SQLException e) {
+			throw new DbException(e);
+		}
+		return txn;
+	}
+
+	@Override
+	public void abortTransaction(Connection txn) {
+		try {
+			txn.rollback();
+			connectionsLock.lock();
+			try {
+				connections.add(txn);
+				connectionsChanged.signalAll();
+			} finally {
+				connectionsLock.unlock();
+			}
+		} catch (SQLException e) {
+			// Try to close the connection
+			logException(LOG, WARNING, e);
+			try {
+				txn.close();
+			} catch (SQLException e1) {
+				logException(LOG, WARNING, e1);
+			}
+			// Whatever happens, allow the database to close
+			connectionsLock.lock();
+			try {
+				openConnections--;
+				connectionsChanged.signalAll();
+			} finally {
+				connectionsLock.unlock();
+			}
+		}
+	}
+
+	@Override
+	public void commitTransaction(Connection txn) throws DbException {
+		try {
+			txn.commit();
+		} catch (SQLException e) {
+			throw new DbException(e);
+		}
+		connectionsLock.lock();
+		try {
+			connections.add(txn);
+			connectionsChanged.signalAll();
+		} finally {
+			connectionsLock.unlock();
+		}
+	}
+
+	void closeAllConnections() throws SQLException {
+		boolean interrupted = false;
+		connectionsLock.lock();
+		try {
+			closed = true;
+			for (Connection c : connections) c.close();
+			openConnections -= connections.size();
+			connections.clear();
+			while (openConnections > 0) {
+				try {
+					connectionsChanged.await();
+				} catch (InterruptedException e) {
+					LOG.warning("Interrupted while closing connections");
+					interrupted = true;
+				}
+				for (Connection c : connections) c.close();
+				openConnections -= connections.size();
+				connections.clear();
+			}
+		} finally {
+			connectionsLock.unlock();
+		}
+
+		if (interrupted) Thread.currentThread().interrupt();
+	}
+
+	@Override
+	public ContactId addContact(Connection txn, Author remote, AuthorId local,
+			boolean verified, boolean active) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			// Create a contact row
+			String sql = "INSERT INTO contacts"
+					+ " (authorId, formatVersion, name, publicKey,"
+					+ " localAuthorId,"
+					+ " verified, active)"
+					+ " VALUES (?, ?, ?, ?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, remote.getId().getBytes());
+			ps.setInt(2, remote.getFormatVersion());
+			ps.setString(3, remote.getName());
+			ps.setBytes(4, remote.getPublicKey());
+			ps.setBytes(5, local.getBytes());
+			ps.setBoolean(6, verified);
+			ps.setBoolean(7, active);
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+			// Get the new (highest) contact ID
+			sql = "SELECT contactId FROM contacts"
+					+ " ORDER BY contactId DESC LIMIT 1";
+			ps = txn.prepareStatement(sql);
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			ContactId c = new ContactId(rs.getInt(1));
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return c;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void addGroup(Connection txn, Group g) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "INSERT INTO groups"
+					+ " (groupId, clientId, majorVersion, descriptor)"
+					+ " VALUES (?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getId().getBytes());
+			ps.setString(2, g.getClientId().getString());
+			ps.setInt(3, g.getMajorVersion());
+			ps.setBytes(4, g.getDescriptor());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void addGroupVisibility(Connection txn, ContactId c, GroupId g,
+			boolean groupShared) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "INSERT INTO groupVisibilities"
+					+ " (contactId, groupId, shared)"
+					+ " VALUES (?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setBytes(2, g.getBytes());
+			ps.setBoolean(3, groupShared);
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+			// Create a status row for each message in the group
+			addStatus(txn, c, g, groupShared);
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	private void addStatus(Connection txn, ContactId c, GroupId g,
+			boolean groupShared) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT messageId, timestamp, state, shared,"
+					+ " length, raw IS NULL"
+					+ " FROM messages"
+					+ " WHERE groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			rs = ps.executeQuery();
+			while (rs.next()) {
+				MessageId id = new MessageId(rs.getBytes(1));
+				long timestamp = rs.getLong(2);
+				State state = State.fromValue(rs.getInt(3));
+				boolean messageShared = rs.getBoolean(4);
+				int length = rs.getInt(5);
+				boolean deleted = rs.getBoolean(6);
+				boolean seen = removeOfferedMessage(txn, c, id);
+				addStatus(txn, id, c, g, timestamp, length, state, groupShared,
+						messageShared, deleted, seen);
+			}
+			rs.close();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void addLocalAuthor(Connection txn, LocalAuthor a)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "INSERT INTO localAuthors"
+					+ " (authorId, formatVersion, name, publicKey,"
+					+ " privateKey, created)"
+					+ " VALUES (?, ?, ?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, a.getId().getBytes());
+			ps.setInt(2, a.getFormatVersion());
+			ps.setString(3, a.getName());
+			ps.setBytes(4, a.getPublicKey());
+			ps.setBytes(5, a.getPrivateKey());
+			ps.setLong(6, a.getTimeCreated());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void addMessage(Connection txn, Message m, State state,
+			boolean messageShared, @Nullable ContactId sender)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "INSERT INTO messages (messageId, groupId, timestamp,"
+					+ " state, shared, length, raw)"
+					+ " VALUES (?, ?, ?, ?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getId().getBytes());
+			ps.setBytes(2, m.getGroupId().getBytes());
+			ps.setLong(3, m.getTimestamp());
+			ps.setInt(4, state.getValue());
+			ps.setBoolean(5, messageShared);
+			byte[] raw = m.getRaw();
+			ps.setInt(6, raw.length);
+			ps.setBytes(7, raw);
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+			// Create a status row for each contact that can see the group
+			Map<ContactId, Boolean> visibility =
+					getGroupVisibility(txn, m.getGroupId());
+			for (Entry<ContactId, Boolean> e : visibility.entrySet()) {
+				ContactId c = e.getKey();
+				boolean offered = removeOfferedMessage(txn, c, m.getId());
+				boolean seen = offered || (sender != null && c.equals(sender));
+				addStatus(txn, m.getId(), c, m.getGroupId(), m.getTimestamp(),
+						m.getLength(), state, e.getValue(), messageShared,
+						false, seen);
+			}
+			// Update denormalised column in messageDependencies if dependency
+			// is in same group as dependent
+			sql = "UPDATE messageDependencies SET dependencyState = ?"
+					+ " WHERE groupId = ? AND dependencyId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, state.getValue());
+			ps.setBytes(2, m.getGroupId().getBytes());
+			ps.setBytes(3, m.getId().getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void addOfferedMessage(Connection txn, ContactId c, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM offers"
+					+ " WHERE messageId = ? AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			if (found) return;
+			sql = "INSERT INTO offers (messageId, contactId) VALUES (?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	private void addStatus(Connection txn, MessageId m, ContactId c, GroupId g,
+			long timestamp, int length, State state, boolean groupShared,
+			boolean messageShared, boolean deleted, boolean seen)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "INSERT INTO statuses (messageId, contactId, groupId,"
+					+ " timestamp, length, state, groupShared, messageShared,"
+					+ " deleted, ack, seen, requested, lastSentTime, txCount)"
+					+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, FALSE, 0, 0)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			ps.setBytes(3, g.getBytes());
+			ps.setLong(4, timestamp);
+			ps.setInt(5, length);
+			ps.setInt(6, state.getValue());
+			ps.setBoolean(7, groupShared);
+			ps.setBoolean(8, messageShared);
+			ps.setBoolean(9, deleted);
+			ps.setBoolean(10, seen);
+			ps.setBoolean(11, seen);
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void addMessageDependency(Connection txn, Message dependent,
+			MessageId dependency, State dependentState) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			// Get state of dependency if present and in same group as dependent
+			String sql = "SELECT state FROM messages"
+					+ " WHERE messageId = ? AND groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, dependency.getBytes());
+			ps.setBytes(2, dependent.getGroupId().getBytes());
+			rs = ps.executeQuery();
+			State dependencyState = null;
+			if (rs.next()) {
+				dependencyState = State.fromValue(rs.getInt(1));
+				if (rs.next()) throw new DbStateException();
+			}
+			rs.close();
+			ps.close();
+			// Create messageDependencies row
+			sql = "INSERT INTO messageDependencies"
+					+ " (groupId, messageId, dependencyId, messageState,"
+					+ " dependencyState)"
+					+ " VALUES (?, ?, ?, ? ,?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, dependent.getGroupId().getBytes());
+			ps.setBytes(2, dependent.getId().getBytes());
+			ps.setBytes(3, dependency.getBytes());
+			ps.setInt(4, dependentState.getValue());
+			if (dependencyState == null) ps.setNull(5, INTEGER);
+			else ps.setInt(5, dependencyState.getValue());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void addTransport(Connection txn, TransportId t, int maxLatency)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "INSERT INTO transports (transportId, maxLatency)"
+					+ " VALUES (?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, t.getString());
+			ps.setLong(2, maxLatency);
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public KeySetId addTransportKeys(Connection txn, ContactId c,
+			TransportKeys k) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			// Store the outgoing keys
+			String sql = "INSERT INTO outgoingKeys (contactId, transportId,"
+					+ " rotationPeriod, tagKey, headerKey, stream, active)"
+					+ " VALUES (?, ?, ?, ?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setString(2, k.getTransportId().getString());
+			OutgoingKeys outCurr = k.getCurrentOutgoingKeys();
+			ps.setLong(3, outCurr.getRotationPeriod());
+			ps.setBytes(4, outCurr.getTagKey().getBytes());
+			ps.setBytes(5, outCurr.getHeaderKey().getBytes());
+			ps.setLong(6, outCurr.getStreamCounter());
+			ps.setBoolean(7, outCurr.isActive());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+			// Get the new (highest) key set ID
+			sql = "SELECT keySetId FROM outgoingKeys"
+					+ " ORDER BY keySetId DESC LIMIT 1";
+			ps = txn.prepareStatement(sql);
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			KeySetId keySetId = new KeySetId(rs.getInt(1));
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			// Store the incoming keys
+			sql = "INSERT INTO incomingKeys (keySetId, contactId, transportId,"
+					+ " rotationPeriod, tagKey, headerKey, base, bitmap,"
+					+ " periodOffset)"
+					+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, keySetId.getInt());
+			ps.setInt(2, c.getInt());
+			ps.setString(3, k.getTransportId().getString());
+			// Previous rotation period
+			IncomingKeys inPrev = k.getPreviousIncomingKeys();
+			ps.setLong(4, inPrev.getRotationPeriod());
+			ps.setBytes(5, inPrev.getTagKey().getBytes());
+			ps.setBytes(6, inPrev.getHeaderKey().getBytes());
+			ps.setLong(7, inPrev.getWindowBase());
+			ps.setBytes(8, inPrev.getWindowBitmap());
+			ps.setInt(9, OFFSET_PREV);
+			ps.addBatch();
+			// Current rotation period
+			IncomingKeys inCurr = k.getCurrentIncomingKeys();
+			ps.setLong(4, inCurr.getRotationPeriod());
+			ps.setBytes(5, inCurr.getTagKey().getBytes());
+			ps.setBytes(6, inCurr.getHeaderKey().getBytes());
+			ps.setLong(7, inCurr.getWindowBase());
+			ps.setBytes(8, inCurr.getWindowBitmap());
+			ps.setInt(9, OFFSET_CURR);
+			ps.addBatch();
+			// Next rotation period
+			IncomingKeys inNext = k.getNextIncomingKeys();
+			ps.setLong(4, inNext.getRotationPeriod());
+			ps.setBytes(5, inNext.getTagKey().getBytes());
+			ps.setBytes(6, inNext.getHeaderKey().getBytes());
+			ps.setLong(7, inNext.getWindowBase());
+			ps.setBytes(8, inNext.getWindowBitmap());
+			ps.setInt(9, OFFSET_NEXT);
+			ps.addBatch();
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != 3) throw new DbStateException();
+			for (int rows : batchAffected)
+				if (rows != 1) throw new DbStateException();
+			ps.close();
+			return keySetId;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public boolean containsContact(Connection txn, AuthorId remote,
+			AuthorId local) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM contacts"
+					+ " WHERE authorId = ? AND localAuthorId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, remote.getBytes());
+			ps.setBytes(2, local.getBytes());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return found;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public boolean containsContact(Connection txn, ContactId c)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM contacts WHERE contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return found;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public boolean containsGroup(Connection txn, GroupId g)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM groups WHERE groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return found;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public boolean containsLocalAuthor(Connection txn, AuthorId a)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM localAuthors WHERE authorId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, a.getBytes());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return found;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public boolean containsMessage(Connection txn, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM messages WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return found;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public boolean containsTransport(Connection txn, TransportId t)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM transports WHERE transportId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, t.getString());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return found;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public boolean containsVisibleMessage(Connection txn, ContactId c,
+			MessageId m) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM statuses"
+					+ " WHERE messageId = ? AND contactId = ?"
+					+ " AND messageShared = TRUE";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return found;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public int countOfferedMessages(Connection txn, ContactId c)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT COUNT (messageId) FROM offers "
+					+ " WHERE contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbException();
+			int count = rs.getInt(1);
+			if (rs.next()) throw new DbException();
+			rs.close();
+			ps.close();
+			return count;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void deleteMessage(Connection txn, MessageId m) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE messages SET raw = NULL WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			if (affected > 1) throw new DbStateException();
+			ps.close();
+			// Update denormalised column in statuses
+			sql = "UPDATE statuses SET deleted = TRUE WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void deleteMessageMetadata(Connection txn, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM messageMetadata WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Contact getContact(Connection txn, ContactId c) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT authorId, formatVersion, name, publicKey,"
+					+ " localAuthorId, verified, active"
+					+ " FROM contacts"
+					+ " WHERE contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			AuthorId authorId = new AuthorId(rs.getBytes(1));
+			int formatVersion = rs.getInt(2);
+			String name = rs.getString(3);
+			byte[] publicKey = rs.getBytes(4);
+			AuthorId localAuthorId = new AuthorId(rs.getBytes(5));
+			boolean verified = rs.getBoolean(6);
+			boolean active = rs.getBoolean(7);
+			rs.close();
+			ps.close();
+			Author author =
+					new Author(authorId, formatVersion, name, publicKey);
+			return new Contact(c, author, localAuthorId, verified, active);
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<Contact> getContacts(Connection txn)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT contactId, authorId, formatVersion, name,"
+					+ " publicKey, localAuthorId, verified, active"
+					+ " FROM contacts";
+			ps = txn.prepareStatement(sql);
+			rs = ps.executeQuery();
+			List<Contact> contacts = new ArrayList<>();
+			while (rs.next()) {
+				ContactId contactId = new ContactId(rs.getInt(1));
+				AuthorId authorId = new AuthorId(rs.getBytes(2));
+				int formatVersion = rs.getInt(3);
+				String name = rs.getString(4);
+				byte[] publicKey = rs.getBytes(5);
+				Author author =
+						new Author(authorId, formatVersion, name, publicKey);
+				AuthorId localAuthorId = new AuthorId(rs.getBytes(6));
+				boolean verified = rs.getBoolean(7);
+				boolean active = rs.getBoolean(8);
+				contacts.add(new Contact(contactId, author, localAuthorId,
+						verified, active));
+			}
+			rs.close();
+			ps.close();
+			return contacts;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<ContactId> getContacts(Connection txn, AuthorId local)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT contactId FROM contacts"
+					+ " WHERE localAuthorId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, local.getBytes());
+			rs = ps.executeQuery();
+			List<ContactId> ids = new ArrayList<>();
+			while (rs.next()) ids.add(new ContactId(rs.getInt(1)));
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<Contact> getContactsByAuthorId(Connection txn,
+			AuthorId remote) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT contactId, formatVersion, name, publicKey,"
+					+ " localAuthorId, verified, active"
+					+ " FROM contacts"
+					+ " WHERE authorId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, remote.getBytes());
+			rs = ps.executeQuery();
+			List<Contact> contacts = new ArrayList<>();
+			while (rs.next()) {
+				ContactId c = new ContactId(rs.getInt(1));
+				int formatVersion = rs.getInt(2);
+				String name = rs.getString(3);
+				byte[] publicKey = rs.getBytes(4);
+				AuthorId localAuthorId = new AuthorId(rs.getBytes(5));
+				boolean verified = rs.getBoolean(6);
+				boolean active = rs.getBoolean(7);
+				Author author =
+						new Author(remote, formatVersion, name, publicKey);
+				contacts.add(new Contact(c, author, localAuthorId, verified,
+						active));
+			}
+			rs.close();
+			ps.close();
+			return contacts;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Group getGroup(Connection txn, GroupId g) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT clientId, majorVersion, descriptor"
+					+ " FROM groups WHERE groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			ClientId clientId = new ClientId(rs.getString(1));
+			int majorVersion = rs.getInt(2);
+			byte[] descriptor = rs.getBytes(3);
+			rs.close();
+			ps.close();
+			return new Group(g, clientId, majorVersion, descriptor);
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<Group> getGroups(Connection txn, ClientId c,
+			int majorVersion) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT groupId, descriptor FROM groups"
+					+ " WHERE clientId = ? AND majorVersion = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, c.getString());
+			ps.setInt(2, majorVersion);
+			rs = ps.executeQuery();
+			List<Group> groups = new ArrayList<>();
+			while (rs.next()) {
+				GroupId id = new GroupId(rs.getBytes(1));
+				byte[] descriptor = rs.getBytes(2);
+				groups.add(new Group(id, c, majorVersion, descriptor));
+			}
+			rs.close();
+			ps.close();
+			return groups;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Visibility getGroupVisibility(Connection txn, ContactId c, GroupId g)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT shared FROM groupVisibilities"
+					+ " WHERE contactId = ? AND groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setBytes(2, g.getBytes());
+			rs = ps.executeQuery();
+			Visibility v;
+			if (rs.next()) v = rs.getBoolean(1) ? SHARED : VISIBLE;
+			else v = INVISIBLE;
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return v;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Map<ContactId, Boolean> getGroupVisibility(Connection txn, GroupId g)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT contactId, shared FROM groupVisibilities"
+					+ " WHERE groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			rs = ps.executeQuery();
+			Map<ContactId, Boolean> visible = new HashMap<>();
+			while (rs.next())
+				visible.put(new ContactId(rs.getInt(1)), rs.getBoolean(2));
+			rs.close();
+			ps.close();
+			return visible;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public LocalAuthor getLocalAuthor(Connection txn, AuthorId a)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT formatVersion, name, publicKey,"
+					+ " privateKey, created"
+					+ " FROM localAuthors"
+					+ " WHERE authorId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, a.getBytes());
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			int formatVersion = rs.getInt(1);
+			String name = rs.getString(2);
+			byte[] publicKey = rs.getBytes(3);
+			byte[] privateKey = rs.getBytes(4);
+			long created = rs.getLong(5);
+			LocalAuthor localAuthor = new LocalAuthor(a, formatVersion, name,
+					publicKey, privateKey, created);
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return localAuthor;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<LocalAuthor> getLocalAuthors(Connection txn)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT authorId, formatVersion, name, publicKey,"
+					+ " privateKey, created"
+					+ " FROM localAuthors";
+			ps = txn.prepareStatement(sql);
+			rs = ps.executeQuery();
+			List<LocalAuthor> authors = new ArrayList<>();
+			while (rs.next()) {
+				AuthorId authorId = new AuthorId(rs.getBytes(1));
+				int formatVersion = rs.getInt(2);
+				String name = rs.getString(3);
+				byte[] publicKey = rs.getBytes(4);
+				byte[] privateKey = rs.getBytes(5);
+				long created = rs.getLong(6);
+				authors.add(new LocalAuthor(authorId, formatVersion, name,
+						publicKey, privateKey, created));
+			}
+			rs.close();
+			ps.close();
+			return authors;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getMessageIds(Connection txn, GroupId g)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT messageId FROM messages"
+					+ " WHERE groupId = ? AND state = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			ps.setInt(2, DELIVERED.getValue());
+			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<>();
+			while (rs.next()) ids.add(new MessageId(rs.getBytes(1)));
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getMessageIds(Connection txn, GroupId g,
+			Metadata query) throws DbException {
+		// If there are no query terms, return all delivered messages
+		if (query.isEmpty()) return getMessageIds(txn, g);
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			// Retrieve the message IDs for each query term and intersect
+			Set<MessageId> intersection = null;
+			String sql = "SELECT messageId FROM messageMetadata"
+					+ " WHERE groupId = ? AND state = ?"
+					+ " AND metaKey = ? AND value = ?";
+			for (Entry<String, byte[]> e : query.entrySet()) {
+				ps = txn.prepareStatement(sql);
+				ps.setBytes(1, g.getBytes());
+				ps.setInt(2, DELIVERED.getValue());
+				ps.setString(3, e.getKey());
+				ps.setBytes(4, e.getValue());
+				rs = ps.executeQuery();
+				Set<MessageId> ids = new HashSet<>();
+				while (rs.next()) ids.add(new MessageId(rs.getBytes(1)));
+				rs.close();
+				ps.close();
+				if (intersection == null) intersection = ids;
+				else intersection.retainAll(ids);
+				// Return early if there are no matches
+				if (intersection.isEmpty()) return Collections.emptySet();
+			}
+			if (intersection == null) throw new AssertionError();
+			return intersection;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Map<MessageId, Metadata> getMessageMetadata(Connection txn,
+			GroupId g) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT messageId, metaKey, value"
+					+ " FROM messageMetadata"
+					+ " WHERE groupId = ? AND state = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			ps.setInt(2, DELIVERED.getValue());
+			rs = ps.executeQuery();
+			Map<MessageId, Metadata> all = new HashMap<>();
+			while (rs.next()) {
+				MessageId messageId = new MessageId(rs.getBytes(1));
+				Metadata metadata = all.get(messageId);
+				if (metadata == null) {
+					metadata = new Metadata();
+					all.put(messageId, metadata);
+				}
+				metadata.put(rs.getString(2), rs.getBytes(3));
+			}
+			rs.close();
+			ps.close();
+			return all;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Map<MessageId, Metadata> getMessageMetadata(Connection txn,
+			GroupId g, Metadata query) throws DbException {
+		// Retrieve the matching message IDs
+		Collection<MessageId> matches = getMessageIds(txn, g, query);
+		if (matches.isEmpty()) return Collections.emptyMap();
+		// Retrieve the metadata for each match
+		Map<MessageId, Metadata> all = new HashMap<>(matches.size());
+		for (MessageId m : matches) all.put(m, getMessageMetadata(txn, m));
+		return all;
+	}
+
+	@Override
+	public Metadata getGroupMetadata(Connection txn, GroupId g)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT metaKey, value FROM groupMetadata"
+					+ " WHERE groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			rs = ps.executeQuery();
+			Metadata metadata = new Metadata();
+			while (rs.next()) metadata.put(rs.getString(1), rs.getBytes(2));
+			rs.close();
+			ps.close();
+			return metadata;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Metadata getMessageMetadata(Connection txn, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT metaKey, value FROM messageMetadata"
+					+ " WHERE state = ? AND messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, DELIVERED.getValue());
+			ps.setBytes(2, m.getBytes());
+			rs = ps.executeQuery();
+			Metadata metadata = new Metadata();
+			while (rs.next()) metadata.put(rs.getString(1), rs.getBytes(2));
+			rs.close();
+			ps.close();
+			return metadata;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Metadata getMessageMetadataForValidator(Connection txn, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT metaKey, value FROM messageMetadata"
+					+ " WHERE (state = ? OR state = ?)"
+					+ " AND messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, DELIVERED.getValue());
+			ps.setInt(2, PENDING.getValue());
+			ps.setBytes(3, m.getBytes());
+			rs = ps.executeQuery();
+			Metadata metadata = new Metadata();
+			while (rs.next()) metadata.put(rs.getString(1), rs.getBytes(2));
+			rs.close();
+			ps.close();
+			return metadata;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageStatus> getMessageStatus(Connection txn,
+			ContactId c, GroupId g) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT messageId, txCount > 0, seen FROM statuses"
+					+ " WHERE groupId = ? AND contactId = ? AND state = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			ps.setInt(2, c.getInt());
+			ps.setInt(3, DELIVERED.getValue());
+			rs = ps.executeQuery();
+			List<MessageStatus> statuses = new ArrayList<>();
+			while (rs.next()) {
+				MessageId messageId = new MessageId(rs.getBytes(1));
+				boolean sent = rs.getBoolean(2);
+				boolean seen = rs.getBoolean(3);
+				statuses.add(new MessageStatus(messageId, c, sent, seen));
+			}
+			rs.close();
+			ps.close();
+			return statuses;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	@Nullable
+	public MessageStatus getMessageStatus(Connection txn, ContactId c,
+			MessageId m) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT txCount > 0, seen FROM statuses"
+					+ " WHERE messageId = ? AND contactId = ? AND state = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			ps.setInt(3, DELIVERED.getValue());
+			rs = ps.executeQuery();
+			MessageStatus status = null;
+			if (rs.next()) {
+				boolean sent = rs.getBoolean(1);
+				boolean seen = rs.getBoolean(2);
+				status = new MessageStatus(m, c, sent, seen);
+			}
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return status;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Map<MessageId, State> getMessageDependencies(Connection txn,
+			MessageId m) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT dependencyId, dependencyState"
+					+ " FROM messageDependencies"
+					+ " WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			rs = ps.executeQuery();
+			Map<MessageId, State> dependencies = new HashMap<>();
+			while (rs.next()) {
+				MessageId dependency = new MessageId(rs.getBytes(1));
+				State state = State.fromValue(rs.getInt(2));
+				if (rs.wasNull())
+					state = UNKNOWN; // Missing or in a different group
+				dependencies.put(dependency, state);
+			}
+			rs.close();
+			ps.close();
+			return dependencies;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Map<MessageId, State> getMessageDependents(Connection txn,
+			MessageId m) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			// Exclude dependencies that are missing or in a different group
+			// from the dependent
+			String sql = "SELECT messageId, messageState"
+					+ " FROM messageDependencies"
+					+ " WHERE dependencyId = ?"
+					+ " AND dependencyState IS NOT NULL";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			rs = ps.executeQuery();
+			Map<MessageId, State> dependents = new HashMap<>();
+			while (rs.next()) {
+				MessageId dependent = new MessageId(rs.getBytes(1));
+				State state = State.fromValue(rs.getInt(2));
+				dependents.put(dependent, state);
+			}
+			rs.close();
+			ps.close();
+			return dependents;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public State getMessageState(Connection txn, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT state FROM messages WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			State state = State.fromValue(rs.getInt(1));
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return state;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getMessagesToAck(Connection txn, ContactId c,
+			int maxMessages) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT messageId FROM statuses"
+					+ " WHERE contactId = ? AND ack = TRUE"
+					+ " LIMIT ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setInt(2, maxMessages);
+			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<>();
+			while (rs.next()) ids.add(new MessageId(rs.getBytes(1)));
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getMessagesToOffer(Connection txn,
+			ContactId c, int maxMessages, int maxLatency) throws DbException {
+		long now = clock.currentTimeMillis();
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT messageId FROM statuses"
+					+ " WHERE contactId = ? AND state = ?"
+					+ " AND groupShared = TRUE AND messageShared = TRUE"
+					+ " AND deleted = FALSE"
+					+ " AND seen = FALSE AND requested = FALSE"
+					+ " AND ( lastSentTime = 0"
+					+ " OR (lastSentTime + ? * POWER(2,txCount+1)) <= ?)"
+					+ " ORDER BY timestamp LIMIT ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setInt(2, DELIVERED.getValue());
+			ps.setInt(3, maxLatency);
+			ps.setLong(4, now);
+			ps.setInt(5, maxMessages);
+			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<>();
+			while (rs.next()) ids.add(new MessageId(rs.getBytes(1)));
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getMessagesToRequest(Connection txn,
+			ContactId c, int maxMessages) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT messageId FROM offers"
+					+ " WHERE contactId = ?"
+					+ " LIMIT ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setInt(2, maxMessages);
+			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<>();
+			while (rs.next()) ids.add(new MessageId(rs.getBytes(1)));
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getMessagesToSend(Connection txn, ContactId c,
+			int maxLength, int maxLatency) throws DbException {
+		long now = clock.currentTimeMillis();
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT length, messageId FROM statuses"
+					+ " WHERE contactId = ? AND state = ?"
+					+ " AND groupShared = TRUE AND messageShared = TRUE"
+					+ " AND deleted = FALSE"
+					+ " AND seen = FALSE"
+					+ " AND ( lastSentTime = 0"
+					+ " OR (lastSentTime + ? * POWER(2,txCount)) <= ?)"
+					+ " ORDER BY timestamp";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setInt(2, DELIVERED.getValue());
+			ps.setLong(3, maxLatency);
+			ps.setLong(4, now);
+			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<>();
+			int total = 0;
+			while (rs.next()) {
+				int length = rs.getInt(1);
+				if (total + length > maxLength) break;
+				ids.add(new MessageId(rs.getBytes(2)));
+				total += length;
+			}
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getMessagesToValidate(Connection txn)
+			throws DbException {
+		return getMessagesInState(txn, UNKNOWN);
+	}
+
+	@Override
+	public Collection<MessageId> getPendingMessages(Connection txn)
+			throws DbException {
+		return getMessagesInState(txn, PENDING);
+	}
+
+	private Collection<MessageId> getMessagesInState(Connection txn,
+			State state) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT messageId FROM messages"
+					+ " WHERE state = ? AND raw IS NOT NULL";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, state.getValue());
+			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<>();
+			while (rs.next()) ids.add(new MessageId(rs.getBytes(1)));
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getMessagesToShare(Connection txn)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT m.messageId FROM messages AS m"
+					+ " JOIN messageDependencies AS d"
+					+ " ON m.messageId = d.dependencyId"
+					+ " JOIN messages AS m1"
+					+ " ON d.messageId = m1.messageId"
+					+ " WHERE m.state = ?"
+					+ " AND m.shared = FALSE AND m1.shared = TRUE";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, DELIVERED.getValue());
+			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<>();
+			while (rs.next()) ids.add(new MessageId(rs.getBytes(1)));
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public long getNextSendTime(Connection txn, ContactId c, int maxLatency)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql =
+					"SELECT Min(lastSentTime + ? * POWER(2, txCount)) FROM statuses"
+							+ " WHERE contactId = ? AND state = ?"
+							+ " AND groupShared = TRUE AND messageShared = TRUE"
+							+ " AND deleted = FALSE AND seen = FALSE";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, maxLatency);
+			ps.setInt(2, c.getInt());
+			ps.setInt(3, DELIVERED.getValue());
+			rs = ps.executeQuery();
+			long lastSentTime = Long.MAX_VALUE;
+			if (rs.next()) {
+				lastSentTime = rs.getLong(1);
+				if (rs.next()) throw new AssertionError();
+			}
+			rs.close();
+			ps.close();
+			return lastSentTime;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	@Nullable
+	public byte[] getRawMessage(Connection txn, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT raw FROM messages WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			byte[] raw = rs.getBytes(1);
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return raw;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getRequestedMessagesToSend(Connection txn,
+			ContactId c, int maxLength) throws DbException {
+		long now = clock.currentTimeMillis();
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT length, messageId FROM statuses"
+					+ " WHERE contactId = ? AND state = ?"
+					+ " AND groupShared = TRUE AND messageShared = TRUE"
+					+ " AND deleted = FALSE"
+					+ " AND seen = FALSE AND requested = TRUE"
+					+ " AND lastSendTime < ?"
+					+ " ORDER BY timestamp";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setInt(2, DELIVERED.getValue());
+			ps.setLong(3, now);
+			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<>();
+			int total = 0;
+			while (rs.next()) {
+				int length = rs.getInt(1);
+				if (total + length > maxLength) break;
+				ids.add(new MessageId(rs.getBytes(2)));
+				total += length;
+			}
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Settings getSettings(Connection txn, String namespace)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT settingKey, value FROM settings"
+					+ " WHERE namespace = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, namespace);
+			rs = ps.executeQuery();
+			Settings s = new Settings();
+			while (rs.next()) s.put(rs.getString(1), rs.getString(2));
+			rs.close();
+			ps.close();
+			return s;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<KeySet> getTransportKeys(Connection txn, TransportId t)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			// Retrieve the incoming keys
+			String sql = "SELECT rotationPeriod, tagKey, headerKey,"
+					+ " base, bitmap"
+					+ " FROM incomingKeys"
+					+ " WHERE transportId = ?"
+					+ " ORDER BY keySetId, periodOffset";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, t.getString());
+			rs = ps.executeQuery();
+			List<IncomingKeys> inKeys = new ArrayList<>();
+			while (rs.next()) {
+				long rotationPeriod = rs.getLong(1);
+				SecretKey tagKey = new SecretKey(rs.getBytes(2));
+				SecretKey headerKey = new SecretKey(rs.getBytes(3));
+				long windowBase = rs.getLong(4);
+				byte[] windowBitmap = rs.getBytes(5);
+				inKeys.add(new IncomingKeys(tagKey, headerKey, rotationPeriod,
+						windowBase, windowBitmap));
+			}
+			rs.close();
+			ps.close();
+			// Retrieve the outgoing keys in the same order
+			sql = "SELECT keySetId, contactId, rotationPeriod,"
+					+ " tagKey, headerKey, stream, active"
+					+ " FROM outgoingKeys"
+					+ " WHERE transportId = ?"
+					+ " ORDER BY keySetId";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, t.getString());
+			rs = ps.executeQuery();
+			Collection<KeySet> keys = new ArrayList<>();
+			for (int i = 0; rs.next(); i++) {
+				// There should be three times as many incoming keys
+				if (inKeys.size() < (i + 1) * 3) throw new DbStateException();
+				KeySetId keySetId = new KeySetId(rs.getInt(1));
+				ContactId contactId = new ContactId(rs.getInt(2));
+				long rotationPeriod = rs.getLong(3);
+				SecretKey tagKey = new SecretKey(rs.getBytes(4));
+				SecretKey headerKey = new SecretKey(rs.getBytes(5));
+				long streamCounter = rs.getLong(6);
+				boolean active = rs.getBoolean(7);
+				OutgoingKeys outCurr = new OutgoingKeys(tagKey, headerKey,
+						rotationPeriod, streamCounter, active);
+				IncomingKeys inPrev = inKeys.get(i * 3);
+				IncomingKeys inCurr = inKeys.get(i * 3 + 1);
+				IncomingKeys inNext = inKeys.get(i * 3 + 2);
+				TransportKeys transportKeys = new TransportKeys(t, inPrev,
+						inCurr, inNext, outCurr);
+				keys.add(new KeySet(keySetId, contactId, transportKeys));
+			}
+			rs.close();
+			ps.close();
+			return keys;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void incrementStreamCounter(Connection txn, TransportId t,
+			KeySetId k) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE outgoingKeys SET stream = stream + 1"
+					+ " WHERE transportId = ? AND keySetId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, t.getString());
+			ps.setInt(2, k.getInt());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void lowerAckFlag(Connection txn, ContactId c,
+			Collection<MessageId> acked) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE statuses SET ack = FALSE"
+					+ " WHERE messageId = ? AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(2, c.getInt());
+			for (MessageId m : acked) {
+				ps.setBytes(1, m.getBytes());
+				ps.addBatch();
+			}
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != acked.size())
+				throw new DbStateException();
+			for (int rows : batchAffected) {
+				if (rows < 0) throw new DbStateException();
+				if (rows > 1) throw new DbStateException();
+			}
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void lowerRequestedFlag(Connection txn, ContactId c,
+			Collection<MessageId> requested) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE statuses SET requested = FALSE"
+					+ " WHERE messageId = ? AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(2, c.getInt());
+			for (MessageId m : requested) {
+				ps.setBytes(1, m.getBytes());
+				ps.addBatch();
+			}
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != requested.size())
+				throw new DbStateException();
+			for (int rows : batchAffected) {
+				if (rows < 0) throw new DbStateException();
+				if (rows > 1) throw new DbStateException();
+			}
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void mergeGroupMetadata(Connection txn, GroupId g, Metadata meta)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			Map<String, byte[]> added = removeOrUpdateMetadata(txn,
+					g.getBytes(), meta, "groupMetadata", "groupId");
+			if (added.isEmpty()) return;
+			// Insert any keys that don't already exist
+			String sql = "INSERT INTO groupMetadata (groupId, metaKey, value)"
+					+ " VALUES (?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			for (Entry<String, byte[]> e : added.entrySet()) {
+				ps.setString(2, e.getKey());
+				ps.setBytes(3, e.getValue());
+				ps.addBatch();
+			}
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != added.size())
+				throw new DbStateException();
+			for (int rows : batchAffected)
+				if (rows != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void mergeMessageMetadata(Connection txn, MessageId m,
+			Metadata meta) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			Map<String, byte[]> added = removeOrUpdateMetadata(txn,
+					m.getBytes(), meta, "messageMetadata", "messageId");
+			if (added.isEmpty()) return;
+			// Get the group ID and message state for the denormalised columns
+			String sql = "SELECT groupId, state FROM messages"
+					+ " WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			GroupId g = new GroupId(rs.getBytes(1));
+			State state = State.fromValue(rs.getInt(2));
+			rs.close();
+			ps.close();
+			// Insert any keys that don't already exist
+			sql = "INSERT INTO messageMetadata"
+					+ " (messageId, groupId, state, metaKey, value)"
+					+ " VALUES (?, ?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setBytes(2, g.getBytes());
+			ps.setInt(3, state.getValue());
+			for (Entry<String, byte[]> e : added.entrySet()) {
+				ps.setString(4, e.getKey());
+				ps.setBytes(5, e.getValue());
+				ps.addBatch();
+			}
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != added.size())
+				throw new DbStateException();
+			for (int rows : batchAffected)
+				if (rows != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	// Removes or updates any existing entries, returns any entries that
+	// need to be added
+	private Map<String, byte[]> removeOrUpdateMetadata(Connection txn,
+			byte[] id, Metadata meta, String tableName, String columnName)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			// Determine which keys are being removed
+			List<String> removed = new ArrayList<>();
+			Map<String, byte[]> notRemoved = new HashMap<>();
+			for (Entry<String, byte[]> e : meta.entrySet()) {
+				if (e.getValue() == REMOVE) removed.add(e.getKey());
+				else notRemoved.put(e.getKey(), e.getValue());
+			}
+			// Delete any keys that are being removed
+			if (!removed.isEmpty()) {
+				String sql = "DELETE FROM " + tableName
+						+ " WHERE " + columnName + " = ? AND metaKey = ?";
+				ps = txn.prepareStatement(sql);
+				ps.setBytes(1, id);
+				for (String key : removed) {
+					ps.setString(2, key);
+					ps.addBatch();
+				}
+				int[] batchAffected = ps.executeBatch();
+				if (batchAffected.length != removed.size())
+					throw new DbStateException();
+				for (int rows : batchAffected) {
+					if (rows < 0) throw new DbStateException();
+					if (rows > 1) throw new DbStateException();
+				}
+				ps.close();
+			}
+			if (notRemoved.isEmpty()) return Collections.emptyMap();
+			// Update any keys that already exist
+			String sql = "UPDATE " + tableName + " SET value = ?"
+					+ " WHERE " + columnName + " = ? AND metaKey = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(2, id);
+			for (Entry<String, byte[]> e : notRemoved.entrySet()) {
+				ps.setBytes(1, e.getValue());
+				ps.setString(3, e.getKey());
+				ps.addBatch();
+			}
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != notRemoved.size())
+				throw new DbStateException();
+			for (int rows : batchAffected) {
+				if (rows < 0) throw new DbStateException();
+				if (rows > 1) throw new DbStateException();
+			}
+			ps.close();
+			// Are there any keys that don't already exist?
+			Map<String, byte[]> added = new HashMap<>();
+			int updateIndex = 0;
+			for (Entry<String, byte[]> e : notRemoved.entrySet()) {
+				if (batchAffected[updateIndex++] == 0)
+					added.put(e.getKey(), e.getValue());
+			}
+			return added;
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void mergeSettings(Connection txn, Settings s, String namespace)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			// Update any settings that already exist
+			String sql = "UPDATE settings SET value = ?"
+					+ " WHERE namespace = ? AND settingKey = ?";
+			ps = txn.prepareStatement(sql);
+			for (Entry<String, String> e : s.entrySet()) {
+				ps.setString(1, e.getValue());
+				ps.setString(2, namespace);
+				ps.setString(3, e.getKey());
+				ps.addBatch();
+			}
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != s.size()) throw new DbStateException();
+			for (int rows : batchAffected) {
+				if (rows < 0) throw new DbStateException();
+				if (rows > 1) throw new DbStateException();
+			}
+			// Insert any settings that don't already exist
+			sql = "INSERT INTO settings (namespace, settingKey, value)"
+					+ " VALUES (?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			int updateIndex = 0, inserted = 0;
+			for (Entry<String, String> e : s.entrySet()) {
+				if (batchAffected[updateIndex] == 0) {
+					ps.setString(1, namespace);
+					ps.setString(2, e.getKey());
+					ps.setString(3, e.getValue());
+					ps.addBatch();
+					inserted++;
+				}
+				updateIndex++;
+			}
+			batchAffected = ps.executeBatch();
+			if (batchAffected.length != inserted) throw new DbStateException();
+			for (int rows : batchAffected)
+				if (rows != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void raiseAckFlag(Connection txn, ContactId c, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE statuses SET ack = TRUE"
+					+ " WHERE messageId = ? AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void raiseRequestedFlag(Connection txn, ContactId c, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE statuses SET requested = TRUE"
+					+ " WHERE messageId = ? AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void raiseSeenFlag(Connection txn, ContactId c, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE statuses SET seen = TRUE"
+					+ " WHERE messageId = ? AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void removeContact(Connection txn, ContactId c)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM contacts WHERE contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void removeGroup(Connection txn, GroupId g) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM groups WHERE groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void removeGroupVisibility(Connection txn, ContactId c, GroupId g)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM groupVisibilities"
+					+ " WHERE contactId = ? AND groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setBytes(2, g.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+			// Remove status rows for the messages in the group
+			sql = "DELETE FROM statuses"
+					+ " WHERE contactId = ? AND groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setBytes(2, g.getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void removeLocalAuthor(Connection txn, AuthorId a)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM localAuthors WHERE authorId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, a.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void removeMessage(Connection txn, MessageId m) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM messages WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	private boolean removeOfferedMessage(Connection txn, ContactId c,
+			MessageId m) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM offers"
+					+ " WHERE contactId = ? AND messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setBytes(2, m.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+			return affected == 1;
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void removeOfferedMessages(Connection txn, ContactId c,
+			Collection<MessageId> requested) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM offers"
+					+ " WHERE contactId = ? AND messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			for (MessageId m : requested) {
+				ps.setBytes(2, m.getBytes());
+				ps.addBatch();
+			}
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != requested.size())
+				throw new DbStateException();
+			for (int rows : batchAffected)
+				if (rows != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void removeTransport(Connection txn, TransportId t)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM transports WHERE transportId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, t.getString());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void removeTransportKeys(Connection txn, TransportId t, KeySetId k)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			// Delete any existing outgoing keys - this will also remove any
+			// incoming keys with the same key set ID
+			String sql = "DELETE FROM outgoingKeys"
+					+ " WHERE transportId = ? AND keySetId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, t.getString());
+			ps.setInt(2, k.getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void resetExpiryTime(Connection txn, ContactId c, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE statuses SET lastSentTime = 0, txCount = 0"
+					+ " WHERE messageId = ? AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void setContactVerified(Connection txn, ContactId c)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE contacts SET verified = ? WHERE contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBoolean(1, true);
+			ps.setInt(2, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void setContactActive(Connection txn, ContactId c, boolean active)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE contacts SET active = ? WHERE contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBoolean(1, active);
+			ps.setInt(2, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void setGroupVisibility(Connection txn, ContactId c, GroupId g,
+			boolean shared) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE groupVisibilities SET shared = ?"
+					+ " WHERE contactId = ? AND groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBoolean(1, shared);
+			ps.setInt(2, c.getInt());
+			ps.setBytes(3, g.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+			// Update denormalised column in statuses
+			sql = "UPDATE statuses SET groupShared = ?"
+					+ " WHERE contactId = ? AND groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBoolean(1, shared);
+			ps.setInt(2, c.getInt());
+			ps.setBytes(3, g.getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void setMessageShared(Connection txn, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE messages SET shared = TRUE"
+					+ " WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+			// Update denormalised column in statuses
+			sql = "UPDATE statuses SET messageShared = TRUE"
+					+ " WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void setMessageState(Connection txn, MessageId m, State state)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE messages SET state = ? WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, state.getValue());
+			ps.setBytes(2, m.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+			// Update denormalised column in messageMetadata
+			sql = "UPDATE messageMetadata SET state = ? WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, state.getValue());
+			ps.setBytes(2, m.getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+			// Update denormalised column in statuses
+			sql = "UPDATE statuses SET state = ? WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, state.getValue());
+			ps.setBytes(2, m.getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+			// Update denormalised column in messageDependencies
+			sql = "UPDATE messageDependencies SET messageState = ?"
+					+ " WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, state.getValue());
+			ps.setBytes(2, m.getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+			// Update denormalised column in messageDependencies if dependency
+			// is present and in same group as dependent
+			sql = "UPDATE messageDependencies SET dependencyState = ?"
+					+ " WHERE dependencyId = ? AND dependencyState IS NOT NULL";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, state.getValue());
+			ps.setBytes(2, m.getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void setReorderingWindow(Connection txn, KeySetId k, TransportId t,
+			long rotationPeriod, long base, byte[] bitmap) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE incomingKeys SET base = ?, bitmap = ?"
+					+ " WHERE transportId = ? AND keySetId = ?"
+					+ " AND rotationPeriod = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setLong(1, base);
+			ps.setBytes(2, bitmap);
+			ps.setString(3, t.getString());
+			ps.setInt(4, k.getInt());
+			ps.setLong(5, rotationPeriod);
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void setTransportKeysActive(Connection txn, TransportId t,
+			KeySetId k) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE outgoingKeys SET active = true"
+					+ " WHERE transportId = ? AND keySetId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, t.getString());
+			ps.setInt(2, k.getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void updateLastSentTime(Connection txn, ContactId c, MessageId m,
+			int maxLatency) throws DbException {
+		updateLastSentTime(txn, c, m);
+	}
+
+	@Override
+	public void updateLastSentTime(Connection txn, ContactId c, MessageId m)
+			throws DbException {
+		updateLastSentTime(txn, c, m, 0, clock.currentTimeMillis());
+	}
+
+	@Override
+	public void updateLastSentTime(Connection txn, ContactId c, MessageId m,
+			int maxLatency, long time)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE statuses SET lastSentTime = ?, "
+					+ "txCount = txCount + 1 WHERE messageId = ? "
+					+ "AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setLong(1, time);
+			ps.setBytes(2, m.getBytes());
+			ps.setInt(3, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void updateTransportKeys(Connection txn, KeySet ks)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			// Update the outgoing keys
+			String sql = "UPDATE outgoingKeys SET rotationPeriod = ?,"
+					+ " tagKey = ?, headerKey = ?, stream = ?"
+					+ " WHERE transportId = ? AND keySetId = ?";
+			ps = txn.prepareStatement(sql);
+			TransportKeys k = ks.getTransportKeys();
+			OutgoingKeys outCurr = k.getCurrentOutgoingKeys();
+			ps.setLong(1, outCurr.getRotationPeriod());
+			ps.setBytes(2, outCurr.getTagKey().getBytes());
+			ps.setBytes(3, outCurr.getHeaderKey().getBytes());
+			ps.setLong(4, outCurr.getStreamCounter());
+			ps.setString(5, k.getTransportId().getString());
+			ps.setInt(6, ks.getKeySetId().getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+			// Update the incoming keys
+			sql = "UPDATE incomingKeys SET rotationPeriod = ?,"
+					+ " tagKey = ?, headerKey = ?, base = ?, bitmap = ?"
+					+ " WHERE transportId = ? AND keySetId = ?"
+					+ " AND periodOffset = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setString(6, k.getTransportId().getString());
+			ps.setInt(7, ks.getKeySetId().getInt());
+			// Previous rotation period
+			IncomingKeys inPrev = k.getPreviousIncomingKeys();
+			ps.setLong(1, inPrev.getRotationPeriod());
+			ps.setBytes(2, inPrev.getTagKey().getBytes());
+			ps.setBytes(3, inPrev.getHeaderKey().getBytes());
+			ps.setLong(4, inPrev.getWindowBase());
+			ps.setBytes(5, inPrev.getWindowBitmap());
+			ps.setInt(8, OFFSET_PREV);
+			ps.addBatch();
+			// Current rotation period
+			IncomingKeys inCurr = k.getCurrentIncomingKeys();
+			ps.setLong(1, inCurr.getRotationPeriod());
+			ps.setBytes(2, inCurr.getTagKey().getBytes());
+			ps.setBytes(3, inCurr.getHeaderKey().getBytes());
+			ps.setLong(4, inCurr.getWindowBase());
+			ps.setBytes(5, inCurr.getWindowBitmap());
+			ps.setInt(8, OFFSET_CURR);
+			ps.addBatch();
+			// Next rotation period
+			IncomingKeys inNext = k.getNextIncomingKeys();
+			ps.setLong(1, inNext.getRotationPeriod());
+			ps.setBytes(2, inNext.getTagKey().getBytes());
+			ps.setBytes(3, inNext.getHeaderKey().getBytes());
+			ps.setLong(4, inNext.getWindowBase());
+			ps.setBytes(5, inNext.getWindowBitmap());
+			ps.setInt(8, OFFSET_NEXT);
+			ps.addBatch();
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != 3) throw new DbStateException();
+			for (int rows : batchAffected)
+				if (rows < 0 || rows > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+}
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/db/JdbcDatabaseOrig.java b/bramble-core/src/main/java/org/briarproject/bramble/db/JdbcDatabaseOrig.java
new file mode 100644
index 0000000000000000000000000000000000000000..8b01630ac68498413ffb5fdfedacf4ad1db17f88
--- /dev/null
+++ b/bramble-core/src/main/java/org/briarproject/bramble/db/JdbcDatabaseOrig.java
@@ -0,0 +1,2984 @@
+package org.briarproject.bramble.db;
+
+import org.briarproject.bramble.api.contact.Contact;
+import org.briarproject.bramble.api.contact.ContactId;
+import org.briarproject.bramble.api.crypto.SecretKey;
+import org.briarproject.bramble.api.db.DataTooNewException;
+import org.briarproject.bramble.api.db.DataTooOldException;
+import org.briarproject.bramble.api.db.DbClosedException;
+import org.briarproject.bramble.api.db.DbException;
+import org.briarproject.bramble.api.db.Metadata;
+import org.briarproject.bramble.api.db.MigrationListener;
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.api.identity.AuthorId;
+import org.briarproject.bramble.api.identity.LocalAuthor;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.plugin.TransportId;
+import org.briarproject.bramble.api.settings.Settings;
+import org.briarproject.bramble.api.sync.ClientId;
+import org.briarproject.bramble.api.sync.Group;
+import org.briarproject.bramble.api.sync.Group.Visibility;
+import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.bramble.api.sync.Message;
+import org.briarproject.bramble.api.sync.MessageId;
+import org.briarproject.bramble.api.sync.MessageStatus;
+import org.briarproject.bramble.api.sync.ValidationManager.State;
+import org.briarproject.bramble.api.system.Clock;
+import org.briarproject.bramble.api.transport.IncomingKeys;
+import org.briarproject.bramble.api.transport.KeySet;
+import org.briarproject.bramble.api.transport.KeySetId;
+import org.briarproject.bramble.api.transport.OutgoingKeys;
+import org.briarproject.bramble.api.transport.TransportKeys;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.concurrent.locks.Condition;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.logging.Logger;
+
+import javax.annotation.Nullable;
+
+import static java.sql.Types.INTEGER;
+import static java.util.Collections.singletonList;
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Level.WARNING;
+import static org.briarproject.bramble.api.db.Metadata.REMOVE;
+import static org.briarproject.bramble.api.sync.Group.Visibility.INVISIBLE;
+import static org.briarproject.bramble.api.sync.Group.Visibility.SHARED;
+import static org.briarproject.bramble.api.sync.Group.Visibility.VISIBLE;
+import static org.briarproject.bramble.api.sync.ValidationManager.State.DELIVERED;
+import static org.briarproject.bramble.api.sync.ValidationManager.State.PENDING;
+import static org.briarproject.bramble.api.sync.ValidationManager.State.UNKNOWN;
+import static org.briarproject.bramble.db.DatabaseConstants.DB_SETTINGS_NAMESPACE;
+import static org.briarproject.bramble.db.DatabaseConstants.SCHEMA_VERSION_KEY;
+import static org.briarproject.bramble.db.ExponentialBackoff.calculateExpiry;
+import static org.briarproject.bramble.util.LogUtils.logException;
+
+/**
+ * A generic database implementation that can be used with any JDBC-compatible
+ * database library.
+ */
+@NotNullByDefault
+abstract class JdbcDatabaseOrig implements Database<Connection> {
+
+	// Package access for testing
+	static final int CODE_SCHEMA_VERSION = 39;
+
+	// Rotation period offsets for incoming transport keys
+	private static final int OFFSET_PREV = -1;
+	private static final int OFFSET_CURR = 0;
+	private static final int OFFSET_NEXT = 1;
+
+	private static final String CREATE_SETTINGS =
+			"CREATE TABLE settings"
+					+ " (namespace _STRING NOT NULL,"
+					+ " settingKey _STRING NOT NULL,"
+					+ " value _STRING NOT NULL,"
+					+ " PRIMARY KEY (namespace, settingKey))";
+
+	private static final String CREATE_LOCAL_AUTHORS =
+			"CREATE TABLE localAuthors"
+					+ " (authorId _HASH NOT NULL,"
+					+ " formatVersion INT NOT NULL,"
+					+ " name _STRING NOT NULL,"
+					+ " publicKey _BINARY NOT NULL,"
+					+ " privateKey _BINARY NOT NULL,"
+					+ " created BIGINT NOT NULL,"
+					+ " PRIMARY KEY (authorId))";
+
+	private static final String CREATE_CONTACTS =
+			"CREATE TABLE contacts"
+					+ " (contactId _COUNTER,"
+					+ " authorId _HASH NOT NULL,"
+					+ " formatVersion INT NOT NULL,"
+					+ " name _STRING NOT NULL,"
+					+ " publicKey _BINARY NOT NULL,"
+					+ " localAuthorId _HASH NOT NULL,"
+					+ " verified BOOLEAN NOT NULL,"
+					+ " active BOOLEAN NOT NULL,"
+					+ " PRIMARY KEY (contactId),"
+					+ " FOREIGN KEY (localAuthorId)"
+					+ " REFERENCES localAuthors (authorId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_GROUPS =
+			"CREATE TABLE groups"
+					+ " (groupId _HASH NOT NULL,"
+					+ " clientId _STRING NOT NULL,"
+					+ " majorVersion INT NOT NULL,"
+					+ " descriptor _BINARY NOT NULL,"
+					+ " PRIMARY KEY (groupId))";
+
+	private static final String CREATE_GROUP_METADATA =
+			"CREATE TABLE groupMetadata"
+					+ " (groupId _HASH NOT NULL,"
+					+ " metaKey _STRING NOT NULL,"
+					+ " value _BINARY NOT NULL,"
+					+ " PRIMARY KEY (groupId, metaKey),"
+					+ " FOREIGN KEY (groupId)"
+					+ " REFERENCES groups (groupId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_GROUP_VISIBILITIES =
+			"CREATE TABLE groupVisibilities"
+					+ " (contactId INT NOT NULL,"
+					+ " groupId _HASH NOT NULL,"
+					+ " shared BOOLEAN NOT NULL,"
+					+ " PRIMARY KEY (contactId, groupId),"
+					+ " FOREIGN KEY (contactId)"
+					+ " REFERENCES contacts (contactId)"
+					+ " ON DELETE CASCADE,"
+					+ " FOREIGN KEY (groupId)"
+					+ " REFERENCES groups (groupId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_MESSAGES =
+			"CREATE TABLE messages"
+					+ " (messageId _HASH NOT NULL,"
+					+ " groupId _HASH NOT NULL,"
+					+ " timestamp BIGINT NOT NULL,"
+					+ " state INT NOT NULL,"
+					+ " shared BOOLEAN NOT NULL,"
+					+ " length INT NOT NULL,"
+					+ " raw BLOB," // Null if message has been deleted
+					+ " PRIMARY KEY (messageId),"
+					+ " FOREIGN KEY (groupId)"
+					+ " REFERENCES groups (groupId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_MESSAGE_METADATA =
+			"CREATE TABLE messageMetadata"
+					+ " (messageId _HASH NOT NULL,"
+					+ " groupId _HASH NOT NULL," // Denormalised
+					+ " state INT NOT NULL," // Denormalised
+					+ " metaKey _STRING NOT NULL,"
+					+ " value _BINARY NOT NULL,"
+					+ " PRIMARY KEY (messageId, metaKey),"
+					+ " FOREIGN KEY (messageId)"
+					+ " REFERENCES messages (messageId)"
+					+ " ON DELETE CASCADE,"
+					+ " FOREIGN KEY (groupId)"
+					+ " REFERENCES groups (groupId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_MESSAGE_DEPENDENCIES =
+			"CREATE TABLE messageDependencies"
+					+ " (groupId _HASH NOT NULL,"
+					+ " messageId _HASH NOT NULL,"
+					+ " dependencyId _HASH NOT NULL," // Not a foreign key
+					+ " messageState INT NOT NULL," // Denormalised
+					// Denormalised, null if dependency is missing or in a
+					// different group
+					+ " dependencyState INT,"
+					+ " FOREIGN KEY (groupId)"
+					+ " REFERENCES groups (groupId)"
+					+ " ON DELETE CASCADE,"
+					+ " FOREIGN KEY (messageId)"
+					+ " REFERENCES messages (messageId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_OFFERS =
+			"CREATE TABLE offers"
+					+ " (messageId _HASH NOT NULL," // Not a foreign key
+					+ " contactId INT NOT NULL,"
+					+ " PRIMARY KEY (messageId, contactId),"
+					+ " FOREIGN KEY (contactId)"
+					+ " REFERENCES contacts (contactId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_STATUSES =
+			"CREATE TABLE statuses"
+					+ " (messageId _HASH NOT NULL,"
+					+ " contactId INT NOT NULL,"
+					+ " groupId _HASH NOT NULL," // Denormalised
+					+ " timestamp BIGINT NOT NULL," // Denormalised
+					+ " length INT NOT NULL," // Denormalised
+					+ " state INT NOT NULL," // Denormalised
+					+ " groupShared BOOLEAN NOT NULL," // Denormalised
+					+ " messageShared BOOLEAN NOT NULL," // Denormalised
+					+ " deleted BOOLEAN NOT NULL," // Denormalised
+					+ " ack BOOLEAN NOT NULL,"
+					+ " seen BOOLEAN NOT NULL,"
+					+ " requested BOOLEAN NOT NULL,"
+					+ " expiry BIGINT NOT NULL,"
+					+ " txCount INT NOT NULL,"
+					+ " PRIMARY KEY (messageId, contactId),"
+					+ " FOREIGN KEY (messageId)"
+					+ " REFERENCES messages (messageId)"
+					+ " ON DELETE CASCADE,"
+					+ " FOREIGN KEY (contactId)"
+					+ " REFERENCES contacts (contactId)"
+					+ " ON DELETE CASCADE,"
+					+ " FOREIGN KEY (groupId)"
+					+ " REFERENCES groups (groupId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_TRANSPORTS =
+			"CREATE TABLE transports"
+					+ " (transportId _STRING NOT NULL,"
+					+ " maxLatency INT NOT NULL,"
+					+ " PRIMARY KEY (transportId))";
+
+	private static final String CREATE_OUTGOING_KEYS =
+			"CREATE TABLE outgoingKeys"
+					+ " (transportId _STRING NOT NULL,"
+					+ " keySetId _COUNTER,"
+					+ " rotationPeriod BIGINT NOT NULL,"
+					+ " contactId INT NOT NULL,"
+					+ " tagKey _SECRET NOT NULL,"
+					+ " headerKey _SECRET NOT NULL,"
+					+ " stream BIGINT NOT NULL,"
+					+ " active BOOLEAN NOT NULL,"
+					+ " PRIMARY KEY (transportId, keySetId),"
+					+ " FOREIGN KEY (transportId)"
+					+ " REFERENCES transports (transportId)"
+					+ " ON DELETE CASCADE,"
+					+ " UNIQUE (keySetId),"
+					+ " FOREIGN KEY (contactId)"
+					+ " REFERENCES contacts (contactId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_INCOMING_KEYS =
+			"CREATE TABLE incomingKeys"
+					+ " (transportId _STRING NOT NULL,"
+					+ " keySetId INT NOT NULL,"
+					+ " rotationPeriod BIGINT NOT NULL,"
+					+ " contactId INT NOT NULL,"
+					+ " tagKey _SECRET NOT NULL,"
+					+ " headerKey _SECRET NOT NULL,"
+					+ " base BIGINT NOT NULL,"
+					+ " bitmap _BINARY NOT NULL,"
+					+ " periodOffset INT NOT NULL,"
+					+ " PRIMARY KEY (transportId, keySetId, periodOffset),"
+					+ " FOREIGN KEY (transportId)"
+					+ " REFERENCES transports (transportId)"
+					+ " ON DELETE CASCADE,"
+					+ " FOREIGN KEY (keySetId)"
+					+ " REFERENCES outgoingKeys (keySetId)"
+					+ " ON DELETE CASCADE,"
+					+ " FOREIGN KEY (contactId)"
+					+ " REFERENCES contacts (contactId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String INDEX_CONTACTS_BY_AUTHOR_ID =
+			"CREATE INDEX IF NOT EXISTS contactsByAuthorId"
+					+ " ON contacts (authorId)";
+
+	private static final String INDEX_GROUPS_BY_CLIENT_ID_MAJOR_VERSION =
+			"CREATE INDEX IF NOT EXISTS groupsByClientIdMajorVersion"
+					+ " ON groups (clientId, majorVersion)";
+
+	private static final String INDEX_MESSAGE_METADATA_BY_GROUP_ID_STATE =
+			"CREATE INDEX IF NOT EXISTS messageMetadataByGroupIdState"
+					+ " ON messageMetadata (groupId, state)";
+
+	private static final String INDEX_MESSAGE_DEPENDENCIES_BY_DEPENDENCY_ID =
+			"CREATE INDEX IF NOT EXISTS messageDependenciesByDependencyId"
+					+ " ON messageDependencies (dependencyId)";
+
+	private static final String INDEX_STATUSES_BY_CONTACT_ID_GROUP_ID =
+			"CREATE INDEX IF NOT EXISTS statusesByContactIdGroupId"
+					+ " ON statuses (contactId, groupId)";
+
+	private static final String INDEX_STATUSES_BY_CONTACT_ID_TIMESTAMP =
+			"CREATE INDEX IF NOT EXISTS statusesByContactIdTimestamp"
+					+ " ON statuses (contactId, timestamp)";
+
+	private static final Logger LOG =
+			Logger.getLogger(JdbcDatabase.class.getName());
+
+	// Different database libraries use different names for certain types
+	private final String hashType, secretType, binaryType;
+	private final String counterType, stringType;
+	private final Clock clock;
+
+	// Locking: connectionsLock
+	private final LinkedList<Connection> connections = new LinkedList<>();
+
+	private int openConnections = 0; // Locking: connectionsLock
+	private boolean closed = false; // Locking: connectionsLock
+
+	@Nullable
+	protected abstract Connection createConnection() throws SQLException;
+
+	private final Lock connectionsLock = new ReentrantLock();
+	private final Condition connectionsChanged = connectionsLock.newCondition();
+
+	JdbcDatabaseOrig(String hashType, String secretType, String binaryType,
+			String counterType, String stringType, Clock clock) {
+		this.hashType = hashType;
+		this.secretType = secretType;
+		this.binaryType = binaryType;
+		this.counterType = counterType;
+		this.stringType = stringType;
+		this.clock = clock;
+	}
+
+	protected void open(String driverClass, boolean reopen, SecretKey key,
+			@Nullable MigrationListener listener) throws DbException {
+		// Load the JDBC driver
+		try {
+			Class.forName(driverClass);
+		} catch (ClassNotFoundException e) {
+			throw new DbException(e);
+		}
+		// Open the database and create the tables and indexes if necessary
+		Connection txn = startTransaction();
+		try {
+			if (reopen) {
+				checkSchemaVersion(txn, listener);
+			} else {
+				createTables(txn);
+				storeSchemaVersion(txn, CODE_SCHEMA_VERSION);
+			}
+			createIndexes(txn);
+			commitTransaction(txn);
+		} catch (DbException e) {
+			abortTransaction(txn);
+			throw e;
+		}
+	}
+
+	/**
+	 * Compares the schema version stored in the database with the schema
+	 * version used by the current code and applies any suitable migrations to
+	 * the data if necessary.
+	 *
+	 * @throws DataTooNewException if the data uses a newer schema than the
+	 * current code
+	 * @throws DataTooOldException if the data uses an older schema than the
+	 * current code and cannot be migrated
+	 */
+	private void checkSchemaVersion(Connection txn,
+			@Nullable MigrationListener listener) throws DbException {
+		Settings s = getSettings(txn, DB_SETTINGS_NAMESPACE);
+		int dataSchemaVersion = s.getInt(SCHEMA_VERSION_KEY, -1);
+		if (dataSchemaVersion == -1) throw new DbException();
+		if (dataSchemaVersion == CODE_SCHEMA_VERSION) return;
+		if (CODE_SCHEMA_VERSION < dataSchemaVersion)
+			throw new DataTooNewException();
+		// Apply any suitable migrations in order
+		for (Migration<Connection> m : getMigrations()) {
+			int start = m.getStartVersion(), end = m.getEndVersion();
+			if (start == dataSchemaVersion) {
+				if (LOG.isLoggable(INFO))
+					LOG.info("Migrating from schema " + start + " to " + end);
+				if (listener != null) listener.onMigrationRun();
+				// Apply the migration
+				m.migrate(txn);
+				// Store the new schema version
+				storeSchemaVersion(txn, end);
+				dataSchemaVersion = end;
+			}
+		}
+		if (dataSchemaVersion != CODE_SCHEMA_VERSION)
+			throw new DataTooOldException();
+	}
+
+	// Package access for testing
+	List<Migration<Connection>> getMigrations() {
+		return singletonList(new Migration38_39());
+	}
+
+	private void storeSchemaVersion(Connection txn, int version)
+			throws DbException {
+		Settings s = new Settings();
+		s.putInt(SCHEMA_VERSION_KEY, version);
+		mergeSettings(txn, s, DB_SETTINGS_NAMESPACE);
+	}
+
+	private void tryToClose(@Nullable ResultSet rs) {
+		try {
+			if (rs != null) rs.close();
+		} catch (SQLException e) {
+			logException(LOG, WARNING, e);
+		}
+	}
+
+	private void tryToClose(@Nullable Statement s) {
+		try {
+			if (s != null) s.close();
+		} catch (SQLException e) {
+			logException(LOG, WARNING, e);
+		}
+	}
+
+	private void createTables(Connection txn) throws DbException {
+		Statement s = null;
+		try {
+			s = txn.createStatement();
+			s.executeUpdate(insertTypeNames(CREATE_SETTINGS));
+			s.executeUpdate(insertTypeNames(CREATE_LOCAL_AUTHORS));
+			s.executeUpdate(insertTypeNames(CREATE_CONTACTS));
+			s.executeUpdate(insertTypeNames(CREATE_GROUPS));
+			s.executeUpdate(insertTypeNames(CREATE_GROUP_METADATA));
+			s.executeUpdate(insertTypeNames(CREATE_GROUP_VISIBILITIES));
+			s.executeUpdate(insertTypeNames(CREATE_MESSAGES));
+			s.executeUpdate(insertTypeNames(CREATE_MESSAGE_METADATA));
+			s.executeUpdate(insertTypeNames(CREATE_MESSAGE_DEPENDENCIES));
+			s.executeUpdate(insertTypeNames(CREATE_OFFERS));
+			s.executeUpdate(insertTypeNames(CREATE_STATUSES));
+			s.executeUpdate(insertTypeNames(CREATE_TRANSPORTS));
+			s.executeUpdate(insertTypeNames(CREATE_OUTGOING_KEYS));
+			s.executeUpdate(insertTypeNames(CREATE_INCOMING_KEYS));
+			s.close();
+		} catch (SQLException e) {
+			tryToClose(s);
+			throw new DbException(e);
+		}
+	}
+
+	private void createIndexes(Connection txn) throws DbException {
+		Statement s = null;
+		try {
+			s = txn.createStatement();
+			s.executeUpdate(INDEX_CONTACTS_BY_AUTHOR_ID);
+			s.executeUpdate(INDEX_GROUPS_BY_CLIENT_ID_MAJOR_VERSION);
+			s.executeUpdate(INDEX_MESSAGE_METADATA_BY_GROUP_ID_STATE);
+			s.executeUpdate(INDEX_MESSAGE_DEPENDENCIES_BY_DEPENDENCY_ID);
+			s.executeUpdate(INDEX_STATUSES_BY_CONTACT_ID_GROUP_ID);
+			s.executeUpdate(INDEX_STATUSES_BY_CONTACT_ID_TIMESTAMP);
+			s.close();
+		} catch (SQLException e) {
+			tryToClose(s);
+			throw new DbException(e);
+		}
+	}
+
+	private String insertTypeNames(String s) {
+		s = s.replaceAll("_HASH", hashType);
+		s = s.replaceAll("_SECRET", secretType);
+		s = s.replaceAll("_BINARY", binaryType);
+		s = s.replaceAll("_COUNTER", counterType);
+		s = s.replaceAll("_STRING", stringType);
+		return s;
+	}
+
+	@Override
+	public Connection startTransaction() throws DbException {
+		Connection txn;
+		connectionsLock.lock();
+		try {
+			if (closed) throw new DbClosedException();
+			txn = connections.poll();
+		} finally {
+			connectionsLock.unlock();
+		}
+		try {
+			if (txn == null) {
+				// Open a new connection
+				txn = createConnection();
+				if (txn == null) throw new DbException();
+				txn.setAutoCommit(false);
+				connectionsLock.lock();
+				try {
+					openConnections++;
+				} finally {
+					connectionsLock.unlock();
+				}
+			}
+		} catch (SQLException e) {
+			throw new DbException(e);
+		}
+		return txn;
+	}
+
+	@Override
+	public void abortTransaction(Connection txn) {
+		try {
+			txn.rollback();
+			connectionsLock.lock();
+			try {
+				connections.add(txn);
+				connectionsChanged.signalAll();
+			} finally {
+				connectionsLock.unlock();
+			}
+		} catch (SQLException e) {
+			// Try to close the connection
+			logException(LOG, WARNING, e);
+			try {
+				txn.close();
+			} catch (SQLException e1) {
+				logException(LOG, WARNING, e1);
+			}
+			// Whatever happens, allow the database to close
+			connectionsLock.lock();
+			try {
+				openConnections--;
+				connectionsChanged.signalAll();
+			} finally {
+				connectionsLock.unlock();
+			}
+		}
+	}
+
+	@Override
+	public void commitTransaction(Connection txn) throws DbException {
+		try {
+			txn.commit();
+		} catch (SQLException e) {
+			throw new DbException(e);
+		}
+		connectionsLock.lock();
+		try {
+			connections.add(txn);
+			connectionsChanged.signalAll();
+		} finally {
+			connectionsLock.unlock();
+		}
+	}
+
+	void closeAllConnections() throws SQLException {
+		boolean interrupted = false;
+		connectionsLock.lock();
+		try {
+			closed = true;
+			for (Connection c : connections) c.close();
+			openConnections -= connections.size();
+			connections.clear();
+			while (openConnections > 0) {
+				try {
+					connectionsChanged.await();
+				} catch (InterruptedException e) {
+					LOG.warning("Interrupted while closing connections");
+					interrupted = true;
+				}
+				for (Connection c : connections) c.close();
+				openConnections -= connections.size();
+				connections.clear();
+			}
+		} finally {
+			connectionsLock.unlock();
+		}
+
+		if (interrupted) Thread.currentThread().interrupt();
+	}
+
+	@Override
+	public ContactId addContact(Connection txn, Author remote, AuthorId local,
+			boolean verified, boolean active) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			// Create a contact row
+			String sql = "INSERT INTO contacts"
+					+ " (authorId, formatVersion, name, publicKey,"
+					+ " localAuthorId,"
+					+ " verified, active)"
+					+ " VALUES (?, ?, ?, ?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, remote.getId().getBytes());
+			ps.setInt(2, remote.getFormatVersion());
+			ps.setString(3, remote.getName());
+			ps.setBytes(4, remote.getPublicKey());
+			ps.setBytes(5, local.getBytes());
+			ps.setBoolean(6, verified);
+			ps.setBoolean(7, active);
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+			// Get the new (highest) contact ID
+			sql = "SELECT contactId FROM contacts"
+					+ " ORDER BY contactId DESC LIMIT 1";
+			ps = txn.prepareStatement(sql);
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			ContactId c = new ContactId(rs.getInt(1));
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return c;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void addGroup(Connection txn, Group g) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "INSERT INTO groups"
+					+ " (groupId, clientId, majorVersion, descriptor)"
+					+ " VALUES (?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getId().getBytes());
+			ps.setString(2, g.getClientId().getString());
+			ps.setInt(3, g.getMajorVersion());
+			ps.setBytes(4, g.getDescriptor());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void addGroupVisibility(Connection txn, ContactId c, GroupId g,
+			boolean groupShared) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "INSERT INTO groupVisibilities"
+					+ " (contactId, groupId, shared)"
+					+ " VALUES (?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setBytes(2, g.getBytes());
+			ps.setBoolean(3, groupShared);
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+			// Create a status row for each message in the group
+			addStatus(txn, c, g, groupShared);
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	private void addStatus(Connection txn, ContactId c, GroupId g,
+			boolean groupShared) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT messageId, timestamp, state, shared,"
+					+ " length, raw IS NULL"
+					+ " FROM messages"
+					+ " WHERE groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			rs = ps.executeQuery();
+			while (rs.next()) {
+				MessageId id = new MessageId(rs.getBytes(1));
+				long timestamp = rs.getLong(2);
+				State state = State.fromValue(rs.getInt(3));
+				boolean messageShared = rs.getBoolean(4);
+				int length = rs.getInt(5);
+				boolean deleted = rs.getBoolean(6);
+				boolean seen = removeOfferedMessage(txn, c, id);
+				addStatus(txn, id, c, g, timestamp, length, state, groupShared,
+						messageShared, deleted, seen);
+			}
+			rs.close();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void addLocalAuthor(Connection txn, LocalAuthor a)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "INSERT INTO localAuthors"
+					+ " (authorId, formatVersion, name, publicKey,"
+					+ " privateKey, created)"
+					+ " VALUES (?, ?, ?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, a.getId().getBytes());
+			ps.setInt(2, a.getFormatVersion());
+			ps.setString(3, a.getName());
+			ps.setBytes(4, a.getPublicKey());
+			ps.setBytes(5, a.getPrivateKey());
+			ps.setLong(6, a.getTimeCreated());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void addMessage(Connection txn, Message m, State state,
+			boolean messageShared, @Nullable ContactId sender)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "INSERT INTO messages (messageId, groupId, timestamp,"
+					+ " state, shared, length, raw)"
+					+ " VALUES (?, ?, ?, ?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getId().getBytes());
+			ps.setBytes(2, m.getGroupId().getBytes());
+			ps.setLong(3, m.getTimestamp());
+			ps.setInt(4, state.getValue());
+			ps.setBoolean(5, messageShared);
+			byte[] raw = m.getRaw();
+			ps.setInt(6, raw.length);
+			ps.setBytes(7, raw);
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+			// Create a status row for each contact that can see the group
+			Map<ContactId, Boolean> visibility =
+					getGroupVisibility(txn, m.getGroupId());
+			for (Entry<ContactId, Boolean> e : visibility.entrySet()) {
+				ContactId c = e.getKey();
+				boolean offered = removeOfferedMessage(txn, c, m.getId());
+				boolean seen = offered || (sender != null && c.equals(sender));
+				addStatus(txn, m.getId(), c, m.getGroupId(), m.getTimestamp(),
+						m.getLength(), state, e.getValue(), messageShared,
+						false, seen);
+			}
+			// Update denormalised column in messageDependencies if dependency
+			// is in same group as dependent
+			sql = "UPDATE messageDependencies SET dependencyState = ?"
+					+ " WHERE groupId = ? AND dependencyId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, state.getValue());
+			ps.setBytes(2, m.getGroupId().getBytes());
+			ps.setBytes(3, m.getId().getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void addOfferedMessage(Connection txn, ContactId c, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM offers"
+					+ " WHERE messageId = ? AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			if (found) return;
+			sql = "INSERT INTO offers (messageId, contactId) VALUES (?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	private void addStatus(Connection txn, MessageId m, ContactId c, GroupId g,
+			long timestamp, int length, State state, boolean groupShared,
+			boolean messageShared, boolean deleted, boolean seen)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "INSERT INTO statuses (messageId, contactId, groupId,"
+					+ " timestamp, length, state, groupShared, messageShared,"
+					+ " deleted, ack, seen, requested, expiry, txCount)"
+					+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, FALSE, 0, 0)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			ps.setBytes(3, g.getBytes());
+			ps.setLong(4, timestamp);
+			ps.setInt(5, length);
+			ps.setInt(6, state.getValue());
+			ps.setBoolean(7, groupShared);
+			ps.setBoolean(8, messageShared);
+			ps.setBoolean(9, deleted);
+			ps.setBoolean(10, seen);
+			ps.setBoolean(11, seen);
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void addMessageDependency(Connection txn, Message dependent,
+			MessageId dependency, State dependentState) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			// Get state of dependency if present and in same group as dependent
+			String sql = "SELECT state FROM messages"
+					+ " WHERE messageId = ? AND groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, dependency.getBytes());
+			ps.setBytes(2, dependent.getGroupId().getBytes());
+			rs = ps.executeQuery();
+			State dependencyState = null;
+			if (rs.next()) {
+				dependencyState = State.fromValue(rs.getInt(1));
+				if (rs.next()) throw new DbStateException();
+			}
+			rs.close();
+			ps.close();
+			// Create messageDependencies row
+			sql = "INSERT INTO messageDependencies"
+					+ " (groupId, messageId, dependencyId, messageState,"
+					+ " dependencyState)"
+					+ " VALUES (?, ?, ?, ? ,?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, dependent.getGroupId().getBytes());
+			ps.setBytes(2, dependent.getId().getBytes());
+			ps.setBytes(3, dependency.getBytes());
+			ps.setInt(4, dependentState.getValue());
+			if (dependencyState == null) ps.setNull(5, INTEGER);
+			else ps.setInt(5, dependencyState.getValue());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void addTransport(Connection txn, TransportId t, int maxLatency)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "INSERT INTO transports (transportId, maxLatency)"
+					+ " VALUES (?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, t.getString());
+			ps.setLong(2, maxLatency);
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public KeySetId addTransportKeys(Connection txn, ContactId c,
+			TransportKeys k) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			// Store the outgoing keys
+			String sql = "INSERT INTO outgoingKeys (contactId, transportId,"
+					+ " rotationPeriod, tagKey, headerKey, stream, active)"
+					+ " VALUES (?, ?, ?, ?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setString(2, k.getTransportId().getString());
+			OutgoingKeys outCurr = k.getCurrentOutgoingKeys();
+			ps.setLong(3, outCurr.getRotationPeriod());
+			ps.setBytes(4, outCurr.getTagKey().getBytes());
+			ps.setBytes(5, outCurr.getHeaderKey().getBytes());
+			ps.setLong(6, outCurr.getStreamCounter());
+			ps.setBoolean(7, outCurr.isActive());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+			// Get the new (highest) key set ID
+			sql = "SELECT keySetId FROM outgoingKeys"
+					+ " ORDER BY keySetId DESC LIMIT 1";
+			ps = txn.prepareStatement(sql);
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			KeySetId keySetId = new KeySetId(rs.getInt(1));
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			// Store the incoming keys
+			sql = "INSERT INTO incomingKeys (keySetId, contactId, transportId,"
+					+ " rotationPeriod, tagKey, headerKey, base, bitmap,"
+					+ " periodOffset)"
+					+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, keySetId.getInt());
+			ps.setInt(2, c.getInt());
+			ps.setString(3, k.getTransportId().getString());
+			// Previous rotation period
+			IncomingKeys inPrev = k.getPreviousIncomingKeys();
+			ps.setLong(4, inPrev.getRotationPeriod());
+			ps.setBytes(5, inPrev.getTagKey().getBytes());
+			ps.setBytes(6, inPrev.getHeaderKey().getBytes());
+			ps.setLong(7, inPrev.getWindowBase());
+			ps.setBytes(8, inPrev.getWindowBitmap());
+			ps.setInt(9, OFFSET_PREV);
+			ps.addBatch();
+			// Current rotation period
+			IncomingKeys inCurr = k.getCurrentIncomingKeys();
+			ps.setLong(4, inCurr.getRotationPeriod());
+			ps.setBytes(5, inCurr.getTagKey().getBytes());
+			ps.setBytes(6, inCurr.getHeaderKey().getBytes());
+			ps.setLong(7, inCurr.getWindowBase());
+			ps.setBytes(8, inCurr.getWindowBitmap());
+			ps.setInt(9, OFFSET_CURR);
+			ps.addBatch();
+			// Next rotation period
+			IncomingKeys inNext = k.getNextIncomingKeys();
+			ps.setLong(4, inNext.getRotationPeriod());
+			ps.setBytes(5, inNext.getTagKey().getBytes());
+			ps.setBytes(6, inNext.getHeaderKey().getBytes());
+			ps.setLong(7, inNext.getWindowBase());
+			ps.setBytes(8, inNext.getWindowBitmap());
+			ps.setInt(9, OFFSET_NEXT);
+			ps.addBatch();
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != 3) throw new DbStateException();
+			for (int rows : batchAffected)
+				if (rows != 1) throw new DbStateException();
+			ps.close();
+			return keySetId;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public boolean containsContact(Connection txn, AuthorId remote,
+			AuthorId local) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM contacts"
+					+ " WHERE authorId = ? AND localAuthorId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, remote.getBytes());
+			ps.setBytes(2, local.getBytes());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return found;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public boolean containsContact(Connection txn, ContactId c)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM contacts WHERE contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return found;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public boolean containsGroup(Connection txn, GroupId g)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM groups WHERE groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return found;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public boolean containsLocalAuthor(Connection txn, AuthorId a)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM localAuthors WHERE authorId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, a.getBytes());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return found;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public boolean containsMessage(Connection txn, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM messages WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return found;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public boolean containsTransport(Connection txn, TransportId t)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM transports WHERE transportId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, t.getString());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return found;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public boolean containsVisibleMessage(Connection txn, ContactId c,
+			MessageId m) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM statuses"
+					+ " WHERE messageId = ? AND contactId = ?"
+					+ " AND messageShared = TRUE";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return found;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public int countOfferedMessages(Connection txn, ContactId c)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT COUNT (messageId) FROM offers "
+					+ " WHERE contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbException();
+			int count = rs.getInt(1);
+			if (rs.next()) throw new DbException();
+			rs.close();
+			ps.close();
+			return count;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void deleteMessage(Connection txn, MessageId m) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE messages SET raw = NULL WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			if (affected > 1) throw new DbStateException();
+			ps.close();
+			// Update denormalised column in statuses
+			sql = "UPDATE statuses SET deleted = TRUE WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void deleteMessageMetadata(Connection txn, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM messageMetadata WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Contact getContact(Connection txn, ContactId c) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT authorId, formatVersion, name, publicKey,"
+					+ " localAuthorId, verified, active"
+					+ " FROM contacts"
+					+ " WHERE contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			AuthorId authorId = new AuthorId(rs.getBytes(1));
+			int formatVersion = rs.getInt(2);
+			String name = rs.getString(3);
+			byte[] publicKey = rs.getBytes(4);
+			AuthorId localAuthorId = new AuthorId(rs.getBytes(5));
+			boolean verified = rs.getBoolean(6);
+			boolean active = rs.getBoolean(7);
+			rs.close();
+			ps.close();
+			Author author =
+					new Author(authorId, formatVersion, name, publicKey);
+			return new Contact(c, author, localAuthorId, verified, active);
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<Contact> getContacts(Connection txn)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT contactId, authorId, formatVersion, name,"
+					+ " publicKey, localAuthorId, verified, active"
+					+ " FROM contacts";
+			ps = txn.prepareStatement(sql);
+			rs = ps.executeQuery();
+			List<Contact> contacts = new ArrayList<>();
+			while (rs.next()) {
+				ContactId contactId = new ContactId(rs.getInt(1));
+				AuthorId authorId = new AuthorId(rs.getBytes(2));
+				int formatVersion = rs.getInt(3);
+				String name = rs.getString(4);
+				byte[] publicKey = rs.getBytes(5);
+				Author author =
+						new Author(authorId, formatVersion, name, publicKey);
+				AuthorId localAuthorId = new AuthorId(rs.getBytes(6));
+				boolean verified = rs.getBoolean(7);
+				boolean active = rs.getBoolean(8);
+				contacts.add(new Contact(contactId, author, localAuthorId,
+						verified, active));
+			}
+			rs.close();
+			ps.close();
+			return contacts;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<ContactId> getContacts(Connection txn, AuthorId local)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT contactId FROM contacts"
+					+ " WHERE localAuthorId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, local.getBytes());
+			rs = ps.executeQuery();
+			List<ContactId> ids = new ArrayList<>();
+			while (rs.next()) ids.add(new ContactId(rs.getInt(1)));
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<Contact> getContactsByAuthorId(Connection txn,
+			AuthorId remote) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT contactId, formatVersion, name, publicKey,"
+					+ " localAuthorId, verified, active"
+					+ " FROM contacts"
+					+ " WHERE authorId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, remote.getBytes());
+			rs = ps.executeQuery();
+			List<Contact> contacts = new ArrayList<>();
+			while (rs.next()) {
+				ContactId c = new ContactId(rs.getInt(1));
+				int formatVersion = rs.getInt(2);
+				String name = rs.getString(3);
+				byte[] publicKey = rs.getBytes(4);
+				AuthorId localAuthorId = new AuthorId(rs.getBytes(5));
+				boolean verified = rs.getBoolean(6);
+				boolean active = rs.getBoolean(7);
+				Author author =
+						new Author(remote, formatVersion, name, publicKey);
+				contacts.add(new Contact(c, author, localAuthorId, verified,
+						active));
+			}
+			rs.close();
+			ps.close();
+			return contacts;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Group getGroup(Connection txn, GroupId g) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT clientId, majorVersion, descriptor"
+					+ " FROM groups WHERE groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			ClientId clientId = new ClientId(rs.getString(1));
+			int majorVersion = rs.getInt(2);
+			byte[] descriptor = rs.getBytes(3);
+			rs.close();
+			ps.close();
+			return new Group(g, clientId, majorVersion, descriptor);
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<Group> getGroups(Connection txn, ClientId c,
+			int majorVersion) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT groupId, descriptor FROM groups"
+					+ " WHERE clientId = ? AND majorVersion = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, c.getString());
+			ps.setInt(2, majorVersion);
+			rs = ps.executeQuery();
+			List<Group> groups = new ArrayList<>();
+			while (rs.next()) {
+				GroupId id = new GroupId(rs.getBytes(1));
+				byte[] descriptor = rs.getBytes(2);
+				groups.add(new Group(id, c, majorVersion, descriptor));
+			}
+			rs.close();
+			ps.close();
+			return groups;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Visibility getGroupVisibility(Connection txn, ContactId c, GroupId g)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT shared FROM groupVisibilities"
+					+ " WHERE contactId = ? AND groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setBytes(2, g.getBytes());
+			rs = ps.executeQuery();
+			Visibility v;
+			if (rs.next()) v = rs.getBoolean(1) ? SHARED : VISIBLE;
+			else v = INVISIBLE;
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return v;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Map<ContactId, Boolean> getGroupVisibility(Connection txn, GroupId g)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT contactId, shared FROM groupVisibilities"
+					+ " WHERE groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			rs = ps.executeQuery();
+			Map<ContactId, Boolean> visible = new HashMap<>();
+			while (rs.next())
+				visible.put(new ContactId(rs.getInt(1)), rs.getBoolean(2));
+			rs.close();
+			ps.close();
+			return visible;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public LocalAuthor getLocalAuthor(Connection txn, AuthorId a)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT formatVersion, name, publicKey,"
+					+ " privateKey, created"
+					+ " FROM localAuthors"
+					+ " WHERE authorId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, a.getBytes());
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			int formatVersion = rs.getInt(1);
+			String name = rs.getString(2);
+			byte[] publicKey = rs.getBytes(3);
+			byte[] privateKey = rs.getBytes(4);
+			long created = rs.getLong(5);
+			LocalAuthor localAuthor = new LocalAuthor(a, formatVersion, name,
+					publicKey, privateKey, created);
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return localAuthor;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<LocalAuthor> getLocalAuthors(Connection txn)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT authorId, formatVersion, name, publicKey,"
+					+ " privateKey, created"
+					+ " FROM localAuthors";
+			ps = txn.prepareStatement(sql);
+			rs = ps.executeQuery();
+			List<LocalAuthor> authors = new ArrayList<>();
+			while (rs.next()) {
+				AuthorId authorId = new AuthorId(rs.getBytes(1));
+				int formatVersion = rs.getInt(2);
+				String name = rs.getString(3);
+				byte[] publicKey = rs.getBytes(4);
+				byte[] privateKey = rs.getBytes(5);
+				long created = rs.getLong(6);
+				authors.add(new LocalAuthor(authorId, formatVersion, name,
+						publicKey, privateKey, created));
+			}
+			rs.close();
+			ps.close();
+			return authors;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getMessageIds(Connection txn, GroupId g)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT messageId FROM messages"
+					+ " WHERE groupId = ? AND state = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			ps.setInt(2, DELIVERED.getValue());
+			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<>();
+			while (rs.next()) ids.add(new MessageId(rs.getBytes(1)));
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getMessageIds(Connection txn, GroupId g,
+			Metadata query) throws DbException {
+		// If there are no query terms, return all delivered messages
+		if (query.isEmpty()) return getMessageIds(txn, g);
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			// Retrieve the message IDs for each query term and intersect
+			Set<MessageId> intersection = null;
+			String sql = "SELECT messageId FROM messageMetadata"
+					+ " WHERE groupId = ? AND state = ?"
+					+ " AND metaKey = ? AND value = ?";
+			for (Entry<String, byte[]> e : query.entrySet()) {
+				ps = txn.prepareStatement(sql);
+				ps.setBytes(1, g.getBytes());
+				ps.setInt(2, DELIVERED.getValue());
+				ps.setString(3, e.getKey());
+				ps.setBytes(4, e.getValue());
+				rs = ps.executeQuery();
+				Set<MessageId> ids = new HashSet<>();
+				while (rs.next()) ids.add(new MessageId(rs.getBytes(1)));
+				rs.close();
+				ps.close();
+				if (intersection == null) intersection = ids;
+				else intersection.retainAll(ids);
+				// Return early if there are no matches
+				if (intersection.isEmpty()) return Collections.emptySet();
+			}
+			if (intersection == null) throw new AssertionError();
+			return intersection;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Map<MessageId, Metadata> getMessageMetadata(Connection txn,
+			GroupId g) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT messageId, metaKey, value"
+					+ " FROM messageMetadata"
+					+ " WHERE groupId = ? AND state = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			ps.setInt(2, DELIVERED.getValue());
+			rs = ps.executeQuery();
+			Map<MessageId, Metadata> all = new HashMap<>();
+			while (rs.next()) {
+				MessageId messageId = new MessageId(rs.getBytes(1));
+				Metadata metadata = all.get(messageId);
+				if (metadata == null) {
+					metadata = new Metadata();
+					all.put(messageId, metadata);
+				}
+				metadata.put(rs.getString(2), rs.getBytes(3));
+			}
+			rs.close();
+			ps.close();
+			return all;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Map<MessageId, Metadata> getMessageMetadata(Connection txn,
+			GroupId g, Metadata query) throws DbException {
+		// Retrieve the matching message IDs
+		Collection<MessageId> matches = getMessageIds(txn, g, query);
+		if (matches.isEmpty()) return Collections.emptyMap();
+		// Retrieve the metadata for each match
+		Map<MessageId, Metadata> all = new HashMap<>(matches.size());
+		for (MessageId m : matches) all.put(m, getMessageMetadata(txn, m));
+		return all;
+	}
+
+	@Override
+	public Metadata getGroupMetadata(Connection txn, GroupId g)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT metaKey, value FROM groupMetadata"
+					+ " WHERE groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			rs = ps.executeQuery();
+			Metadata metadata = new Metadata();
+			while (rs.next()) metadata.put(rs.getString(1), rs.getBytes(2));
+			rs.close();
+			ps.close();
+			return metadata;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Metadata getMessageMetadata(Connection txn, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT metaKey, value FROM messageMetadata"
+					+ " WHERE state = ? AND messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, DELIVERED.getValue());
+			ps.setBytes(2, m.getBytes());
+			rs = ps.executeQuery();
+			Metadata metadata = new Metadata();
+			while (rs.next()) metadata.put(rs.getString(1), rs.getBytes(2));
+			rs.close();
+			ps.close();
+			return metadata;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Metadata getMessageMetadataForValidator(Connection txn, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT metaKey, value FROM messageMetadata"
+					+ " WHERE (state = ? OR state = ?)"
+					+ " AND messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, DELIVERED.getValue());
+			ps.setInt(2, PENDING.getValue());
+			ps.setBytes(3, m.getBytes());
+			rs = ps.executeQuery();
+			Metadata metadata = new Metadata();
+			while (rs.next()) metadata.put(rs.getString(1), rs.getBytes(2));
+			rs.close();
+			ps.close();
+			return metadata;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageStatus> getMessageStatus(Connection txn,
+			ContactId c, GroupId g) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT messageId, txCount > 0, seen FROM statuses"
+					+ " WHERE groupId = ? AND contactId = ? AND state = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			ps.setInt(2, c.getInt());
+			ps.setInt(3, DELIVERED.getValue());
+			rs = ps.executeQuery();
+			List<MessageStatus> statuses = new ArrayList<>();
+			while (rs.next()) {
+				MessageId messageId = new MessageId(rs.getBytes(1));
+				boolean sent = rs.getBoolean(2);
+				boolean seen = rs.getBoolean(3);
+				statuses.add(new MessageStatus(messageId, c, sent, seen));
+			}
+			rs.close();
+			ps.close();
+			return statuses;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	@Nullable
+	public MessageStatus getMessageStatus(Connection txn, ContactId c,
+			MessageId m) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT txCount > 0, seen FROM statuses"
+					+ " WHERE messageId = ? AND contactId = ? AND state = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			ps.setInt(3, DELIVERED.getValue());
+			rs = ps.executeQuery();
+			MessageStatus status = null;
+			if (rs.next()) {
+				boolean sent = rs.getBoolean(1);
+				boolean seen = rs.getBoolean(2);
+				status = new MessageStatus(m, c, sent, seen);
+			}
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return status;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Map<MessageId, State> getMessageDependencies(Connection txn,
+			MessageId m) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT dependencyId, dependencyState"
+					+ " FROM messageDependencies"
+					+ " WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			rs = ps.executeQuery();
+			Map<MessageId, State> dependencies = new HashMap<>();
+			while (rs.next()) {
+				MessageId dependency = new MessageId(rs.getBytes(1));
+				State state = State.fromValue(rs.getInt(2));
+				if (rs.wasNull())
+					state = UNKNOWN; // Missing or in a different group
+				dependencies.put(dependency, state);
+			}
+			rs.close();
+			ps.close();
+			return dependencies;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Map<MessageId, State> getMessageDependents(Connection txn,
+			MessageId m) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			// Exclude dependencies that are missing or in a different group
+			// from the dependent
+			String sql = "SELECT messageId, messageState"
+					+ " FROM messageDependencies"
+					+ " WHERE dependencyId = ?"
+					+ " AND dependencyState IS NOT NULL";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			rs = ps.executeQuery();
+			Map<MessageId, State> dependents = new HashMap<>();
+			while (rs.next()) {
+				MessageId dependent = new MessageId(rs.getBytes(1));
+				State state = State.fromValue(rs.getInt(2));
+				dependents.put(dependent, state);
+			}
+			rs.close();
+			ps.close();
+			return dependents;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public State getMessageState(Connection txn, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT state FROM messages WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			State state = State.fromValue(rs.getInt(1));
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return state;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getMessagesToAck(Connection txn, ContactId c,
+			int maxMessages) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT messageId FROM statuses"
+					+ " WHERE contactId = ? AND ack = TRUE"
+					+ " LIMIT ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setInt(2, maxMessages);
+			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<>();
+			while (rs.next()) ids.add(new MessageId(rs.getBytes(1)));
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getMessagesToOffer(Connection txn,
+			ContactId c, int maxMessages, int maxLatency) throws DbException {
+		long now = clock.currentTimeMillis();
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT messageId FROM statuses"
+					+ " WHERE contactId = ? AND state = ?"
+					+ " AND groupShared = TRUE AND messageShared = TRUE"
+					+ " AND deleted = FALSE"
+					+ " AND seen = FALSE AND requested = FALSE"
+					+ " AND expiry < ?"
+					+ " ORDER BY timestamp LIMIT ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setInt(2, DELIVERED.getValue());
+			ps.setLong(3, now);
+			ps.setInt(4, maxMessages);
+			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<>();
+			while (rs.next()) ids.add(new MessageId(rs.getBytes(1)));
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getMessagesToRequest(Connection txn,
+			ContactId c, int maxMessages) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT messageId FROM offers"
+					+ " WHERE contactId = ?"
+					+ " LIMIT ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setInt(2, maxMessages);
+			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<>();
+			while (rs.next()) ids.add(new MessageId(rs.getBytes(1)));
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getMessagesToSend(Connection txn, ContactId c,
+			int maxLength, int maxLatency) throws DbException {
+		long now = clock.currentTimeMillis();
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT length, messageId FROM statuses"
+					+ " WHERE contactId = ? AND state = ?"
+					+ " AND groupShared = TRUE AND messageShared = TRUE"
+					+ " AND deleted = FALSE"
+					+ " AND seen = FALSE"
+					+ " AND expiry < ?"
+					+ " ORDER BY timestamp";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setInt(2, DELIVERED.getValue());
+			ps.setLong(3, now);
+			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<>();
+			int total = 0;
+			while (rs.next()) {
+				int length = rs.getInt(1);
+				if (total + length > maxLength) break;
+				ids.add(new MessageId(rs.getBytes(2)));
+				total += length;
+			}
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getMessagesToValidate(Connection txn)
+			throws DbException {
+		return getMessagesInState(txn, UNKNOWN);
+	}
+
+	@Override
+	public Collection<MessageId> getPendingMessages(Connection txn)
+			throws DbException {
+		return getMessagesInState(txn, PENDING);
+	}
+
+	private Collection<MessageId> getMessagesInState(Connection txn,
+			State state) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT messageId FROM messages"
+					+ " WHERE state = ? AND raw IS NOT NULL";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, state.getValue());
+			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<>();
+			while (rs.next()) ids.add(new MessageId(rs.getBytes(1)));
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getMessagesToShare(Connection txn)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT m.messageId FROM messages AS m"
+					+ " JOIN messageDependencies AS d"
+					+ " ON m.messageId = d.dependencyId"
+					+ " JOIN messages AS m1"
+					+ " ON d.messageId = m1.messageId"
+					+ " WHERE m.state = ?"
+					+ " AND m.shared = FALSE AND m1.shared = TRUE";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, DELIVERED.getValue());
+			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<>();
+			while (rs.next()) ids.add(new MessageId(rs.getBytes(1)));
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public long getNextSendTime(Connection txn, ContactId c, int maxLatency)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT expiry FROM statuses"
+					+ " WHERE contactId = ? AND state = ?"
+					+ " AND groupShared = TRUE AND messageShared = TRUE"
+					+ " AND deleted = FALSE AND seen = FALSE"
+					+ " ORDER BY expiry LIMIT 1";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setInt(2, DELIVERED.getValue());
+			rs = ps.executeQuery();
+			long nextSendTime = Long.MAX_VALUE;
+			if (rs.next()) {
+				nextSendTime = rs.getLong(1);
+				if (rs.next()) throw new AssertionError();
+			}
+			rs.close();
+			ps.close();
+			return nextSendTime;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	@Nullable
+	public byte[] getRawMessage(Connection txn, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT raw FROM messages WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			byte[] raw = rs.getBytes(1);
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return raw;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getRequestedMessagesToSend(Connection txn,
+			ContactId c, int maxLength) throws DbException {
+		long now = clock.currentTimeMillis();
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT length, messageId FROM statuses"
+					+ " WHERE contactId = ? AND state = ?"
+					+ " AND groupShared = TRUE AND messageShared = TRUE"
+					+ " AND deleted = FALSE"
+					+ " AND seen = FALSE AND requested = TRUE"
+					+ " AND expiry < ?"
+					+ " ORDER BY timestamp";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setInt(2, DELIVERED.getValue());
+			ps.setLong(3, now);
+			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<>();
+			int total = 0;
+			while (rs.next()) {
+				int length = rs.getInt(1);
+				if (total + length > maxLength) break;
+				ids.add(new MessageId(rs.getBytes(2)));
+				total += length;
+			}
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Settings getSettings(Connection txn, String namespace)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT settingKey, value FROM settings"
+					+ " WHERE namespace = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, namespace);
+			rs = ps.executeQuery();
+			Settings s = new Settings();
+			while (rs.next()) s.put(rs.getString(1), rs.getString(2));
+			rs.close();
+			ps.close();
+			return s;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<KeySet> getTransportKeys(Connection txn, TransportId t)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			// Retrieve the incoming keys
+			String sql = "SELECT rotationPeriod, tagKey, headerKey,"
+					+ " base, bitmap"
+					+ " FROM incomingKeys"
+					+ " WHERE transportId = ?"
+					+ " ORDER BY keySetId, periodOffset";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, t.getString());
+			rs = ps.executeQuery();
+			List<IncomingKeys> inKeys = new ArrayList<>();
+			while (rs.next()) {
+				long rotationPeriod = rs.getLong(1);
+				SecretKey tagKey = new SecretKey(rs.getBytes(2));
+				SecretKey headerKey = new SecretKey(rs.getBytes(3));
+				long windowBase = rs.getLong(4);
+				byte[] windowBitmap = rs.getBytes(5);
+				inKeys.add(new IncomingKeys(tagKey, headerKey, rotationPeriod,
+						windowBase, windowBitmap));
+			}
+			rs.close();
+			ps.close();
+			// Retrieve the outgoing keys in the same order
+			sql = "SELECT keySetId, contactId, rotationPeriod,"
+					+ " tagKey, headerKey, stream, active"
+					+ " FROM outgoingKeys"
+					+ " WHERE transportId = ?"
+					+ " ORDER BY keySetId";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, t.getString());
+			rs = ps.executeQuery();
+			Collection<KeySet> keys = new ArrayList<>();
+			for (int i = 0; rs.next(); i++) {
+				// There should be three times as many incoming keys
+				if (inKeys.size() < (i + 1) * 3) throw new DbStateException();
+				KeySetId keySetId = new KeySetId(rs.getInt(1));
+				ContactId contactId = new ContactId(rs.getInt(2));
+				long rotationPeriod = rs.getLong(3);
+				SecretKey tagKey = new SecretKey(rs.getBytes(4));
+				SecretKey headerKey = new SecretKey(rs.getBytes(5));
+				long streamCounter = rs.getLong(6);
+				boolean active = rs.getBoolean(7);
+				OutgoingKeys outCurr = new OutgoingKeys(tagKey, headerKey,
+						rotationPeriod, streamCounter, active);
+				IncomingKeys inPrev = inKeys.get(i * 3);
+				IncomingKeys inCurr = inKeys.get(i * 3 + 1);
+				IncomingKeys inNext = inKeys.get(i * 3 + 2);
+				TransportKeys transportKeys = new TransportKeys(t, inPrev,
+						inCurr, inNext, outCurr);
+				keys.add(new KeySet(keySetId, contactId, transportKeys));
+			}
+			rs.close();
+			ps.close();
+			return keys;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void incrementStreamCounter(Connection txn, TransportId t,
+			KeySetId k) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE outgoingKeys SET stream = stream + 1"
+					+ " WHERE transportId = ? AND keySetId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, t.getString());
+			ps.setInt(2, k.getInt());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void lowerAckFlag(Connection txn, ContactId c,
+			Collection<MessageId> acked) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE statuses SET ack = FALSE"
+					+ " WHERE messageId = ? AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(2, c.getInt());
+			for (MessageId m : acked) {
+				ps.setBytes(1, m.getBytes());
+				ps.addBatch();
+			}
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != acked.size())
+				throw new DbStateException();
+			for (int rows : batchAffected) {
+				if (rows < 0) throw new DbStateException();
+				if (rows > 1) throw new DbStateException();
+			}
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void lowerRequestedFlag(Connection txn, ContactId c,
+			Collection<MessageId> requested) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE statuses SET requested = FALSE"
+					+ " WHERE messageId = ? AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(2, c.getInt());
+			for (MessageId m : requested) {
+				ps.setBytes(1, m.getBytes());
+				ps.addBatch();
+			}
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != requested.size())
+				throw new DbStateException();
+			for (int rows : batchAffected) {
+				if (rows < 0) throw new DbStateException();
+				if (rows > 1) throw new DbStateException();
+			}
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void mergeGroupMetadata(Connection txn, GroupId g, Metadata meta)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			Map<String, byte[]> added = removeOrUpdateMetadata(txn,
+					g.getBytes(), meta, "groupMetadata", "groupId");
+			if (added.isEmpty()) return;
+			// Insert any keys that don't already exist
+			String sql = "INSERT INTO groupMetadata (groupId, metaKey, value)"
+					+ " VALUES (?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			for (Entry<String, byte[]> e : added.entrySet()) {
+				ps.setString(2, e.getKey());
+				ps.setBytes(3, e.getValue());
+				ps.addBatch();
+			}
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != added.size())
+				throw new DbStateException();
+			for (int rows : batchAffected)
+				if (rows != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void mergeMessageMetadata(Connection txn, MessageId m,
+			Metadata meta) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			Map<String, byte[]> added = removeOrUpdateMetadata(txn,
+					m.getBytes(), meta, "messageMetadata", "messageId");
+			if (added.isEmpty()) return;
+			// Get the group ID and message state for the denormalised columns
+			String sql = "SELECT groupId, state FROM messages"
+					+ " WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			GroupId g = new GroupId(rs.getBytes(1));
+			State state = State.fromValue(rs.getInt(2));
+			rs.close();
+			ps.close();
+			// Insert any keys that don't already exist
+			sql = "INSERT INTO messageMetadata"
+					+ " (messageId, groupId, state, metaKey, value)"
+					+ " VALUES (?, ?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setBytes(2, g.getBytes());
+			ps.setInt(3, state.getValue());
+			for (Entry<String, byte[]> e : added.entrySet()) {
+				ps.setString(4, e.getKey());
+				ps.setBytes(5, e.getValue());
+				ps.addBatch();
+			}
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != added.size())
+				throw new DbStateException();
+			for (int rows : batchAffected)
+				if (rows != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	// Removes or updates any existing entries, returns any entries that
+	// need to be added
+	private Map<String, byte[]> removeOrUpdateMetadata(Connection txn,
+			byte[] id, Metadata meta, String tableName, String columnName)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			// Determine which keys are being removed
+			List<String> removed = new ArrayList<>();
+			Map<String, byte[]> notRemoved = new HashMap<>();
+			for (Entry<String, byte[]> e : meta.entrySet()) {
+				if (e.getValue() == REMOVE) removed.add(e.getKey());
+				else notRemoved.put(e.getKey(), e.getValue());
+			}
+			// Delete any keys that are being removed
+			if (!removed.isEmpty()) {
+				String sql = "DELETE FROM " + tableName
+						+ " WHERE " + columnName + " = ? AND metaKey = ?";
+				ps = txn.prepareStatement(sql);
+				ps.setBytes(1, id);
+				for (String key : removed) {
+					ps.setString(2, key);
+					ps.addBatch();
+				}
+				int[] batchAffected = ps.executeBatch();
+				if (batchAffected.length != removed.size())
+					throw new DbStateException();
+				for (int rows : batchAffected) {
+					if (rows < 0) throw new DbStateException();
+					if (rows > 1) throw new DbStateException();
+				}
+				ps.close();
+			}
+			if (notRemoved.isEmpty()) return Collections.emptyMap();
+			// Update any keys that already exist
+			String sql = "UPDATE " + tableName + " SET value = ?"
+					+ " WHERE " + columnName + " = ? AND metaKey = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(2, id);
+			for (Entry<String, byte[]> e : notRemoved.entrySet()) {
+				ps.setBytes(1, e.getValue());
+				ps.setString(3, e.getKey());
+				ps.addBatch();
+			}
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != notRemoved.size())
+				throw new DbStateException();
+			for (int rows : batchAffected) {
+				if (rows < 0) throw new DbStateException();
+				if (rows > 1) throw new DbStateException();
+			}
+			ps.close();
+			// Are there any keys that don't already exist?
+			Map<String, byte[]> added = new HashMap<>();
+			int updateIndex = 0;
+			for (Entry<String, byte[]> e : notRemoved.entrySet()) {
+				if (batchAffected[updateIndex++] == 0)
+					added.put(e.getKey(), e.getValue());
+			}
+			return added;
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void mergeSettings(Connection txn, Settings s, String namespace)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			// Update any settings that already exist
+			String sql = "UPDATE settings SET value = ?"
+					+ " WHERE namespace = ? AND settingKey = ?";
+			ps = txn.prepareStatement(sql);
+			for (Entry<String, String> e : s.entrySet()) {
+				ps.setString(1, e.getValue());
+				ps.setString(2, namespace);
+				ps.setString(3, e.getKey());
+				ps.addBatch();
+			}
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != s.size()) throw new DbStateException();
+			for (int rows : batchAffected) {
+				if (rows < 0) throw new DbStateException();
+				if (rows > 1) throw new DbStateException();
+			}
+			// Insert any settings that don't already exist
+			sql = "INSERT INTO settings (namespace, settingKey, value)"
+					+ " VALUES (?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			int updateIndex = 0, inserted = 0;
+			for (Entry<String, String> e : s.entrySet()) {
+				if (batchAffected[updateIndex] == 0) {
+					ps.setString(1, namespace);
+					ps.setString(2, e.getKey());
+					ps.setString(3, e.getValue());
+					ps.addBatch();
+					inserted++;
+				}
+				updateIndex++;
+			}
+			batchAffected = ps.executeBatch();
+			if (batchAffected.length != inserted) throw new DbStateException();
+			for (int rows : batchAffected)
+				if (rows != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void raiseAckFlag(Connection txn, ContactId c, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE statuses SET ack = TRUE"
+					+ " WHERE messageId = ? AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void raiseRequestedFlag(Connection txn, ContactId c, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE statuses SET requested = TRUE"
+					+ " WHERE messageId = ? AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void raiseSeenFlag(Connection txn, ContactId c, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE statuses SET seen = TRUE"
+					+ " WHERE messageId = ? AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void removeContact(Connection txn, ContactId c)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM contacts WHERE contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void removeGroup(Connection txn, GroupId g) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM groups WHERE groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void removeGroupVisibility(Connection txn, ContactId c, GroupId g)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM groupVisibilities"
+					+ " WHERE contactId = ? AND groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setBytes(2, g.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+			// Remove status rows for the messages in the group
+			sql = "DELETE FROM statuses"
+					+ " WHERE contactId = ? AND groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setBytes(2, g.getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void removeLocalAuthor(Connection txn, AuthorId a)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM localAuthors WHERE authorId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, a.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void removeMessage(Connection txn, MessageId m) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM messages WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	private boolean removeOfferedMessage(Connection txn, ContactId c,
+			MessageId m) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM offers"
+					+ " WHERE contactId = ? AND messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setBytes(2, m.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+			return affected == 1;
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void removeOfferedMessages(Connection txn, ContactId c,
+			Collection<MessageId> requested) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM offers"
+					+ " WHERE contactId = ? AND messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			for (MessageId m : requested) {
+				ps.setBytes(2, m.getBytes());
+				ps.addBatch();
+			}
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != requested.size())
+				throw new DbStateException();
+			for (int rows : batchAffected)
+				if (rows != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void removeTransport(Connection txn, TransportId t)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM transports WHERE transportId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, t.getString());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void removeTransportKeys(Connection txn, TransportId t, KeySetId k)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			// Delete any existing outgoing keys - this will also remove any
+			// incoming keys with the same key set ID
+			String sql = "DELETE FROM outgoingKeys"
+					+ " WHERE transportId = ? AND keySetId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, t.getString());
+			ps.setInt(2, k.getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void resetExpiryTime(Connection txn, ContactId c, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE statuses SET expiry = 0, txCount = 0"
+					+ " WHERE messageId = ? AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void setContactVerified(Connection txn, ContactId c)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE contacts SET verified = ? WHERE contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBoolean(1, true);
+			ps.setInt(2, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void setContactActive(Connection txn, ContactId c, boolean active)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE contacts SET active = ? WHERE contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBoolean(1, active);
+			ps.setInt(2, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void setGroupVisibility(Connection txn, ContactId c, GroupId g,
+			boolean shared) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE groupVisibilities SET shared = ?"
+					+ " WHERE contactId = ? AND groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBoolean(1, shared);
+			ps.setInt(2, c.getInt());
+			ps.setBytes(3, g.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+			// Update denormalised column in statuses
+			sql = "UPDATE statuses SET groupShared = ?"
+					+ " WHERE contactId = ? AND groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBoolean(1, shared);
+			ps.setInt(2, c.getInt());
+			ps.setBytes(3, g.getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void setMessageShared(Connection txn, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE messages SET shared = TRUE"
+					+ " WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+			// Update denormalised column in statuses
+			sql = "UPDATE statuses SET messageShared = TRUE"
+					+ " WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void setMessageState(Connection txn, MessageId m, State state)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE messages SET state = ? WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, state.getValue());
+			ps.setBytes(2, m.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+			// Update denormalised column in messageMetadata
+			sql = "UPDATE messageMetadata SET state = ? WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, state.getValue());
+			ps.setBytes(2, m.getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+			// Update denormalised column in statuses
+			sql = "UPDATE statuses SET state = ? WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, state.getValue());
+			ps.setBytes(2, m.getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+			// Update denormalised column in messageDependencies
+			sql = "UPDATE messageDependencies SET messageState = ?"
+					+ " WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, state.getValue());
+			ps.setBytes(2, m.getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+			// Update denormalised column in messageDependencies if dependency
+			// is present and in same group as dependent
+			sql = "UPDATE messageDependencies SET dependencyState = ?"
+					+ " WHERE dependencyId = ? AND dependencyState IS NOT NULL";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, state.getValue());
+			ps.setBytes(2, m.getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void setReorderingWindow(Connection txn, KeySetId k, TransportId t,
+			long rotationPeriod, long base, byte[] bitmap) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE incomingKeys SET base = ?, bitmap = ?"
+					+ " WHERE transportId = ? AND keySetId = ?"
+					+ " AND rotationPeriod = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setLong(1, base);
+			ps.setBytes(2, bitmap);
+			ps.setString(3, t.getString());
+			ps.setInt(4, k.getInt());
+			ps.setLong(5, rotationPeriod);
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void setTransportKeysActive(Connection txn, TransportId t,
+			KeySetId k) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE outgoingKeys SET active = true"
+					+ " WHERE transportId = ? AND keySetId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, t.getString());
+			ps.setInt(2, k.getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void updateLastSentTime(Connection txn, ContactId c, MessageId m,
+			int maxLatency) throws DbException {
+		updateLastSentTime(txn, c, m);
+	}
+
+	@Override
+	public void updateLastSentTime(Connection txn, ContactId c, MessageId m)
+			throws DbException {
+		updateLastSentTime(txn, c, m, 1000, clock.currentTimeMillis());
+	}
+
+	@Override
+	public void updateLastSentTime(Connection txn, ContactId c, MessageId m,
+			int maxLatency, long time)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT txCount FROM statuses"
+					+ " WHERE messageId = ? AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			int txCount = rs.getInt(1);
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			sql = "UPDATE statuses SET expiry = ?, txCount = txCount + 1"
+					+ " WHERE messageId = ? AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setLong(1, calculateExpiry(time, maxLatency, txCount));
+			ps.setBytes(2, m.getBytes());
+			ps.setInt(3, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void updateTransportKeys(Connection txn, KeySet ks)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			// Update the outgoing keys
+			String sql = "UPDATE outgoingKeys SET rotationPeriod = ?,"
+					+ " tagKey = ?, headerKey = ?, stream = ?"
+					+ " WHERE transportId = ? AND keySetId = ?";
+			ps = txn.prepareStatement(sql);
+			TransportKeys k = ks.getTransportKeys();
+			OutgoingKeys outCurr = k.getCurrentOutgoingKeys();
+			ps.setLong(1, outCurr.getRotationPeriod());
+			ps.setBytes(2, outCurr.getTagKey().getBytes());
+			ps.setBytes(3, outCurr.getHeaderKey().getBytes());
+			ps.setLong(4, outCurr.getStreamCounter());
+			ps.setString(5, k.getTransportId().getString());
+			ps.setInt(6, ks.getKeySetId().getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+			// Update the incoming keys
+			sql = "UPDATE incomingKeys SET rotationPeriod = ?,"
+					+ " tagKey = ?, headerKey = ?, base = ?, bitmap = ?"
+					+ " WHERE transportId = ? AND keySetId = ?"
+					+ " AND periodOffset = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setString(6, k.getTransportId().getString());
+			ps.setInt(7, ks.getKeySetId().getInt());
+			// Previous rotation period
+			IncomingKeys inPrev = k.getPreviousIncomingKeys();
+			ps.setLong(1, inPrev.getRotationPeriod());
+			ps.setBytes(2, inPrev.getTagKey().getBytes());
+			ps.setBytes(3, inPrev.getHeaderKey().getBytes());
+			ps.setLong(4, inPrev.getWindowBase());
+			ps.setBytes(5, inPrev.getWindowBitmap());
+			ps.setInt(8, OFFSET_PREV);
+			ps.addBatch();
+			// Current rotation period
+			IncomingKeys inCurr = k.getCurrentIncomingKeys();
+			ps.setLong(1, inCurr.getRotationPeriod());
+			ps.setBytes(2, inCurr.getTagKey().getBytes());
+			ps.setBytes(3, inCurr.getHeaderKey().getBytes());
+			ps.setLong(4, inCurr.getWindowBase());
+			ps.setBytes(5, inCurr.getWindowBitmap());
+			ps.setInt(8, OFFSET_CURR);
+			ps.addBatch();
+			// Next rotation period
+			IncomingKeys inNext = k.getNextIncomingKeys();
+			ps.setLong(1, inNext.getRotationPeriod());
+			ps.setBytes(2, inNext.getTagKey().getBytes());
+			ps.setBytes(3, inNext.getHeaderKey().getBytes());
+			ps.setLong(4, inNext.getWindowBase());
+			ps.setBytes(5, inNext.getWindowBitmap());
+			ps.setInt(8, OFFSET_NEXT);
+			ps.addBatch();
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != 3) throw new DbStateException();
+			for (int rows : batchAffected)
+				if (rows < 0 || rows > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+}
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/db/JdbcDatabaseUnion.java b/bramble-core/src/main/java/org/briarproject/bramble/db/JdbcDatabaseUnion.java
new file mode 100644
index 0000000000000000000000000000000000000000..9b401321ca4e50eaf18cee9b6b7a25715b208b45
--- /dev/null
+++ b/bramble-core/src/main/java/org/briarproject/bramble/db/JdbcDatabaseUnion.java
@@ -0,0 +1,2996 @@
+package org.briarproject.bramble.db;
+
+import org.briarproject.bramble.api.contact.Contact;
+import org.briarproject.bramble.api.contact.ContactId;
+import org.briarproject.bramble.api.crypto.SecretKey;
+import org.briarproject.bramble.api.db.DataTooNewException;
+import org.briarproject.bramble.api.db.DataTooOldException;
+import org.briarproject.bramble.api.db.DbClosedException;
+import org.briarproject.bramble.api.db.DbException;
+import org.briarproject.bramble.api.db.Metadata;
+import org.briarproject.bramble.api.db.MigrationListener;
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.api.identity.AuthorId;
+import org.briarproject.bramble.api.identity.LocalAuthor;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.plugin.TransportId;
+import org.briarproject.bramble.api.settings.Settings;
+import org.briarproject.bramble.api.sync.ClientId;
+import org.briarproject.bramble.api.sync.Group;
+import org.briarproject.bramble.api.sync.Group.Visibility;
+import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.bramble.api.sync.Message;
+import org.briarproject.bramble.api.sync.MessageId;
+import org.briarproject.bramble.api.sync.MessageStatus;
+import org.briarproject.bramble.api.sync.ValidationManager.State;
+import org.briarproject.bramble.api.system.Clock;
+import org.briarproject.bramble.api.transport.IncomingKeys;
+import org.briarproject.bramble.api.transport.KeySet;
+import org.briarproject.bramble.api.transport.KeySetId;
+import org.briarproject.bramble.api.transport.OutgoingKeys;
+import org.briarproject.bramble.api.transport.TransportKeys;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.concurrent.locks.Condition;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.logging.Logger;
+
+import javax.annotation.Nullable;
+
+import static java.sql.Types.INTEGER;
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Level.WARNING;
+import static org.briarproject.bramble.api.db.Metadata.REMOVE;
+import static org.briarproject.bramble.api.sync.Group.Visibility.INVISIBLE;
+import static org.briarproject.bramble.api.sync.Group.Visibility.SHARED;
+import static org.briarproject.bramble.api.sync.Group.Visibility.VISIBLE;
+import static org.briarproject.bramble.api.sync.ValidationManager.State.DELIVERED;
+import static org.briarproject.bramble.api.sync.ValidationManager.State.PENDING;
+import static org.briarproject.bramble.api.sync.ValidationManager.State.UNKNOWN;
+import static org.briarproject.bramble.db.DatabaseConstants.DB_SETTINGS_NAMESPACE;
+import static org.briarproject.bramble.db.DatabaseConstants.SCHEMA_VERSION_KEY;
+import static org.briarproject.bramble.util.LogUtils.logException;
+
+/**
+ * A generic database implementation that can be used with any JDBC-compatible
+ * database library.
+ */
+@NotNullByDefault
+abstract class JdbcDatabaseUnion implements Database<Connection> {
+
+	// Package access for testing
+	static final int CODE_SCHEMA_VERSION = 40;
+
+	// Rotation period offsets for incoming transport keys
+	private static final int OFFSET_PREV = -1;
+	private static final int OFFSET_CURR = 0;
+	private static final int OFFSET_NEXT = 1;
+
+	private static final String CREATE_SETTINGS =
+			"CREATE TABLE settings"
+					+ " (namespace _STRING NOT NULL,"
+					+ " settingKey _STRING NOT NULL,"
+					+ " value _STRING NOT NULL,"
+					+ " PRIMARY KEY (namespace, settingKey))";
+
+	private static final String CREATE_LOCAL_AUTHORS =
+			"CREATE TABLE localAuthors"
+					+ " (authorId _HASH NOT NULL,"
+					+ " formatVersion INT NOT NULL,"
+					+ " name _STRING NOT NULL,"
+					+ " publicKey _BINARY NOT NULL,"
+					+ " privateKey _BINARY NOT NULL,"
+					+ " created BIGINT NOT NULL,"
+					+ " PRIMARY KEY (authorId))";
+
+	private static final String CREATE_CONTACTS =
+			"CREATE TABLE contacts"
+					+ " (contactId _COUNTER,"
+					+ " authorId _HASH NOT NULL,"
+					+ " formatVersion INT NOT NULL,"
+					+ " name _STRING NOT NULL,"
+					+ " publicKey _BINARY NOT NULL,"
+					+ " localAuthorId _HASH NOT NULL,"
+					+ " verified BOOLEAN NOT NULL,"
+					+ " active BOOLEAN NOT NULL,"
+					+ " PRIMARY KEY (contactId),"
+					+ " FOREIGN KEY (localAuthorId)"
+					+ " REFERENCES localAuthors (authorId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_GROUPS =
+			"CREATE TABLE groups"
+					+ " (groupId _HASH NOT NULL,"
+					+ " clientId _STRING NOT NULL,"
+					+ " majorVersion INT NOT NULL,"
+					+ " descriptor _BINARY NOT NULL,"
+					+ " PRIMARY KEY (groupId))";
+
+	private static final String CREATE_GROUP_METADATA =
+			"CREATE TABLE groupMetadata"
+					+ " (groupId _HASH NOT NULL,"
+					+ " metaKey _STRING NOT NULL,"
+					+ " value _BINARY NOT NULL,"
+					+ " PRIMARY KEY (groupId, metaKey),"
+					+ " FOREIGN KEY (groupId)"
+					+ " REFERENCES groups (groupId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_GROUP_VISIBILITIES =
+			"CREATE TABLE groupVisibilities"
+					+ " (contactId INT NOT NULL,"
+					+ " groupId _HASH NOT NULL,"
+					+ " shared BOOLEAN NOT NULL,"
+					+ " PRIMARY KEY (contactId, groupId),"
+					+ " FOREIGN KEY (contactId)"
+					+ " REFERENCES contacts (contactId)"
+					+ " ON DELETE CASCADE,"
+					+ " FOREIGN KEY (groupId)"
+					+ " REFERENCES groups (groupId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_MESSAGES =
+			"CREATE TABLE messages"
+					+ " (messageId _HASH NOT NULL,"
+					+ " groupId _HASH NOT NULL,"
+					+ " timestamp BIGINT NOT NULL,"
+					+ " state INT NOT NULL,"
+					+ " shared BOOLEAN NOT NULL,"
+					+ " length INT NOT NULL,"
+					+ " raw BLOB," // Null if message has been deleted
+					+ " PRIMARY KEY (messageId),"
+					+ " FOREIGN KEY (groupId)"
+					+ " REFERENCES groups (groupId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_MESSAGE_METADATA =
+			"CREATE TABLE messageMetadata"
+					+ " (messageId _HASH NOT NULL,"
+					+ " groupId _HASH NOT NULL," // Denormalised
+					+ " state INT NOT NULL," // Denormalised
+					+ " metaKey _STRING NOT NULL,"
+					+ " value _BINARY NOT NULL,"
+					+ " PRIMARY KEY (messageId, metaKey),"
+					+ " FOREIGN KEY (messageId)"
+					+ " REFERENCES messages (messageId)"
+					+ " ON DELETE CASCADE,"
+					+ " FOREIGN KEY (groupId)"
+					+ " REFERENCES groups (groupId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_MESSAGE_DEPENDENCIES =
+			"CREATE TABLE messageDependencies"
+					+ " (groupId _HASH NOT NULL,"
+					+ " messageId _HASH NOT NULL,"
+					+ " dependencyId _HASH NOT NULL," // Not a foreign key
+					+ " messageState INT NOT NULL," // Denormalised
+					// Denormalised, null if dependency is missing or in a
+					// different group
+					+ " dependencyState INT,"
+					+ " FOREIGN KEY (groupId)"
+					+ " REFERENCES groups (groupId)"
+					+ " ON DELETE CASCADE,"
+					+ " FOREIGN KEY (messageId)"
+					+ " REFERENCES messages (messageId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_OFFERS =
+			"CREATE TABLE offers"
+					+ " (messageId _HASH NOT NULL," // Not a foreign key
+					+ " contactId INT NOT NULL,"
+					+ " PRIMARY KEY (messageId, contactId),"
+					+ " FOREIGN KEY (contactId)"
+					+ " REFERENCES contacts (contactId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_STATUSES =
+			"CREATE TABLE statuses"
+					+ " (messageId _HASH NOT NULL,"
+					+ " contactId INT NOT NULL,"
+					+ " groupId _HASH NOT NULL," // Denormalised
+					+ " timestamp BIGINT NOT NULL," // Denormalised
+					+ " length INT NOT NULL," // Denormalised
+					+ " state INT NOT NULL," // Denormalised
+					+ " groupShared BOOLEAN NOT NULL," // Denormalised
+					+ " messageShared BOOLEAN NOT NULL," // Denormalised
+					+ " deleted BOOLEAN NOT NULL," // Denormalised
+					+ " ack BOOLEAN NOT NULL,"
+					+ " seen BOOLEAN NOT NULL,"
+					+ " requested BOOLEAN NOT NULL,"
+					+ " lastSentTime BIGINT NOT NULL,"
+					+ " txCount INT NOT NULL,"
+					+ " PRIMARY KEY (messageId, contactId),"
+					+ " FOREIGN KEY (messageId)"
+					+ " REFERENCES messages (messageId)"
+					+ " ON DELETE CASCADE,"
+					+ " FOREIGN KEY (contactId)"
+					+ " REFERENCES contacts (contactId)"
+					+ " ON DELETE CASCADE,"
+					+ " FOREIGN KEY (groupId)"
+					+ " REFERENCES groups (groupId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_TRANSPORTS =
+			"CREATE TABLE transports"
+					+ " (transportId _STRING NOT NULL,"
+					+ " maxLatency INT NOT NULL,"
+					+ " PRIMARY KEY (transportId))";
+
+	private static final String CREATE_OUTGOING_KEYS =
+			"CREATE TABLE outgoingKeys"
+					+ " (transportId _STRING NOT NULL,"
+					+ " keySetId _COUNTER,"
+					+ " rotationPeriod BIGINT NOT NULL,"
+					+ " contactId INT NOT NULL,"
+					+ " tagKey _SECRET NOT NULL,"
+					+ " headerKey _SECRET NOT NULL,"
+					+ " stream BIGINT NOT NULL,"
+					+ " active BOOLEAN NOT NULL,"
+					+ " PRIMARY KEY (transportId, keySetId),"
+					+ " FOREIGN KEY (transportId)"
+					+ " REFERENCES transports (transportId)"
+					+ " ON DELETE CASCADE,"
+					+ " UNIQUE (keySetId),"
+					+ " FOREIGN KEY (contactId)"
+					+ " REFERENCES contacts (contactId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String CREATE_INCOMING_KEYS =
+			"CREATE TABLE incomingKeys"
+					+ " (transportId _STRING NOT NULL,"
+					+ " keySetId INT NOT NULL,"
+					+ " rotationPeriod BIGINT NOT NULL,"
+					+ " contactId INT NOT NULL,"
+					+ " tagKey _SECRET NOT NULL,"
+					+ " headerKey _SECRET NOT NULL,"
+					+ " base BIGINT NOT NULL,"
+					+ " bitmap _BINARY NOT NULL,"
+					+ " periodOffset INT NOT NULL,"
+					+ " PRIMARY KEY (transportId, keySetId, periodOffset),"
+					+ " FOREIGN KEY (transportId)"
+					+ " REFERENCES transports (transportId)"
+					+ " ON DELETE CASCADE,"
+					+ " FOREIGN KEY (keySetId)"
+					+ " REFERENCES outgoingKeys (keySetId)"
+					+ " ON DELETE CASCADE,"
+					+ " FOREIGN KEY (contactId)"
+					+ " REFERENCES contacts (contactId)"
+					+ " ON DELETE CASCADE)";
+
+	private static final String INDEX_CONTACTS_BY_AUTHOR_ID =
+			"CREATE INDEX IF NOT EXISTS contactsByAuthorId"
+					+ " ON contacts (authorId)";
+
+	private static final String INDEX_GROUPS_BY_CLIENT_ID_MAJOR_VERSION =
+			"CREATE INDEX IF NOT EXISTS groupsByClientIdMajorVersion"
+					+ " ON groups (clientId, majorVersion)";
+
+	private static final String INDEX_MESSAGE_METADATA_BY_GROUP_ID_STATE =
+			"CREATE INDEX IF NOT EXISTS messageMetadataByGroupIdState"
+					+ " ON messageMetadata (groupId, state)";
+
+	private static final String INDEX_MESSAGE_DEPENDENCIES_BY_DEPENDENCY_ID =
+			"CREATE INDEX IF NOT EXISTS messageDependenciesByDependencyId"
+					+ " ON messageDependencies (dependencyId)";
+
+	private static final String INDEX_STATUSES_BY_CONTACT_ID_GROUP_ID =
+			"CREATE INDEX IF NOT EXISTS statusesByContactIdGroupId"
+					+ " ON statuses (contactId, groupId)";
+
+	private static final String INDEX_STATUSES_BY_CONTACT_ID_TIMESTAMP =
+			"CREATE INDEX IF NOT EXISTS statusesByContactIdTimestamp"
+					+ " ON statuses (contactId, timestamp)";
+
+	private static final Logger LOG =
+			Logger.getLogger(JdbcDatabaseUnion.class.getName());
+
+	// Different database libraries use different names for certain types
+	private final String hashType, secretType, binaryType;
+	private final String counterType, stringType;
+	private final Clock clock;
+
+	// Locking: connectionsLock
+	private final LinkedList<Connection> connections = new LinkedList<>();
+
+	private int openConnections = 0; // Locking: connectionsLock
+	private boolean closed = false; // Locking: connectionsLock
+
+	@Nullable
+	protected abstract Connection createConnection() throws SQLException;
+
+	private final Lock connectionsLock = new ReentrantLock();
+	private final Condition connectionsChanged = connectionsLock.newCondition();
+
+	JdbcDatabaseUnion(String hashType, String secretType, String binaryType,
+			String counterType, String stringType, Clock clock) {
+		this.hashType = hashType;
+		this.secretType = secretType;
+		this.binaryType = binaryType;
+		this.counterType = counterType;
+		this.stringType = stringType;
+		this.clock = clock;
+	}
+
+	protected void open(String driverClass, boolean reopen, SecretKey key,
+			@Nullable MigrationListener listener) throws DbException {
+		// Load the JDBC driver
+		try {
+			Class.forName(driverClass);
+		} catch (ClassNotFoundException e) {
+			throw new DbException(e);
+		}
+		// Open the database and create the tables and indexes if necessary
+		Connection txn = startTransaction();
+		try {
+			if (reopen) {
+				checkSchemaVersion(txn, listener);
+			} else {
+				createTables(txn);
+				storeSchemaVersion(txn, CODE_SCHEMA_VERSION);
+			}
+			createIndexes(txn);
+			commitTransaction(txn);
+		} catch (DbException e) {
+			abortTransaction(txn);
+			throw e;
+		}
+	}
+
+	/**
+	 * Compares the schema version stored in the database with the schema
+	 * version used by the current code and applies any suitable migrations to
+	 * the data if necessary.
+	 *
+	 * @throws DataTooNewException if the data uses a newer schema than the
+	 * current code
+	 * @throws DataTooOldException if the data uses an older schema than the
+	 * current code and cannot be migrated
+	 */
+	private void checkSchemaVersion(Connection txn,
+			@Nullable MigrationListener listener) throws DbException {
+		Settings s = getSettings(txn, DB_SETTINGS_NAMESPACE);
+		int dataSchemaVersion = s.getInt(SCHEMA_VERSION_KEY, -1);
+		if (dataSchemaVersion == -1) throw new DbException();
+		if (dataSchemaVersion == CODE_SCHEMA_VERSION) return;
+		if (CODE_SCHEMA_VERSION < dataSchemaVersion)
+			throw new DataTooNewException();
+		// Apply any suitable migrations in order
+		for (Migration<Connection> m : getMigrations()) {
+			int start = m.getStartVersion(), end = m.getEndVersion();
+			if (start == dataSchemaVersion) {
+				if (LOG.isLoggable(INFO))
+					LOG.info("Migrating from schema " + start + " to " + end);
+				if (listener != null) listener.onMigrationRun();
+				// Apply the migration
+				m.migrate(txn);
+				// Store the new schema version
+				storeSchemaVersion(txn, end);
+				dataSchemaVersion = end;
+			}
+		}
+		if (dataSchemaVersion != CODE_SCHEMA_VERSION)
+			throw new DataTooOldException();
+	}
+
+	// Package access for testing
+	List<Migration<Connection>> getMigrations() {
+		return Arrays.asList(new Migration38_39(), new Migration39_40());
+	}
+
+	private void storeSchemaVersion(Connection txn, int version)
+			throws DbException {
+		Settings s = new Settings();
+		s.putInt(SCHEMA_VERSION_KEY, version);
+		mergeSettings(txn, s, DB_SETTINGS_NAMESPACE);
+	}
+
+	private void tryToClose(@Nullable ResultSet rs) {
+		try {
+			if (rs != null) rs.close();
+		} catch (SQLException e) {
+			logException(LOG, WARNING, e);
+		}
+	}
+
+	private void tryToClose(@Nullable Statement s) {
+		try {
+			if (s != null) s.close();
+		} catch (SQLException e) {
+			logException(LOG, WARNING, e);
+		}
+	}
+
+	private void createTables(Connection txn) throws DbException {
+		Statement s = null;
+		try {
+			s = txn.createStatement();
+			s.executeUpdate(insertTypeNames(CREATE_SETTINGS));
+			s.executeUpdate(insertTypeNames(CREATE_LOCAL_AUTHORS));
+			s.executeUpdate(insertTypeNames(CREATE_CONTACTS));
+			s.executeUpdate(insertTypeNames(CREATE_GROUPS));
+			s.executeUpdate(insertTypeNames(CREATE_GROUP_METADATA));
+			s.executeUpdate(insertTypeNames(CREATE_GROUP_VISIBILITIES));
+			s.executeUpdate(insertTypeNames(CREATE_MESSAGES));
+			s.executeUpdate(insertTypeNames(CREATE_MESSAGE_METADATA));
+			s.executeUpdate(insertTypeNames(CREATE_MESSAGE_DEPENDENCIES));
+			s.executeUpdate(insertTypeNames(CREATE_OFFERS));
+			s.executeUpdate(insertTypeNames(CREATE_STATUSES));
+			s.executeUpdate(insertTypeNames(CREATE_TRANSPORTS));
+			s.executeUpdate(insertTypeNames(CREATE_OUTGOING_KEYS));
+			s.executeUpdate(insertTypeNames(CREATE_INCOMING_KEYS));
+			s.close();
+		} catch (SQLException e) {
+			tryToClose(s);
+			throw new DbException(e);
+		}
+	}
+
+	private void createIndexes(Connection txn) throws DbException {
+		Statement s = null;
+		try {
+			s = txn.createStatement();
+			s.executeUpdate(INDEX_CONTACTS_BY_AUTHOR_ID);
+			s.executeUpdate(INDEX_GROUPS_BY_CLIENT_ID_MAJOR_VERSION);
+			s.executeUpdate(INDEX_MESSAGE_METADATA_BY_GROUP_ID_STATE);
+			s.executeUpdate(INDEX_MESSAGE_DEPENDENCIES_BY_DEPENDENCY_ID);
+			s.executeUpdate(INDEX_STATUSES_BY_CONTACT_ID_GROUP_ID);
+			s.executeUpdate(INDEX_STATUSES_BY_CONTACT_ID_TIMESTAMP);
+			s.close();
+		} catch (SQLException e) {
+			tryToClose(s);
+			throw new DbException(e);
+		}
+	}
+
+	private String insertTypeNames(String s) {
+		s = s.replaceAll("_HASH", hashType);
+		s = s.replaceAll("_SECRET", secretType);
+		s = s.replaceAll("_BINARY", binaryType);
+		s = s.replaceAll("_COUNTER", counterType);
+		s = s.replaceAll("_STRING", stringType);
+		return s;
+	}
+
+	@Override
+	public Connection startTransaction() throws DbException {
+		Connection txn;
+		connectionsLock.lock();
+		try {
+			if (closed) throw new DbClosedException();
+			txn = connections.poll();
+		} finally {
+			connectionsLock.unlock();
+		}
+		try {
+			if (txn == null) {
+				// Open a new connection
+				txn = createConnection();
+				if (txn == null) throw new DbException();
+				txn.setAutoCommit(false);
+				connectionsLock.lock();
+				try {
+					openConnections++;
+				} finally {
+					connectionsLock.unlock();
+				}
+			}
+		} catch (SQLException e) {
+			throw new DbException(e);
+		}
+		return txn;
+	}
+
+	@Override
+	public void abortTransaction(Connection txn) {
+		try {
+			txn.rollback();
+			connectionsLock.lock();
+			try {
+				connections.add(txn);
+				connectionsChanged.signalAll();
+			} finally {
+				connectionsLock.unlock();
+			}
+		} catch (SQLException e) {
+			// Try to close the connection
+			logException(LOG, WARNING, e);
+			try {
+				txn.close();
+			} catch (SQLException e1) {
+				logException(LOG, WARNING, e1);
+			}
+			// Whatever happens, allow the database to close
+			connectionsLock.lock();
+			try {
+				openConnections--;
+				connectionsChanged.signalAll();
+			} finally {
+				connectionsLock.unlock();
+			}
+		}
+	}
+
+	@Override
+	public void commitTransaction(Connection txn) throws DbException {
+		try {
+			txn.commit();
+		} catch (SQLException e) {
+			throw new DbException(e);
+		}
+		connectionsLock.lock();
+		try {
+			connections.add(txn);
+			connectionsChanged.signalAll();
+		} finally {
+			connectionsLock.unlock();
+		}
+	}
+
+	void closeAllConnections() throws SQLException {
+		boolean interrupted = false;
+		connectionsLock.lock();
+		try {
+			closed = true;
+			for (Connection c : connections) c.close();
+			openConnections -= connections.size();
+			connections.clear();
+			while (openConnections > 0) {
+				try {
+					connectionsChanged.await();
+				} catch (InterruptedException e) {
+					LOG.warning("Interrupted while closing connections");
+					interrupted = true;
+				}
+				for (Connection c : connections) c.close();
+				openConnections -= connections.size();
+				connections.clear();
+			}
+		} finally {
+			connectionsLock.unlock();
+		}
+
+		if (interrupted) Thread.currentThread().interrupt();
+	}
+
+	@Override
+	public ContactId addContact(Connection txn, Author remote, AuthorId local,
+			boolean verified, boolean active) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			// Create a contact row
+			String sql = "INSERT INTO contacts"
+					+ " (authorId, formatVersion, name, publicKey,"
+					+ " localAuthorId,"
+					+ " verified, active)"
+					+ " VALUES (?, ?, ?, ?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, remote.getId().getBytes());
+			ps.setInt(2, remote.getFormatVersion());
+			ps.setString(3, remote.getName());
+			ps.setBytes(4, remote.getPublicKey());
+			ps.setBytes(5, local.getBytes());
+			ps.setBoolean(6, verified);
+			ps.setBoolean(7, active);
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+			// Get the new (highest) contact ID
+			sql = "SELECT contactId FROM contacts"
+					+ " ORDER BY contactId DESC LIMIT 1";
+			ps = txn.prepareStatement(sql);
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			ContactId c = new ContactId(rs.getInt(1));
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return c;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void addGroup(Connection txn, Group g) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "INSERT INTO groups"
+					+ " (groupId, clientId, majorVersion, descriptor)"
+					+ " VALUES (?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getId().getBytes());
+			ps.setString(2, g.getClientId().getString());
+			ps.setInt(3, g.getMajorVersion());
+			ps.setBytes(4, g.getDescriptor());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void addGroupVisibility(Connection txn, ContactId c, GroupId g,
+			boolean groupShared) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "INSERT INTO groupVisibilities"
+					+ " (contactId, groupId, shared)"
+					+ " VALUES (?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setBytes(2, g.getBytes());
+			ps.setBoolean(3, groupShared);
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+			// Create a status row for each message in the group
+			addStatus(txn, c, g, groupShared);
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	private void addStatus(Connection txn, ContactId c, GroupId g,
+			boolean groupShared) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT messageId, timestamp, state, shared,"
+					+ " length, raw IS NULL"
+					+ " FROM messages"
+					+ " WHERE groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			rs = ps.executeQuery();
+			while (rs.next()) {
+				MessageId id = new MessageId(rs.getBytes(1));
+				long timestamp = rs.getLong(2);
+				State state = State.fromValue(rs.getInt(3));
+				boolean messageShared = rs.getBoolean(4);
+				int length = rs.getInt(5);
+				boolean deleted = rs.getBoolean(6);
+				boolean seen = removeOfferedMessage(txn, c, id);
+				addStatus(txn, id, c, g, timestamp, length, state, groupShared,
+						messageShared, deleted, seen);
+			}
+			rs.close();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void addLocalAuthor(Connection txn, LocalAuthor a)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "INSERT INTO localAuthors"
+					+ " (authorId, formatVersion, name, publicKey,"
+					+ " privateKey, created)"
+					+ " VALUES (?, ?, ?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, a.getId().getBytes());
+			ps.setInt(2, a.getFormatVersion());
+			ps.setString(3, a.getName());
+			ps.setBytes(4, a.getPublicKey());
+			ps.setBytes(5, a.getPrivateKey());
+			ps.setLong(6, a.getTimeCreated());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void addMessage(Connection txn, Message m, State state,
+			boolean messageShared, @Nullable ContactId sender)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "INSERT INTO messages (messageId, groupId, timestamp,"
+					+ " state, shared, length, raw)"
+					+ " VALUES (?, ?, ?, ?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getId().getBytes());
+			ps.setBytes(2, m.getGroupId().getBytes());
+			ps.setLong(3, m.getTimestamp());
+			ps.setInt(4, state.getValue());
+			ps.setBoolean(5, messageShared);
+			byte[] raw = m.getRaw();
+			ps.setInt(6, raw.length);
+			ps.setBytes(7, raw);
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+			// Create a status row for each contact that can see the group
+			Map<ContactId, Boolean> visibility =
+					getGroupVisibility(txn, m.getGroupId());
+			for (Entry<ContactId, Boolean> e : visibility.entrySet()) {
+				ContactId c = e.getKey();
+				boolean offered = removeOfferedMessage(txn, c, m.getId());
+				boolean seen = offered || (sender != null && c.equals(sender));
+				addStatus(txn, m.getId(), c, m.getGroupId(), m.getTimestamp(),
+						m.getLength(), state, e.getValue(), messageShared,
+						false, seen);
+			}
+			// Update denormalised column in messageDependencies if dependency
+			// is in same group as dependent
+			sql = "UPDATE messageDependencies SET dependencyState = ?"
+					+ " WHERE groupId = ? AND dependencyId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, state.getValue());
+			ps.setBytes(2, m.getGroupId().getBytes());
+			ps.setBytes(3, m.getId().getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void addOfferedMessage(Connection txn, ContactId c, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM offers"
+					+ " WHERE messageId = ? AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			if (found) return;
+			sql = "INSERT INTO offers (messageId, contactId) VALUES (?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	private void addStatus(Connection txn, MessageId m, ContactId c, GroupId g,
+			long timestamp, int length, State state, boolean groupShared,
+			boolean messageShared, boolean deleted, boolean seen)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "INSERT INTO statuses (messageId, contactId, groupId,"
+					+ " timestamp, length, state, groupShared, messageShared,"
+					+ " deleted, ack, seen, requested, lastSentTime, txCount)"
+					+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, FALSE, 0, 0)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			ps.setBytes(3, g.getBytes());
+			ps.setLong(4, timestamp);
+			ps.setInt(5, length);
+			ps.setInt(6, state.getValue());
+			ps.setBoolean(7, groupShared);
+			ps.setBoolean(8, messageShared);
+			ps.setBoolean(9, deleted);
+			ps.setBoolean(10, seen);
+			ps.setBoolean(11, seen);
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void addMessageDependency(Connection txn, Message dependent,
+			MessageId dependency, State dependentState) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			// Get state of dependency if present and in same group as dependent
+			String sql = "SELECT state FROM messages"
+					+ " WHERE messageId = ? AND groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, dependency.getBytes());
+			ps.setBytes(2, dependent.getGroupId().getBytes());
+			rs = ps.executeQuery();
+			State dependencyState = null;
+			if (rs.next()) {
+				dependencyState = State.fromValue(rs.getInt(1));
+				if (rs.next()) throw new DbStateException();
+			}
+			rs.close();
+			ps.close();
+			// Create messageDependencies row
+			sql = "INSERT INTO messageDependencies"
+					+ " (groupId, messageId, dependencyId, messageState,"
+					+ " dependencyState)"
+					+ " VALUES (?, ?, ?, ? ,?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, dependent.getGroupId().getBytes());
+			ps.setBytes(2, dependent.getId().getBytes());
+			ps.setBytes(3, dependency.getBytes());
+			ps.setInt(4, dependentState.getValue());
+			if (dependencyState == null) ps.setNull(5, INTEGER);
+			else ps.setInt(5, dependencyState.getValue());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void addTransport(Connection txn, TransportId t, int maxLatency)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "INSERT INTO transports (transportId, maxLatency)"
+					+ " VALUES (?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, t.getString());
+			ps.setLong(2, maxLatency);
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public KeySetId addTransportKeys(Connection txn, ContactId c,
+			TransportKeys k) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			// Store the outgoing keys
+			String sql = "INSERT INTO outgoingKeys (contactId, transportId,"
+					+ " rotationPeriod, tagKey, headerKey, stream, active)"
+					+ " VALUES (?, ?, ?, ?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setString(2, k.getTransportId().getString());
+			OutgoingKeys outCurr = k.getCurrentOutgoingKeys();
+			ps.setLong(3, outCurr.getRotationPeriod());
+			ps.setBytes(4, outCurr.getTagKey().getBytes());
+			ps.setBytes(5, outCurr.getHeaderKey().getBytes());
+			ps.setLong(6, outCurr.getStreamCounter());
+			ps.setBoolean(7, outCurr.isActive());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+			// Get the new (highest) key set ID
+			sql = "SELECT keySetId FROM outgoingKeys"
+					+ " ORDER BY keySetId DESC LIMIT 1";
+			ps = txn.prepareStatement(sql);
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			KeySetId keySetId = new KeySetId(rs.getInt(1));
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			// Store the incoming keys
+			sql = "INSERT INTO incomingKeys (keySetId, contactId, transportId,"
+					+ " rotationPeriod, tagKey, headerKey, base, bitmap,"
+					+ " periodOffset)"
+					+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, keySetId.getInt());
+			ps.setInt(2, c.getInt());
+			ps.setString(3, k.getTransportId().getString());
+			// Previous rotation period
+			IncomingKeys inPrev = k.getPreviousIncomingKeys();
+			ps.setLong(4, inPrev.getRotationPeriod());
+			ps.setBytes(5, inPrev.getTagKey().getBytes());
+			ps.setBytes(6, inPrev.getHeaderKey().getBytes());
+			ps.setLong(7, inPrev.getWindowBase());
+			ps.setBytes(8, inPrev.getWindowBitmap());
+			ps.setInt(9, OFFSET_PREV);
+			ps.addBatch();
+			// Current rotation period
+			IncomingKeys inCurr = k.getCurrentIncomingKeys();
+			ps.setLong(4, inCurr.getRotationPeriod());
+			ps.setBytes(5, inCurr.getTagKey().getBytes());
+			ps.setBytes(6, inCurr.getHeaderKey().getBytes());
+			ps.setLong(7, inCurr.getWindowBase());
+			ps.setBytes(8, inCurr.getWindowBitmap());
+			ps.setInt(9, OFFSET_CURR);
+			ps.addBatch();
+			// Next rotation period
+			IncomingKeys inNext = k.getNextIncomingKeys();
+			ps.setLong(4, inNext.getRotationPeriod());
+			ps.setBytes(5, inNext.getTagKey().getBytes());
+			ps.setBytes(6, inNext.getHeaderKey().getBytes());
+			ps.setLong(7, inNext.getWindowBase());
+			ps.setBytes(8, inNext.getWindowBitmap());
+			ps.setInt(9, OFFSET_NEXT);
+			ps.addBatch();
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != 3) throw new DbStateException();
+			for (int rows : batchAffected)
+				if (rows != 1) throw new DbStateException();
+			ps.close();
+			return keySetId;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public boolean containsContact(Connection txn, AuthorId remote,
+			AuthorId local) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM contacts"
+					+ " WHERE authorId = ? AND localAuthorId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, remote.getBytes());
+			ps.setBytes(2, local.getBytes());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return found;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public boolean containsContact(Connection txn, ContactId c)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM contacts WHERE contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return found;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public boolean containsGroup(Connection txn, GroupId g)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM groups WHERE groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return found;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public boolean containsLocalAuthor(Connection txn, AuthorId a)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM localAuthors WHERE authorId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, a.getBytes());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return found;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public boolean containsMessage(Connection txn, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM messages WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return found;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public boolean containsTransport(Connection txn, TransportId t)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM transports WHERE transportId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, t.getString());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return found;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public boolean containsVisibleMessage(Connection txn, ContactId c,
+			MessageId m) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM statuses"
+					+ " WHERE messageId = ? AND contactId = ?"
+					+ " AND messageShared = TRUE";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return found;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public int countOfferedMessages(Connection txn, ContactId c)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT COUNT (messageId) FROM offers "
+					+ " WHERE contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbException();
+			int count = rs.getInt(1);
+			if (rs.next()) throw new DbException();
+			rs.close();
+			ps.close();
+			return count;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void deleteMessage(Connection txn, MessageId m) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE messages SET raw = NULL WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			if (affected > 1) throw new DbStateException();
+			ps.close();
+			// Update denormalised column in statuses
+			sql = "UPDATE statuses SET deleted = TRUE WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void deleteMessageMetadata(Connection txn, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM messageMetadata WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Contact getContact(Connection txn, ContactId c) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT authorId, formatVersion, name, publicKey,"
+					+ " localAuthorId, verified, active"
+					+ " FROM contacts"
+					+ " WHERE contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			AuthorId authorId = new AuthorId(rs.getBytes(1));
+			int formatVersion = rs.getInt(2);
+			String name = rs.getString(3);
+			byte[] publicKey = rs.getBytes(4);
+			AuthorId localAuthorId = new AuthorId(rs.getBytes(5));
+			boolean verified = rs.getBoolean(6);
+			boolean active = rs.getBoolean(7);
+			rs.close();
+			ps.close();
+			Author author =
+					new Author(authorId, formatVersion, name, publicKey);
+			return new Contact(c, author, localAuthorId, verified, active);
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<Contact> getContacts(Connection txn)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT contactId, authorId, formatVersion, name,"
+					+ " publicKey, localAuthorId, verified, active"
+					+ " FROM contacts";
+			ps = txn.prepareStatement(sql);
+			rs = ps.executeQuery();
+			List<Contact> contacts = new ArrayList<>();
+			while (rs.next()) {
+				ContactId contactId = new ContactId(rs.getInt(1));
+				AuthorId authorId = new AuthorId(rs.getBytes(2));
+				int formatVersion = rs.getInt(3);
+				String name = rs.getString(4);
+				byte[] publicKey = rs.getBytes(5);
+				Author author =
+						new Author(authorId, formatVersion, name, publicKey);
+				AuthorId localAuthorId = new AuthorId(rs.getBytes(6));
+				boolean verified = rs.getBoolean(7);
+				boolean active = rs.getBoolean(8);
+				contacts.add(new Contact(contactId, author, localAuthorId,
+						verified, active));
+			}
+			rs.close();
+			ps.close();
+			return contacts;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<ContactId> getContacts(Connection txn, AuthorId local)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT contactId FROM contacts"
+					+ " WHERE localAuthorId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, local.getBytes());
+			rs = ps.executeQuery();
+			List<ContactId> ids = new ArrayList<>();
+			while (rs.next()) ids.add(new ContactId(rs.getInt(1)));
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<Contact> getContactsByAuthorId(Connection txn,
+			AuthorId remote) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT contactId, formatVersion, name, publicKey,"
+					+ " localAuthorId, verified, active"
+					+ " FROM contacts"
+					+ " WHERE authorId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, remote.getBytes());
+			rs = ps.executeQuery();
+			List<Contact> contacts = new ArrayList<>();
+			while (rs.next()) {
+				ContactId c = new ContactId(rs.getInt(1));
+				int formatVersion = rs.getInt(2);
+				String name = rs.getString(3);
+				byte[] publicKey = rs.getBytes(4);
+				AuthorId localAuthorId = new AuthorId(rs.getBytes(5));
+				boolean verified = rs.getBoolean(6);
+				boolean active = rs.getBoolean(7);
+				Author author =
+						new Author(remote, formatVersion, name, publicKey);
+				contacts.add(new Contact(c, author, localAuthorId, verified,
+						active));
+			}
+			rs.close();
+			ps.close();
+			return contacts;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Group getGroup(Connection txn, GroupId g) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT clientId, majorVersion, descriptor"
+					+ " FROM groups WHERE groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			ClientId clientId = new ClientId(rs.getString(1));
+			int majorVersion = rs.getInt(2);
+			byte[] descriptor = rs.getBytes(3);
+			rs.close();
+			ps.close();
+			return new Group(g, clientId, majorVersion, descriptor);
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<Group> getGroups(Connection txn, ClientId c,
+			int majorVersion) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT groupId, descriptor FROM groups"
+					+ " WHERE clientId = ? AND majorVersion = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, c.getString());
+			ps.setInt(2, majorVersion);
+			rs = ps.executeQuery();
+			List<Group> groups = new ArrayList<>();
+			while (rs.next()) {
+				GroupId id = new GroupId(rs.getBytes(1));
+				byte[] descriptor = rs.getBytes(2);
+				groups.add(new Group(id, c, majorVersion, descriptor));
+			}
+			rs.close();
+			ps.close();
+			return groups;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Visibility getGroupVisibility(Connection txn, ContactId c, GroupId g)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT shared FROM groupVisibilities"
+					+ " WHERE contactId = ? AND groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setBytes(2, g.getBytes());
+			rs = ps.executeQuery();
+			Visibility v;
+			if (rs.next()) v = rs.getBoolean(1) ? SHARED : VISIBLE;
+			else v = INVISIBLE;
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return v;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Map<ContactId, Boolean> getGroupVisibility(Connection txn, GroupId g)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT contactId, shared FROM groupVisibilities"
+					+ " WHERE groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			rs = ps.executeQuery();
+			Map<ContactId, Boolean> visible = new HashMap<>();
+			while (rs.next())
+				visible.put(new ContactId(rs.getInt(1)), rs.getBoolean(2));
+			rs.close();
+			ps.close();
+			return visible;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public LocalAuthor getLocalAuthor(Connection txn, AuthorId a)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT formatVersion, name, publicKey,"
+					+ " privateKey, created"
+					+ " FROM localAuthors"
+					+ " WHERE authorId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, a.getBytes());
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			int formatVersion = rs.getInt(1);
+			String name = rs.getString(2);
+			byte[] publicKey = rs.getBytes(3);
+			byte[] privateKey = rs.getBytes(4);
+			long created = rs.getLong(5);
+			LocalAuthor localAuthor = new LocalAuthor(a, formatVersion, name,
+					publicKey, privateKey, created);
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return localAuthor;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<LocalAuthor> getLocalAuthors(Connection txn)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT authorId, formatVersion, name, publicKey,"
+					+ " privateKey, created"
+					+ " FROM localAuthors";
+			ps = txn.prepareStatement(sql);
+			rs = ps.executeQuery();
+			List<LocalAuthor> authors = new ArrayList<>();
+			while (rs.next()) {
+				AuthorId authorId = new AuthorId(rs.getBytes(1));
+				int formatVersion = rs.getInt(2);
+				String name = rs.getString(3);
+				byte[] publicKey = rs.getBytes(4);
+				byte[] privateKey = rs.getBytes(5);
+				long created = rs.getLong(6);
+				authors.add(new LocalAuthor(authorId, formatVersion, name,
+						publicKey, privateKey, created));
+			}
+			rs.close();
+			ps.close();
+			return authors;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getMessageIds(Connection txn, GroupId g)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT messageId FROM messages"
+					+ " WHERE groupId = ? AND state = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			ps.setInt(2, DELIVERED.getValue());
+			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<>();
+			while (rs.next()) ids.add(new MessageId(rs.getBytes(1)));
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getMessageIds(Connection txn, GroupId g,
+			Metadata query) throws DbException {
+		// If there are no query terms, return all delivered messages
+		if (query.isEmpty()) return getMessageIds(txn, g);
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			// Retrieve the message IDs for each query term and intersect
+			Set<MessageId> intersection = null;
+			String sql = "SELECT messageId FROM messageMetadata"
+					+ " WHERE groupId = ? AND state = ?"
+					+ " AND metaKey = ? AND value = ?";
+			for (Entry<String, byte[]> e : query.entrySet()) {
+				ps = txn.prepareStatement(sql);
+				ps.setBytes(1, g.getBytes());
+				ps.setInt(2, DELIVERED.getValue());
+				ps.setString(3, e.getKey());
+				ps.setBytes(4, e.getValue());
+				rs = ps.executeQuery();
+				Set<MessageId> ids = new HashSet<>();
+				while (rs.next()) ids.add(new MessageId(rs.getBytes(1)));
+				rs.close();
+				ps.close();
+				if (intersection == null) intersection = ids;
+				else intersection.retainAll(ids);
+				// Return early if there are no matches
+				if (intersection.isEmpty()) return Collections.emptySet();
+			}
+			if (intersection == null) throw new AssertionError();
+			return intersection;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Map<MessageId, Metadata> getMessageMetadata(Connection txn,
+			GroupId g) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT messageId, metaKey, value"
+					+ " FROM messageMetadata"
+					+ " WHERE groupId = ? AND state = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			ps.setInt(2, DELIVERED.getValue());
+			rs = ps.executeQuery();
+			Map<MessageId, Metadata> all = new HashMap<>();
+			while (rs.next()) {
+				MessageId messageId = new MessageId(rs.getBytes(1));
+				Metadata metadata = all.get(messageId);
+				if (metadata == null) {
+					metadata = new Metadata();
+					all.put(messageId, metadata);
+				}
+				metadata.put(rs.getString(2), rs.getBytes(3));
+			}
+			rs.close();
+			ps.close();
+			return all;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Map<MessageId, Metadata> getMessageMetadata(Connection txn,
+			GroupId g, Metadata query) throws DbException {
+		// Retrieve the matching message IDs
+		Collection<MessageId> matches = getMessageIds(txn, g, query);
+		if (matches.isEmpty()) return Collections.emptyMap();
+		// Retrieve the metadata for each match
+		Map<MessageId, Metadata> all = new HashMap<>(matches.size());
+		for (MessageId m : matches) all.put(m, getMessageMetadata(txn, m));
+		return all;
+	}
+
+	@Override
+	public Metadata getGroupMetadata(Connection txn, GroupId g)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT metaKey, value FROM groupMetadata"
+					+ " WHERE groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			rs = ps.executeQuery();
+			Metadata metadata = new Metadata();
+			while (rs.next()) metadata.put(rs.getString(1), rs.getBytes(2));
+			rs.close();
+			ps.close();
+			return metadata;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Metadata getMessageMetadata(Connection txn, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT metaKey, value FROM messageMetadata"
+					+ " WHERE state = ? AND messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, DELIVERED.getValue());
+			ps.setBytes(2, m.getBytes());
+			rs = ps.executeQuery();
+			Metadata metadata = new Metadata();
+			while (rs.next()) metadata.put(rs.getString(1), rs.getBytes(2));
+			rs.close();
+			ps.close();
+			return metadata;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Metadata getMessageMetadataForValidator(Connection txn, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT metaKey, value FROM messageMetadata"
+					+ " WHERE (state = ? OR state = ?)"
+					+ " AND messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, DELIVERED.getValue());
+			ps.setInt(2, PENDING.getValue());
+			ps.setBytes(3, m.getBytes());
+			rs = ps.executeQuery();
+			Metadata metadata = new Metadata();
+			while (rs.next()) metadata.put(rs.getString(1), rs.getBytes(2));
+			rs.close();
+			ps.close();
+			return metadata;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageStatus> getMessageStatus(Connection txn,
+			ContactId c, GroupId g) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT messageId, txCount > 0, seen FROM statuses"
+					+ " WHERE groupId = ? AND contactId = ? AND state = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			ps.setInt(2, c.getInt());
+			ps.setInt(3, DELIVERED.getValue());
+			rs = ps.executeQuery();
+			List<MessageStatus> statuses = new ArrayList<>();
+			while (rs.next()) {
+				MessageId messageId = new MessageId(rs.getBytes(1));
+				boolean sent = rs.getBoolean(2);
+				boolean seen = rs.getBoolean(3);
+				statuses.add(new MessageStatus(messageId, c, sent, seen));
+			}
+			rs.close();
+			ps.close();
+			return statuses;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	@Nullable
+	public MessageStatus getMessageStatus(Connection txn, ContactId c,
+			MessageId m) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT txCount > 0, seen FROM statuses"
+					+ " WHERE messageId = ? AND contactId = ? AND state = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			ps.setInt(3, DELIVERED.getValue());
+			rs = ps.executeQuery();
+			MessageStatus status = null;
+			if (rs.next()) {
+				boolean sent = rs.getBoolean(1);
+				boolean seen = rs.getBoolean(2);
+				status = new MessageStatus(m, c, sent, seen);
+			}
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return status;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Map<MessageId, State> getMessageDependencies(Connection txn,
+			MessageId m) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT dependencyId, dependencyState"
+					+ " FROM messageDependencies"
+					+ " WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			rs = ps.executeQuery();
+			Map<MessageId, State> dependencies = new HashMap<>();
+			while (rs.next()) {
+				MessageId dependency = new MessageId(rs.getBytes(1));
+				State state = State.fromValue(rs.getInt(2));
+				if (rs.wasNull())
+					state = UNKNOWN; // Missing or in a different group
+				dependencies.put(dependency, state);
+			}
+			rs.close();
+			ps.close();
+			return dependencies;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Map<MessageId, State> getMessageDependents(Connection txn,
+			MessageId m) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			// Exclude dependencies that are missing or in a different group
+			// from the dependent
+			String sql = "SELECT messageId, messageState"
+					+ " FROM messageDependencies"
+					+ " WHERE dependencyId = ?"
+					+ " AND dependencyState IS NOT NULL";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			rs = ps.executeQuery();
+			Map<MessageId, State> dependents = new HashMap<>();
+			while (rs.next()) {
+				MessageId dependent = new MessageId(rs.getBytes(1));
+				State state = State.fromValue(rs.getInt(2));
+				dependents.put(dependent, state);
+			}
+			rs.close();
+			ps.close();
+			return dependents;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public State getMessageState(Connection txn, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT state FROM messages WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			State state = State.fromValue(rs.getInt(1));
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return state;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getMessagesToAck(Connection txn, ContactId c,
+			int maxMessages) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT messageId FROM statuses"
+					+ " WHERE contactId = ? AND ack = TRUE"
+					+ " LIMIT ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setInt(2, maxMessages);
+			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<>();
+			while (rs.next()) ids.add(new MessageId(rs.getBytes(1)));
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getMessagesToOffer(Connection txn,
+			ContactId c, int maxMessages, int maxLatency) throws DbException {
+		long now = clock.currentTimeMillis();
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT messageId FROM statuses"
+					+ " WHERE contactId = ? AND state = ?"
+					+ " AND groupShared = TRUE AND messageShared = TRUE"
+					+ " AND deleted = FALSE"
+					+ " AND seen = FALSE AND requested = FALSE"
+					+ " AND ( lastSentTime = 0"
+					+ " OR (lastSentTime + ? * POWER(2,txCount+1)) <= ?)"
+					+ " ORDER BY timestamp LIMIT ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setInt(2, DELIVERED.getValue());
+			ps.setInt(3, maxLatency);
+			ps.setLong(4, now);
+			ps.setInt(5, maxMessages);
+			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<>();
+			while (rs.next()) ids.add(new MessageId(rs.getBytes(1)));
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getMessagesToRequest(Connection txn,
+			ContactId c, int maxMessages) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT messageId FROM offers"
+					+ " WHERE contactId = ?"
+					+ " LIMIT ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setInt(2, maxMessages);
+			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<>();
+			while (rs.next()) ids.add(new MessageId(rs.getBytes(1)));
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getMessagesToSend(Connection txn, ContactId c,
+			int maxLength, int maxLatency) throws DbException {
+		long now = clock.currentTimeMillis();
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT length, messageId FROM statuses"
+					+ " WHERE contactId = ? AND state = ?"
+					+ " AND groupShared = TRUE AND messageShared = TRUE"
+					+ " AND deleted = FALSE"
+					+ " AND seen = FALSE"
+					+ " AND ( lastSentTime = 0"
+					+ " OR (lastSentTime + ? * POWER(2,txCount)) <= ?)"
+					+ " ORDER BY timestamp";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setInt(2, DELIVERED.getValue());
+			ps.setLong(3, maxLatency);
+			ps.setLong(4, now);
+			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<>();
+			int total = 0;
+			while (rs.next()) {
+				int length = rs.getInt(1);
+				if (total + length > maxLength) break;
+				ids.add(new MessageId(rs.getBytes(2)));
+				total += length;
+			}
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getMessagesToValidate(Connection txn)
+			throws DbException {
+		return getMessagesInState(txn, UNKNOWN);
+	}
+
+	@Override
+	public Collection<MessageId> getPendingMessages(Connection txn)
+			throws DbException {
+		return getMessagesInState(txn, PENDING);
+	}
+
+	private Collection<MessageId> getMessagesInState(Connection txn,
+			State state) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT messageId FROM messages"
+					+ " WHERE state = ? AND raw IS NOT NULL";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, state.getValue());
+			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<>();
+			while (rs.next()) ids.add(new MessageId(rs.getBytes(1)));
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getMessagesToShare(Connection txn)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT m.messageId FROM messages AS m"
+					+ " JOIN messageDependencies AS d"
+					+ " ON m.messageId = d.dependencyId"
+					+ " JOIN messages AS m1"
+					+ " ON d.messageId = m1.messageId"
+					+ " WHERE m.state = ?"
+					+ " AND m.shared = FALSE AND m1.shared = TRUE";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, DELIVERED.getValue());
+			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<>();
+			while (rs.next()) ids.add(new MessageId(rs.getBytes(1)));
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+
+	@Override
+	public long getNextSendTime(Connection txn, ContactId c, int maxLatency)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT lastSentTime from statuses"
+					+ " WHERE contactId = ? AND state = ? AND lastSentTime = 0"
+					+ " AND groupShared = TRUE AND messageShared = TRUE"
+					+ " AND deleted = FALSE AND seen = FALSE"
+					+ " LIMIT 1";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setInt(2, DELIVERED.getValue());
+			rs = ps.executeQuery();
+			long lastSentTime = Long.MAX_VALUE;
+			if (rs.next()) {
+				lastSentTime = rs.getLong(1);
+				if (rs.next()) throw new AssertionError();
+			} else {
+				ps.close();
+				rs.close();
+				sql =
+						" SELECT (lastSentTime + ? * POWER(2, txCount)) AS expiry FROM statuses"
+								+
+								" WHERE contactId = ? AND state = ? AND lastSentTime > 0"
+								+
+								" AND groupShared = TRUE AND messageShared = TRUE"
+								+ " AND deleted = FALSE AND seen = FALSE"
+								+ " ORDER BY expiry LIMIT 1";
+				ps = txn.prepareStatement(sql);
+				ps.setInt(1, maxLatency);
+				ps.setInt(2, c.getInt());
+				ps.setInt(3, DELIVERED.getValue());
+				rs = ps.executeQuery();
+				if (rs.next()) {
+					lastSentTime = rs.getLong(1);
+					if (rs.next()) throw new AssertionError();
+				}
+			}
+			rs.close();
+			ps.close();
+			return lastSentTime;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	@Nullable
+	public byte[] getRawMessage(Connection txn, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT raw FROM messages WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			byte[] raw = rs.getBytes(1);
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return raw;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<MessageId> getRequestedMessagesToSend(Connection txn,
+			ContactId c, int maxLength) throws DbException {
+		long now = clock.currentTimeMillis();
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT length, messageId FROM statuses"
+					+ " WHERE contactId = ? AND state = ?"
+					+ " AND groupShared = TRUE AND messageShared = TRUE"
+					+ " AND deleted = FALSE"
+					+ " AND seen = FALSE AND requested = TRUE"
+					+ " AND lastSendTime < ?"
+					+ " ORDER BY timestamp";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setInt(2, DELIVERED.getValue());
+			ps.setLong(3, now);
+			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<>();
+			int total = 0;
+			while (rs.next()) {
+				int length = rs.getInt(1);
+				if (total + length > maxLength) break;
+				ids.add(new MessageId(rs.getBytes(2)));
+				total += length;
+			}
+			rs.close();
+			ps.close();
+			return ids;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Settings getSettings(Connection txn, String namespace)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT settingKey, value FROM settings"
+					+ " WHERE namespace = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, namespace);
+			rs = ps.executeQuery();
+			Settings s = new Settings();
+			while (rs.next()) s.put(rs.getString(1), rs.getString(2));
+			rs.close();
+			ps.close();
+			return s;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public Collection<KeySet> getTransportKeys(Connection txn, TransportId t)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			// Retrieve the incoming keys
+			String sql = "SELECT rotationPeriod, tagKey, headerKey,"
+					+ " base, bitmap"
+					+ " FROM incomingKeys"
+					+ " WHERE transportId = ?"
+					+ " ORDER BY keySetId, periodOffset";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, t.getString());
+			rs = ps.executeQuery();
+			List<IncomingKeys> inKeys = new ArrayList<>();
+			while (rs.next()) {
+				long rotationPeriod = rs.getLong(1);
+				SecretKey tagKey = new SecretKey(rs.getBytes(2));
+				SecretKey headerKey = new SecretKey(rs.getBytes(3));
+				long windowBase = rs.getLong(4);
+				byte[] windowBitmap = rs.getBytes(5);
+				inKeys.add(new IncomingKeys(tagKey, headerKey, rotationPeriod,
+						windowBase, windowBitmap));
+			}
+			rs.close();
+			ps.close();
+			// Retrieve the outgoing keys in the same order
+			sql = "SELECT keySetId, contactId, rotationPeriod,"
+					+ " tagKey, headerKey, stream, active"
+					+ " FROM outgoingKeys"
+					+ " WHERE transportId = ?"
+					+ " ORDER BY keySetId";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, t.getString());
+			rs = ps.executeQuery();
+			Collection<KeySet> keys = new ArrayList<>();
+			for (int i = 0; rs.next(); i++) {
+				// There should be three times as many incoming keys
+				if (inKeys.size() < (i + 1) * 3) throw new DbStateException();
+				KeySetId keySetId = new KeySetId(rs.getInt(1));
+				ContactId contactId = new ContactId(rs.getInt(2));
+				long rotationPeriod = rs.getLong(3);
+				SecretKey tagKey = new SecretKey(rs.getBytes(4));
+				SecretKey headerKey = new SecretKey(rs.getBytes(5));
+				long streamCounter = rs.getLong(6);
+				boolean active = rs.getBoolean(7);
+				OutgoingKeys outCurr = new OutgoingKeys(tagKey, headerKey,
+						rotationPeriod, streamCounter, active);
+				IncomingKeys inPrev = inKeys.get(i * 3);
+				IncomingKeys inCurr = inKeys.get(i * 3 + 1);
+				IncomingKeys inNext = inKeys.get(i * 3 + 2);
+				TransportKeys transportKeys = new TransportKeys(t, inPrev,
+						inCurr, inNext, outCurr);
+				keys.add(new KeySet(keySetId, contactId, transportKeys));
+			}
+			rs.close();
+			ps.close();
+			return keys;
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void incrementStreamCounter(Connection txn, TransportId t,
+			KeySetId k) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE outgoingKeys SET stream = stream + 1"
+					+ " WHERE transportId = ? AND keySetId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, t.getString());
+			ps.setInt(2, k.getInt());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void lowerAckFlag(Connection txn, ContactId c,
+			Collection<MessageId> acked) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE statuses SET ack = FALSE"
+					+ " WHERE messageId = ? AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(2, c.getInt());
+			for (MessageId m : acked) {
+				ps.setBytes(1, m.getBytes());
+				ps.addBatch();
+			}
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != acked.size())
+				throw new DbStateException();
+			for (int rows : batchAffected) {
+				if (rows < 0) throw new DbStateException();
+				if (rows > 1) throw new DbStateException();
+			}
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void lowerRequestedFlag(Connection txn, ContactId c,
+			Collection<MessageId> requested) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE statuses SET requested = FALSE"
+					+ " WHERE messageId = ? AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(2, c.getInt());
+			for (MessageId m : requested) {
+				ps.setBytes(1, m.getBytes());
+				ps.addBatch();
+			}
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != requested.size())
+				throw new DbStateException();
+			for (int rows : batchAffected) {
+				if (rows < 0) throw new DbStateException();
+				if (rows > 1) throw new DbStateException();
+			}
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void mergeGroupMetadata(Connection txn, GroupId g, Metadata meta)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			Map<String, byte[]> added = removeOrUpdateMetadata(txn,
+					g.getBytes(), meta, "groupMetadata", "groupId");
+			if (added.isEmpty()) return;
+			// Insert any keys that don't already exist
+			String sql = "INSERT INTO groupMetadata (groupId, metaKey, value)"
+					+ " VALUES (?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			for (Entry<String, byte[]> e : added.entrySet()) {
+				ps.setString(2, e.getKey());
+				ps.setBytes(3, e.getValue());
+				ps.addBatch();
+			}
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != added.size())
+				throw new DbStateException();
+			for (int rows : batchAffected)
+				if (rows != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void mergeMessageMetadata(Connection txn, MessageId m,
+			Metadata meta) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			Map<String, byte[]> added = removeOrUpdateMetadata(txn,
+					m.getBytes(), meta, "messageMetadata", "messageId");
+			if (added.isEmpty()) return;
+			// Get the group ID and message state for the denormalised columns
+			String sql = "SELECT groupId, state FROM messages"
+					+ " WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			GroupId g = new GroupId(rs.getBytes(1));
+			State state = State.fromValue(rs.getInt(2));
+			rs.close();
+			ps.close();
+			// Insert any keys that don't already exist
+			sql = "INSERT INTO messageMetadata"
+					+ " (messageId, groupId, state, metaKey, value)"
+					+ " VALUES (?, ?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setBytes(2, g.getBytes());
+			ps.setInt(3, state.getValue());
+			for (Entry<String, byte[]> e : added.entrySet()) {
+				ps.setString(4, e.getKey());
+				ps.setBytes(5, e.getValue());
+				ps.addBatch();
+			}
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != added.size())
+				throw new DbStateException();
+			for (int rows : batchAffected)
+				if (rows != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	// Removes or updates any existing entries, returns any entries that
+	// need to be added
+	private Map<String, byte[]> removeOrUpdateMetadata(Connection txn,
+			byte[] id, Metadata meta, String tableName, String columnName)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			// Determine which keys are being removed
+			List<String> removed = new ArrayList<>();
+			Map<String, byte[]> notRemoved = new HashMap<>();
+			for (Entry<String, byte[]> e : meta.entrySet()) {
+				if (e.getValue() == REMOVE) removed.add(e.getKey());
+				else notRemoved.put(e.getKey(), e.getValue());
+			}
+			// Delete any keys that are being removed
+			if (!removed.isEmpty()) {
+				String sql = "DELETE FROM " + tableName
+						+ " WHERE " + columnName + " = ? AND metaKey = ?";
+				ps = txn.prepareStatement(sql);
+				ps.setBytes(1, id);
+				for (String key : removed) {
+					ps.setString(2, key);
+					ps.addBatch();
+				}
+				int[] batchAffected = ps.executeBatch();
+				if (batchAffected.length != removed.size())
+					throw new DbStateException();
+				for (int rows : batchAffected) {
+					if (rows < 0) throw new DbStateException();
+					if (rows > 1) throw new DbStateException();
+				}
+				ps.close();
+			}
+			if (notRemoved.isEmpty()) return Collections.emptyMap();
+			// Update any keys that already exist
+			String sql = "UPDATE " + tableName + " SET value = ?"
+					+ " WHERE " + columnName + " = ? AND metaKey = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(2, id);
+			for (Entry<String, byte[]> e : notRemoved.entrySet()) {
+				ps.setBytes(1, e.getValue());
+				ps.setString(3, e.getKey());
+				ps.addBatch();
+			}
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != notRemoved.size())
+				throw new DbStateException();
+			for (int rows : batchAffected) {
+				if (rows < 0) throw new DbStateException();
+				if (rows > 1) throw new DbStateException();
+			}
+			ps.close();
+			// Are there any keys that don't already exist?
+			Map<String, byte[]> added = new HashMap<>();
+			int updateIndex = 0;
+			for (Entry<String, byte[]> e : notRemoved.entrySet()) {
+				if (batchAffected[updateIndex++] == 0)
+					added.put(e.getKey(), e.getValue());
+			}
+			return added;
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void mergeSettings(Connection txn, Settings s, String namespace)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			// Update any settings that already exist
+			String sql = "UPDATE settings SET value = ?"
+					+ " WHERE namespace = ? AND settingKey = ?";
+			ps = txn.prepareStatement(sql);
+			for (Entry<String, String> e : s.entrySet()) {
+				ps.setString(1, e.getValue());
+				ps.setString(2, namespace);
+				ps.setString(3, e.getKey());
+				ps.addBatch();
+			}
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != s.size()) throw new DbStateException();
+			for (int rows : batchAffected) {
+				if (rows < 0) throw new DbStateException();
+				if (rows > 1) throw new DbStateException();
+			}
+			// Insert any settings that don't already exist
+			sql = "INSERT INTO settings (namespace, settingKey, value)"
+					+ " VALUES (?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			int updateIndex = 0, inserted = 0;
+			for (Entry<String, String> e : s.entrySet()) {
+				if (batchAffected[updateIndex] == 0) {
+					ps.setString(1, namespace);
+					ps.setString(2, e.getKey());
+					ps.setString(3, e.getValue());
+					ps.addBatch();
+					inserted++;
+				}
+				updateIndex++;
+			}
+			batchAffected = ps.executeBatch();
+			if (batchAffected.length != inserted) throw new DbStateException();
+			for (int rows : batchAffected)
+				if (rows != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void raiseAckFlag(Connection txn, ContactId c, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE statuses SET ack = TRUE"
+					+ " WHERE messageId = ? AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void raiseRequestedFlag(Connection txn, ContactId c, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE statuses SET requested = TRUE"
+					+ " WHERE messageId = ? AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void raiseSeenFlag(Connection txn, ContactId c, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE statuses SET seen = TRUE"
+					+ " WHERE messageId = ? AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void removeContact(Connection txn, ContactId c)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM contacts WHERE contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void removeGroup(Connection txn, GroupId g) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM groups WHERE groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void removeGroupVisibility(Connection txn, ContactId c, GroupId g)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM groupVisibilities"
+					+ " WHERE contactId = ? AND groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setBytes(2, g.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+			// Remove status rows for the messages in the group
+			sql = "DELETE FROM statuses"
+					+ " WHERE contactId = ? AND groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setBytes(2, g.getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void removeLocalAuthor(Connection txn, AuthorId a)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM localAuthors WHERE authorId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, a.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void removeMessage(Connection txn, MessageId m) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM messages WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	private boolean removeOfferedMessage(Connection txn, ContactId c,
+			MessageId m) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM offers"
+					+ " WHERE contactId = ? AND messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setBytes(2, m.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+			return affected == 1;
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void removeOfferedMessages(Connection txn, ContactId c,
+			Collection<MessageId> requested) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM offers"
+					+ " WHERE contactId = ? AND messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			for (MessageId m : requested) {
+				ps.setBytes(2, m.getBytes());
+				ps.addBatch();
+			}
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != requested.size())
+				throw new DbStateException();
+			for (int rows : batchAffected)
+				if (rows != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void removeTransport(Connection txn, TransportId t)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "DELETE FROM transports WHERE transportId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, t.getString());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void removeTransportKeys(Connection txn, TransportId t, KeySetId k)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			// Delete any existing outgoing keys - this will also remove any
+			// incoming keys with the same key set ID
+			String sql = "DELETE FROM outgoingKeys"
+					+ " WHERE transportId = ? AND keySetId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, t.getString());
+			ps.setInt(2, k.getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void resetExpiryTime(Connection txn, ContactId c, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE statuses SET lastSentTime = 0, txCount = 0"
+					+ " WHERE messageId = ? AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void setContactVerified(Connection txn, ContactId c)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE contacts SET verified = ? WHERE contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBoolean(1, true);
+			ps.setInt(2, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void setContactActive(Connection txn, ContactId c, boolean active)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE contacts SET active = ? WHERE contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBoolean(1, active);
+			ps.setInt(2, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void setGroupVisibility(Connection txn, ContactId c, GroupId g,
+			boolean shared) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE groupVisibilities SET shared = ?"
+					+ " WHERE contactId = ? AND groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBoolean(1, shared);
+			ps.setInt(2, c.getInt());
+			ps.setBytes(3, g.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+			// Update denormalised column in statuses
+			sql = "UPDATE statuses SET groupShared = ?"
+					+ " WHERE contactId = ? AND groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBoolean(1, shared);
+			ps.setInt(2, c.getInt());
+			ps.setBytes(3, g.getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void setMessageShared(Connection txn, MessageId m)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE messages SET shared = TRUE"
+					+ " WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+			// Update denormalised column in statuses
+			sql = "UPDATE statuses SET messageShared = TRUE"
+					+ " WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void setMessageState(Connection txn, MessageId m, State state)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE messages SET state = ? WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, state.getValue());
+			ps.setBytes(2, m.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+			// Update denormalised column in messageMetadata
+			sql = "UPDATE messageMetadata SET state = ? WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, state.getValue());
+			ps.setBytes(2, m.getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+			// Update denormalised column in statuses
+			sql = "UPDATE statuses SET state = ? WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, state.getValue());
+			ps.setBytes(2, m.getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+			// Update denormalised column in messageDependencies
+			sql = "UPDATE messageDependencies SET messageState = ?"
+					+ " WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, state.getValue());
+			ps.setBytes(2, m.getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+			// Update denormalised column in messageDependencies if dependency
+			// is present and in same group as dependent
+			sql = "UPDATE messageDependencies SET dependencyState = ?"
+					+ " WHERE dependencyId = ? AND dependencyState IS NOT NULL";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, state.getValue());
+			ps.setBytes(2, m.getBytes());
+			affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void setReorderingWindow(Connection txn, KeySetId k, TransportId t,
+			long rotationPeriod, long base, byte[] bitmap) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE incomingKeys SET base = ?, bitmap = ?"
+					+ " WHERE transportId = ? AND keySetId = ?"
+					+ " AND rotationPeriod = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setLong(1, base);
+			ps.setBytes(2, bitmap);
+			ps.setString(3, t.getString());
+			ps.setInt(4, k.getInt());
+			ps.setLong(5, rotationPeriod);
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void setTransportKeysActive(Connection txn, TransportId t,
+			KeySetId k) throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE outgoingKeys SET active = true"
+					+ " WHERE transportId = ? AND keySetId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setString(1, t.getString());
+			ps.setInt(2, k.getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void updateLastSentTime(Connection txn, ContactId c, MessageId m,
+			int maxLatency) throws DbException {
+		updateLastSentTime(txn, c, m);
+	}
+
+	@Override
+	public void updateLastSentTime(Connection txn, ContactId c, MessageId m)
+			throws DbException {
+		updateLastSentTime(txn, c, m, 0, clock.currentTimeMillis());
+	}
+
+	@Override
+	public void updateLastSentTime(Connection txn, ContactId c, MessageId m,
+			int maxLatency, long time)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE statuses SET lastSentTime = ?, "
+					+ "txCount = txCount + 1 WHERE messageId = ? "
+					+ "AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setLong(1, time);
+			ps.setBytes(2, m.getBytes());
+			ps.setInt(3, c.getInt());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void updateTransportKeys(Connection txn, KeySet ks)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			// Update the outgoing keys
+			String sql = "UPDATE outgoingKeys SET rotationPeriod = ?,"
+					+ " tagKey = ?, headerKey = ?, stream = ?"
+					+ " WHERE transportId = ? AND keySetId = ?";
+			ps = txn.prepareStatement(sql);
+			TransportKeys k = ks.getTransportKeys();
+			OutgoingKeys outCurr = k.getCurrentOutgoingKeys();
+			ps.setLong(1, outCurr.getRotationPeriod());
+			ps.setBytes(2, outCurr.getTagKey().getBytes());
+			ps.setBytes(3, outCurr.getHeaderKey().getBytes());
+			ps.setLong(4, outCurr.getStreamCounter());
+			ps.setString(5, k.getTransportId().getString());
+			ps.setInt(6, ks.getKeySetId().getInt());
+			int affected = ps.executeUpdate();
+			if (affected < 0 || affected > 1) throw new DbStateException();
+			ps.close();
+			// Update the incoming keys
+			sql = "UPDATE incomingKeys SET rotationPeriod = ?,"
+					+ " tagKey = ?, headerKey = ?, base = ?, bitmap = ?"
+					+ " WHERE transportId = ? AND keySetId = ?"
+					+ " AND periodOffset = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setString(6, k.getTransportId().getString());
+			ps.setInt(7, ks.getKeySetId().getInt());
+			// Previous rotation period
+			IncomingKeys inPrev = k.getPreviousIncomingKeys();
+			ps.setLong(1, inPrev.getRotationPeriod());
+			ps.setBytes(2, inPrev.getTagKey().getBytes());
+			ps.setBytes(3, inPrev.getHeaderKey().getBytes());
+			ps.setLong(4, inPrev.getWindowBase());
+			ps.setBytes(5, inPrev.getWindowBitmap());
+			ps.setInt(8, OFFSET_PREV);
+			ps.addBatch();
+			// Current rotation period
+			IncomingKeys inCurr = k.getCurrentIncomingKeys();
+			ps.setLong(1, inCurr.getRotationPeriod());
+			ps.setBytes(2, inCurr.getTagKey().getBytes());
+			ps.setBytes(3, inCurr.getHeaderKey().getBytes());
+			ps.setLong(4, inCurr.getWindowBase());
+			ps.setBytes(5, inCurr.getWindowBitmap());
+			ps.setInt(8, OFFSET_CURR);
+			ps.addBatch();
+			// Next rotation period
+			IncomingKeys inNext = k.getNextIncomingKeys();
+			ps.setLong(1, inNext.getRotationPeriod());
+			ps.setBytes(2, inNext.getTagKey().getBytes());
+			ps.setBytes(3, inNext.getHeaderKey().getBytes());
+			ps.setLong(4, inNext.getWindowBase());
+			ps.setBytes(5, inNext.getWindowBitmap());
+			ps.setInt(8, OFFSET_NEXT);
+			ps.addBatch();
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != 3) throw new DbStateException();
+			for (int rows : batchAffected)
+				if (rows < 0 || rows > 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+}
diff --git a/bramble-core/src/test/java/org/briarproject/bramble/db/ABDatabasePerformanceComparisonTest.java b/bramble-core/src/test/java/org/briarproject/bramble/db/ABDatabasePerformanceComparisonTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..f5266923ab13cbafc3f3b4413ef78b101bb557e1
--- /dev/null
+++ b/bramble-core/src/test/java/org/briarproject/bramble/db/ABDatabasePerformanceComparisonTest.java
@@ -0,0 +1,24 @@
+package org.briarproject.bramble.db;
+
+import org.briarproject.bramble.api.db.DatabaseConfig;
+import org.briarproject.bramble.api.system.Clock;
+import org.junit.Ignore;
+
+import java.sql.Connection;
+
+@Ignore
+public class ABDatabasePerformanceComparisonTest
+		extends DatabasePerformanceComparisonTest {
+
+	@Override
+	Database<Connection> createDatabase(boolean conditionA,
+			DatabaseConfig databaseConfig, Clock clock) {
+		if (conditionA) return new H2Database(databaseConfig, clock);
+		else return new H2DatabaseB(databaseConfig, clock);
+	}
+
+	@Override
+	protected String getTestName() {
+		return getClass().getSimpleName();
+	}
+}
diff --git a/bramble-core/src/test/java/org/briarproject/bramble/db/ACDatabasePerformanceComparisonTest.java b/bramble-core/src/test/java/org/briarproject/bramble/db/ACDatabasePerformanceComparisonTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..98c53087fc4e8afeb2f8fe87822f0decf69551b1
--- /dev/null
+++ b/bramble-core/src/test/java/org/briarproject/bramble/db/ACDatabasePerformanceComparisonTest.java
@@ -0,0 +1,24 @@
+package org.briarproject.bramble.db;
+
+import org.briarproject.bramble.api.db.DatabaseConfig;
+import org.briarproject.bramble.api.system.Clock;
+import org.junit.Ignore;
+
+import java.sql.Connection;
+
+@Ignore
+public class ACDatabasePerformanceComparisonTest
+		extends DatabasePerformanceComparisonTest {
+
+	@Override
+	Database<Connection> createDatabase(boolean conditionA,
+			DatabaseConfig databaseConfig, Clock clock) {
+		if (conditionA) return new H2Database(databaseConfig, clock);
+		else return new H2DatabaseC(databaseConfig, clock);
+	}
+
+	@Override
+	protected String getTestName() {
+		return getClass().getSimpleName();
+	}
+}
diff --git a/bramble-core/src/test/java/org/briarproject/bramble/db/ADDatabasePerformanceComparisonTest.java b/bramble-core/src/test/java/org/briarproject/bramble/db/ADDatabasePerformanceComparisonTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..ffc0abe7d9eb1d9bd06713a3fe908665b70d672f
--- /dev/null
+++ b/bramble-core/src/test/java/org/briarproject/bramble/db/ADDatabasePerformanceComparisonTest.java
@@ -0,0 +1,24 @@
+package org.briarproject.bramble.db;
+
+import org.briarproject.bramble.api.db.DatabaseConfig;
+import org.briarproject.bramble.api.system.Clock;
+import org.junit.Ignore;
+
+import java.sql.Connection;
+
+@Ignore
+public class ADDatabasePerformanceComparisonTest
+		extends DatabasePerformanceComparisonTest {
+
+	@Override
+	Database<Connection> createDatabase(boolean conditionA,
+			DatabaseConfig databaseConfig, Clock clock) {
+		if (conditionA) return new H2Database(databaseConfig, clock);
+		else return new H2DatabaseMin(databaseConfig, clock);
+	}
+
+	@Override
+	protected String getTestName() {
+		return getClass().getSimpleName();
+	}
+}
diff --git a/bramble-core/src/test/java/org/briarproject/bramble/db/BDDatabasePerformanceComparisonTest.java b/bramble-core/src/test/java/org/briarproject/bramble/db/BDDatabasePerformanceComparisonTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..b36d257e6dba33dc3ce7d65691bbd488a0bfb0cb
--- /dev/null
+++ b/bramble-core/src/test/java/org/briarproject/bramble/db/BDDatabasePerformanceComparisonTest.java
@@ -0,0 +1,24 @@
+package org.briarproject.bramble.db;
+
+import org.briarproject.bramble.api.db.DatabaseConfig;
+import org.briarproject.bramble.api.system.Clock;
+import org.junit.Ignore;
+
+import java.sql.Connection;
+
+@Ignore
+public class BDDatabasePerformanceComparisonTest
+		extends DatabasePerformanceComparisonTest {
+
+	@Override
+	Database<Connection> createDatabase(boolean conditionA,
+			DatabaseConfig databaseConfig, Clock clock) {
+		if (conditionA) return new H2DatabaseB(databaseConfig, clock);
+		else return new H2DatabaseMin(databaseConfig, clock);
+	}
+
+	@Override
+	protected String getTestName() {
+		return getClass().getSimpleName();
+	}
+}
diff --git a/bramble-core/src/test/java/org/briarproject/bramble/db/DatabasePerformanceComparisonTest.java b/bramble-core/src/test/java/org/briarproject/bramble/db/DatabasePerformanceComparisonTest.java
index 279d1b56e6e0ae44fdb1929a37154a7af6c854e9..3f5657d11d03a9b7b616163a9a7a5eb952e87189 100644
--- a/bramble-core/src/test/java/org/briarproject/bramble/db/DatabasePerformanceComparisonTest.java
+++ b/bramble-core/src/test/java/org/briarproject/bramble/db/DatabasePerformanceComparisonTest.java
@@ -1,5 +1,6 @@
 package org.briarproject.bramble.db;
 
+import org.briarproject.bramble.api.crypto.SecretKey;
 import org.briarproject.bramble.api.db.DatabaseConfig;
 import org.briarproject.bramble.api.db.DbException;
 import org.briarproject.bramble.api.system.Clock;
@@ -26,6 +27,7 @@ public abstract class DatabasePerformanceComparisonTest
 	 * How many blocks of each condition to compare.
 	 */
 	private static final int COMPARISON_BLOCKS = 10;
+	private SecretKey databaseKey = getSecretKey();
 
 	abstract Database<Connection> createDatabase(boolean conditionA,
 			DatabaseConfig databaseConfig, Clock clock);
@@ -72,7 +74,7 @@ public abstract class DatabasePerformanceComparisonTest
 			throws DbException {
 		Database<Connection> db = createDatabase(conditionA,
 				new TestDatabaseConfig(testDir, MAX_SIZE), new SystemClock());
-		db.open(getSecretKey(), null);
+		db.open(databaseKey, null);
 		return db;
 	}
 
diff --git a/bramble-core/src/test/java/org/briarproject/bramble/db/DatabasePerformanceTest.java b/bramble-core/src/test/java/org/briarproject/bramble/db/DatabasePerformanceTest.java
index 9fda33e129f5123cf91f0641e1925f75340c38ea..fbaedc1fa5cc5047bac16e36d34342100189cd23 100644
--- a/bramble-core/src/test/java/org/briarproject/bramble/db/DatabasePerformanceTest.java
+++ b/bramble-core/src/test/java/org/briarproject/bramble/db/DatabasePerformanceTest.java
@@ -78,7 +78,7 @@ public abstract class DatabasePerformanceTest extends BrambleTestCase {
 	 */
 	private static final int LOCAL_GROUPS = 5;
 
-	private static final int MESSAGES_PER_GROUP = 20;
+	private static final int MESSAGES_PER_GROUP = 100;
 	private static final int METADATA_KEYS_PER_GROUP = 5;
 	private static final int METADATA_KEYS_PER_MESSAGE = 5;
 	private static final int METADATA_KEY_LENGTH = 10;
@@ -391,6 +391,16 @@ public abstract class DatabasePerformanceTest extends BrambleTestCase {
 		});
 	}
 
+	@Test
+	public void testGetNextSendTime() throws Exception {
+		String name = "getNextSendTime(T, Contactid, maxLatency)";
+		benchmark(name, db -> {
+			Connection txn = db.startTransaction();
+			db.getNextSendTime(txn, pickRandom(contacts).getId(), MAX_LATENCY);
+			db.commitTransaction(txn);
+		});
+	}
+
 	@Test
 	public void testGetMessageMetadataForValidator() throws Exception {
 		String name = "getMessageMetadataForValidator(T, MessageId)";
@@ -573,6 +583,12 @@ public abstract class DatabasePerformanceTest extends BrambleTestCase {
 					db.addMessage(txn, m, state, shared, sender);
 					if (random.nextBoolean())
 						db.raiseRequestedFlag(txn, c, m.getId());
+					long time = random.nextLong();
+					while (Long.compare(time, 0L) == -1) {
+						time = random.nextLong();
+					}
+					db.updateLastSentTime(txn, c, m.getId(), 1000,
+							time);
 					Metadata mm = getMetadata(METADATA_KEYS_PER_MESSAGE);
 					messageMeta.get(g.getId()).add(mm);
 					db.mergeMessageMetadata(txn, m.getId(), mm);