Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift
Peter Steinberger 4b7661e9a0 feat(ios): durable offline command outbox for chat sends (#100331)
* feat(ios): durable offline command outbox for chat sends

Text messages sent while the gateway is unreachable queue in a durable
per-gateway outbox (new outbox_commands table in the per-gateway chat
cache SQLite store, schema v2) instead of failing. Queued bubbles render
with visible Queued/Sending/Not sent states and flush strictly in
createdAt order once transport health recovers; each command's client
UUID rides as the send idempotency key, so at-least-once delivery plus
gateway dedupe keeps the transcript exact.

Contract summary:
- Bounds: 50 queued commands per gateway; refused enqueues keep the
  draft. Queued rows older than 48h expire to failed("expired") rather
  than silently sending stale commands; tap-to-retry refreshes
  createdAt so an expired row can resend as new intent.
- Failure taxonomy: transport-level failures keep rows queued without
  burning retry attempts (backoff ladder, then health drops so the
  reconnect machinery owns pacing); gateway rejections burn attempts
  and fail terminally after 3, with context-menu retry/delete.
- Deletes are tombstoned synchronously and rechecked after the claim
  await so an active flush can never send a removed command; Delete is
  hidden while a bubble is already in flight; offline enqueue is
  guarded against double submit during the health probe.
- Post-reconnect live sends route behind draining outbox rows (FIFO),
  and a cold open assumes a backlog until restore adopts durable rows.
- Crash safety: 'sending' rows revert to 'queued' at startup; flushed
  turns are spliced into the session's cached transcript before their
  outbox row is deleted, and stale history snapshots cannot evict a
  just-flushed turn until a snapshot confirms it.
- Per-gateway scoping and purge ride the transcript cache: one SQLite
  file per gateway; reset/forget drops the queue with the cache.
  Fixture/unpaired transports get no outbox.

Part of #46664

* chore(ios): sync native i18n inventory

* style(chat-ui): satisfy strict SwiftFormat lint (doc comment, scope blank line)
2026-07-06 05:23:46 +01:00

636 lines
27 KiB
Swift

import Foundation
import OSLog
import SQLite3
#if os(iOS)
import UIKit
#endif
private let cacheLogger = Logger(subsystem: "ai.openclaw", category: "OpenClawChatTranscriptCache")
/// Read-only offline cache seam for chat sessions and transcripts.
///
/// The cache only pre-paints cold opens and covers offline browsing; connected
/// reads always come from the gateway and replace cached content wholesale.
/// Implementations must scope all rows to a single gateway identity so
/// transcripts never leak across paired gateways.
public protocol OpenClawChatTranscriptCache: Sendable {
func loadSessions() async -> [OpenClawChatSessionEntry]
func loadTranscript(sessionKey: String) async -> [OpenClawChatMessage]
func storeSessions(_ sessions: [OpenClawChatSessionEntry]) async
func storeTranscript(sessionKey: String, messages: [OpenClawChatMessage]) async
}
/// One durable queued chat command (text only in v1). `id` is the client UUID
/// that becomes the transport idempotency key on flush, so at-least-once
/// delivery stays safe across retries and app restarts.
///
/// Naming mirrors the watch-side `QueuedCommand` shape (WatchChatCoordinator)
/// so the two queues can merge into one owner later.
public struct OpenClawChatOutboxCommand: Hashable, Sendable, Identifiable {
public enum Status: String, Sendable {
case queued
case sending
case failed
}
public let id: String
public let sessionKey: String
public let text: String
/// Thinking level captured when the command was queued, so a later flush
/// never borrows the setting of whichever session is visible then.
public let thinking: String
/// Seconds since 1970; flush order is strictly ascending `createdAt`.
public let createdAt: Double
public var status: Status
public var retryCount: Int
public var lastError: String?
public init(
id: String,
sessionKey: String,
text: String,
thinking: String,
createdAt: Double,
status: Status,
retryCount: Int,
lastError: String?)
{
self.id = id
self.sessionKey = sessionKey
self.text = text
self.thinking = thinking
self.createdAt = createdAt
self.status = status
self.retryCount = retryCount
self.lastError = lastError
}
}
/// Durable offline outbox for chat commands, scoped to one gateway identity
/// exactly like the transcript cache. Implementations persist queued sends so
/// they survive app restarts and flush on reconnect.
public protocol OpenClawChatCommandOutbox: Sendable {
/// Returns false when the queue is full (`maxQueuedCommands`) or storage
/// is unavailable; callers surface that instead of dropping text silently.
func enqueueCommand(_ command: OpenClawChatOutboxCommand) async -> Bool
/// Gateway-scoped rows in `createdAt` order. Applies the staleness gate:
/// queued rows older than `outboxCommandMaxAge` become failed("expired")
/// so reconnect never sends stale commands silently.
func loadCommands() async -> [OpenClawChatOutboxCommand]
/// Crash safety: rows stuck in 'sending' from a previous process revert
/// to 'queued'; the idempotency key makes the re-send safe. Returns false
/// when the store was unreachable (for example Complete file protection
/// while the device is locked) so callers can retry recovery later.
@discardableResult
func recoverInterruptedSends() async -> Bool
/// Claims a row for sending. Returns false when the row no longer exists
/// (deleted mid-flush), so the flush skips it instead of sending stale text.
@discardableResult
func markCommandSending(id: String) async -> Bool
func markCommandQueued(id: String, retryCount: Int, lastError: String?) async
func markCommandFailed(id: String, retryCount: Int, lastError: String?) async
/// Explicit user retry: reset attempts and refresh `createdAt` so an
/// expired row can send again (retry is new intent, so it also moves the
/// command to the queue tail rather than replaying its old position).
func markCommandRetried(id: String) async
func deleteCommand(id: String) async
}
/// SQLite-backed transcript cache for one gateway identity. Owners should use
/// one database file per gateway so reset can physically remove that gateway's
/// cached transcript bytes without disturbing other paired gateways; queries
/// are additionally scoped by `gatewayID` as a defensive belt.
///
/// The cache is disposable: any open, schema, or decode mismatch drops the
/// affected state and rebuilds silently. There are no migrations. The command
/// outbox shares this database; a drop also clears queued commands, which is
/// acceptable because a queue that predates a schema change is stale anyway.
public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, OpenClawChatCommandOutbox {
/// Bounds keep the cache small: enough for a recently-used session picker
/// and a full first screen of transcript, not a durable archive.
public static let maxCachedSessions = 50
public static let maxCachedTranscripts = 50
public static let maxCachedMessagesPerSession = 200
/// Outbox bounds: refuse enqueue beyond this many rows per gateway, and
/// expire queued commands instead of sending them after two days offline.
public static let maxQueuedCommands = 50
public static let outboxCommandMaxAge: TimeInterval = 48 * 60 * 60
/// Machine-readable `lastError` set by the staleness gate.
public static let outboxExpiredError = "expired"
/// v2 adds the outbox_commands table; older shapes drop-and-rebuild.
static let schemaVersion: Int32 = 2
/// Owns the raw sqlite handle so it closes on release without needing an
/// isolated actor deinit (OpaquePointer is not Sendable).
private final class Connection: @unchecked Sendable {
let raw: OpaquePointer
init(raw: OpaquePointer) {
self.raw = raw
}
deinit {
sqlite3_close_v2(self.raw)
}
}
private let databaseURL: URL
private let gatewayID: String
private var db: Connection?
private var isRetired = false
/// After a failed drop-and-rebuild the cache becomes a no-op instead of
/// erroring the chat surface; a fresh launch retries from scratch.
private var isBroken = false
public init(databaseURL: URL, gatewayID: String) {
self.databaseURL = databaseURL
self.gatewayID = gatewayID
}
/// Startup-only cleanup, before any cache actor can own an open handle.
public static func removeDatabaseFiles(at databaseURL: URL) {
let fm = FileManager.default
try? fm.removeItem(at: databaseURL)
for suffix in ["-wal", "-shm", "-journal"] {
try? fm.removeItem(at: URL(fileURLWithPath: databaseURL.path + suffix))
}
}
// MARK: - OpenClawChatTranscriptCache
public func loadSessions() async -> [OpenClawChatSessionEntry] {
guard !self.isRetired else { return [] }
guard let db = await self.handle() else { return [] }
guard let payload = self.selectPayload(
db,
sql: "SELECT payload FROM cached_sessions WHERE gateway_id = ?1",
bindings: [self.gatewayID])
else {
return []
}
guard let decoded = try? JSONDecoder().decode(
[OpenClawChatSessionEntry].self,
from: Data(payload.utf8))
else {
// Decode mismatch means a stale/foreign shape: drop the row silently.
self.execute(db, sql: "DELETE FROM cached_sessions WHERE gateway_id = ?1", bindings: [self.gatewayID])
return []
}
return decoded
}
public func loadTranscript(sessionKey: String) async -> [OpenClawChatMessage] {
guard !self.isRetired else { return [] }
guard let db = await self.handle() else { return [] }
guard let payload = self.selectPayload(
db,
sql: "SELECT payload FROM cached_transcripts WHERE gateway_id = ?1 AND session_key = ?2",
bindings: [self.gatewayID, sessionKey])
else {
return []
}
guard let decoded = try? JSONDecoder().decode(
[OpenClawChatMessage].self,
from: Data(payload.utf8))
else {
self.execute(
db,
sql: "DELETE FROM cached_transcripts WHERE gateway_id = ?1 AND session_key = ?2",
bindings: [self.gatewayID, sessionKey])
return []
}
return decoded
}
public func storeSessions(_ sessions: [OpenClawChatSessionEntry]) async {
guard !self.isRetired else { return }
guard let db = await self.handle() else { return }
let bounded = Self.boundedSessions(sessions)
guard !bounded.isEmpty else {
self.execute(db, sql: "DELETE FROM cached_sessions WHERE gateway_id = ?1", bindings: [self.gatewayID])
return
}
guard let payload = Self.encodeJSON(bounded) else { return }
self.execute(
db,
sql: """
INSERT OR REPLACE INTO cached_sessions(gateway_id, payload, updated_at)
VALUES (?1, ?2, ?3)
""",
bindings: [self.gatewayID, payload, Date().timeIntervalSince1970])
}
public func storeTranscript(sessionKey: String, messages: [OpenClawChatMessage]) async {
guard !self.isRetired else { return }
guard let db = await self.handle() else { return }
let bounded = Self.cacheableMessages(messages)
guard !bounded.isEmpty else {
// An emptied live transcript must also empty the cache, or the next
// cold open would ghost-paint messages the gateway no longer has.
self.execute(
db,
sql: "DELETE FROM cached_transcripts WHERE gateway_id = ?1 AND session_key = ?2",
bindings: [self.gatewayID, sessionKey])
return
}
guard let payload = Self.encodeJSON(bounded) else { return }
self.execute(
db,
sql: """
INSERT OR REPLACE INTO cached_transcripts(gateway_id, session_key, payload, updated_at)
VALUES (?1, ?2, ?3, ?4)
""",
bindings: [self.gatewayID, sessionKey, payload, Date().timeIntervalSince1970])
// rowid tie-breaks equal timestamps: INSERT OR REPLACE mints a fresh
// rowid, so the most recently written transcript always survives.
self.execute(
db,
sql: """
DELETE FROM cached_transcripts WHERE gateway_id = ?1 AND session_key NOT IN (
SELECT session_key FROM cached_transcripts WHERE gateway_id = ?1
ORDER BY updated_at DESC, rowid DESC LIMIT \(Self.maxCachedTranscripts)
)
""",
bindings: [self.gatewayID])
}
public func retire() async {
// A queued write then either finishes before retirement or becomes a
// no-op. Closing the handle lets the owner delete the whole cache file.
self.isRetired = true
self.db = nil
}
// MARK: - OpenClawChatCommandOutbox
public func enqueueCommand(_ command: OpenClawChatOutboxCommand) async -> Bool {
guard !self.isRetired, let db = await self.handle() else { return false }
let count = self.selectInt(
db,
sql: "SELECT COUNT(*) FROM outbox_commands WHERE gateway_id = ?1",
bindings: [self.gatewayID]) ?? 0
// Bound the queue per gateway across all statuses so failed rows also
// count: the user must clear them before queueing more.
guard count < Self.maxQueuedCommands else { return false }
return self.execute(
db,
sql: """
INSERT INTO outbox_commands(
client_uuid, gateway_id, session_key, text, thinking, created_at, status, retry_count, last_error
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, '')
""",
bindings: [
command.id,
self.gatewayID,
command.sessionKey,
command.text,
command.thinking,
command.createdAt,
command.status.rawValue,
command.retryCount,
])
}
public func loadCommands() async -> [OpenClawChatOutboxCommand] {
guard !self.isRetired, let db = await self.handle() else { return [] }
// Staleness gate: a command queued 48h ago is more likely wrong than
// wanted; surface it as failed("expired") instead of sending it.
self.execute(
db,
sql: """
UPDATE outbox_commands SET status = 'failed', last_error = ?3
WHERE gateway_id = ?1 AND status = 'queued' AND created_at < ?2
""",
bindings: [
self.gatewayID,
Date().timeIntervalSince1970 - Self.outboxCommandMaxAge,
Self.outboxExpiredError,
])
var statement: OpaquePointer?
let sql = """
SELECT client_uuid, session_key, text, thinking, created_at, status, retry_count, last_error
FROM outbox_commands WHERE gateway_id = ?1
ORDER BY created_at ASC, id ASC
"""
guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return [] }
defer { sqlite3_finalize(statement) }
guard self.bind(statement, bindings: [self.gatewayID]) else { return [] }
var commands: [OpenClawChatOutboxCommand] = []
while sqlite3_step(statement) == SQLITE_ROW {
guard let id = sqlite3_column_text(statement, 0),
let sessionKey = sqlite3_column_text(statement, 1),
let text = sqlite3_column_text(statement, 2)
else { continue }
let thinking = sqlite3_column_text(statement, 3).map { String(cString: $0) } ?? ""
let statusRaw = sqlite3_column_text(statement, 5).map { String(cString: $0) } ?? ""
let lastError = sqlite3_column_text(statement, 7).map { String(cString: $0) } ?? ""
commands.append(
OpenClawChatOutboxCommand(
id: String(cString: id),
sessionKey: String(cString: sessionKey),
text: String(cString: text),
thinking: thinking,
createdAt: sqlite3_column_double(statement, 4),
// Unknown status means a foreign writer; treating it as
// queued is safe because the idempotency key dedupes.
status: OpenClawChatOutboxCommand.Status(rawValue: statusRaw) ?? .queued,
retryCount: Int(sqlite3_column_int64(statement, 6)),
lastError: lastError.isEmpty ? nil : lastError))
}
return commands
}
@discardableResult
public func recoverInterruptedSends() async -> Bool {
guard !self.isRetired, let db = await self.handle() else { return false }
return self.execute(
db,
sql: "UPDATE outbox_commands SET status = 'queued' WHERE gateway_id = ?1 AND status = 'sending'",
bindings: [self.gatewayID])
}
@discardableResult
public func markCommandSending(id: String) async -> Bool {
guard !self.isRetired, let db = await self.handle() else { return false }
let updated = self.execute(
db,
sql: "UPDATE outbox_commands SET status = 'sending' WHERE gateway_id = ?1 AND client_uuid = ?2",
bindings: [self.gatewayID, id])
// Zero changed rows means the command was deleted while this claim
// was queued; the caller must not send it.
return updated && sqlite3_changes(db) > 0
}
public func markCommandQueued(id: String, retryCount: Int, lastError: String?) async {
await self.updateCommandStatus(id: id, status: "queued", retryCount: retryCount, lastError: lastError)
}
public func markCommandFailed(id: String, retryCount: Int, lastError: String?) async {
await self.updateCommandStatus(id: id, status: "failed", retryCount: retryCount, lastError: lastError)
}
public func markCommandRetried(id: String) async {
guard !self.isRetired, let db = await self.handle() else { return }
// Fresh createdAt: without it the staleness gate would immediately
// re-expire a retried row that sat offline past the 48h bound.
self.execute(
db,
sql: """
UPDATE outbox_commands SET status = 'queued', retry_count = 0, last_error = '', created_at = ?3
WHERE gateway_id = ?1 AND client_uuid = ?2
""",
bindings: [self.gatewayID, id, Date().timeIntervalSince1970])
}
public func deleteCommand(id: String) async {
guard !self.isRetired, let db = await self.handle() else { return }
self.execute(
db,
sql: "DELETE FROM outbox_commands WHERE gateway_id = ?1 AND client_uuid = ?2",
bindings: [self.gatewayID, id])
}
private func updateCommandStatus(id: String, status: String, retryCount: Int, lastError: String?) async {
guard !self.isRetired, let db = await self.handle() else { return }
self.execute(
db,
sql: """
UPDATE outbox_commands SET status = ?3, retry_count = ?4, last_error = ?5
WHERE gateway_id = ?1 AND client_uuid = ?2
""",
bindings: [self.gatewayID, id, status, retryCount, lastError ?? ""])
}
// MARK: - Cached shapes
/// Text rows only in v1: strip attachment/binary payloads and tool
/// arguments so the cache never persists base64 blobs or large payloads.
static func cacheableMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] {
messages.suffix(self.maxCachedMessagesPerSession).map { message in
OpenClawChatMessage(
id: message.id,
role: message.role,
content: message.content.map { item in
OpenClawChatMessageContent(
type: item.type,
text: item.text,
thinking: item.thinking,
thinkingSignature: nil,
mimeType: item.mimeType,
fileName: item.fileName,
content: nil,
id: item.id,
name: item.name,
arguments: nil)
},
timestamp: message.timestamp,
idempotencyKey: message.idempotencyKey,
toolCallId: message.toolCallId,
toolName: message.toolName,
usage: message.usage,
stopReason: message.stopReason,
errorMessage: message.errorMessage)
}
}
static func boundedSessions(_ sessions: [OpenClawChatSessionEntry]) -> [OpenClawChatSessionEntry] {
guard sessions.count > self.maxCachedSessions else { return sessions }
return Array(
sessions
.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
.prefix(self.maxCachedSessions))
}
private static func encodeJSON(_ value: some Encodable) -> String? {
guard let data = try? JSONEncoder().encode(value) else { return nil }
return String(bytes: data, encoding: .utf8)
}
// MARK: - Connection lifecycle
private func handle() async -> OpaquePointer? {
guard !self.isRetired else { return nil }
if let db { return db.raw }
if self.isBroken { return nil }
#if os(iOS)
// Complete protection intentionally makes the cache unavailable while
// locked. Treat that as a temporary miss, never as corruption.
guard await self.isProtectedDataAvailable(), !self.isRetired else { return nil }
#endif
if let opened = self.openConnection() {
self.db = Connection(raw: opened)
return opened
}
#if os(iOS)
guard await self.isProtectedDataAvailable(), !self.isRetired else { return nil }
#endif
// Cache is disposable: on any open/schema failure drop the file
// (and SQLite sidecars) and rebuild once, silently.
self.removeDatabaseFiles()
if let reopened = self.openConnection() {
self.db = Connection(raw: reopened)
return reopened
}
cacheLogger.error("chat transcript cache unavailable; continuing without offline cache")
self.isBroken = true
return nil
}
#if os(iOS)
private func isProtectedDataAvailable() async -> Bool {
await MainActor.run { UIApplication.shared.isProtectedDataAvailable }
}
#endif
private func openConnection() -> OpaquePointer? {
let fm = FileManager.default
try? fm.createDirectory(
at: self.databaseURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
var opened: OpaquePointer?
var flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX
#if os(iOS)
// Apply Complete protection through the SQLite VFS so auxiliary files
// receive the same class as the main transcript database.
flags |= SQLITE_OPEN_FILEPROTECTION_COMPLETE
#endif
guard sqlite3_open_v2(self.databaseURL.path, &opened, flags, nil) == SQLITE_OK, let opened else {
sqlite3_close_v2(opened)
return nil
}
guard let version = self.readUserVersion(opened) else {
sqlite3_close_v2(opened)
return nil
}
if version == 0 {
guard self.createSchema(opened) else {
sqlite3_close_v2(opened)
return nil
}
} else if version != Self.schemaVersion {
// Unknown schema: no migrations by design, force drop-and-rebuild.
sqlite3_close_v2(opened)
return nil
}
#if os(iOS)
// Upgrade a database created by an older build to the stricter class.
try? fm.setAttributes(
[.protectionKey: FileProtectionType.complete],
ofItemAtPath: self.databaseURL.path)
#endif
return opened
}
private func readUserVersion(_ db: OpaquePointer) -> Int32? {
var statement: OpaquePointer?
guard sqlite3_prepare_v2(db, "PRAGMA user_version", -1, &statement, nil) == SQLITE_OK else {
return nil
}
defer { sqlite3_finalize(statement) }
guard sqlite3_step(statement) == SQLITE_ROW else { return nil }
return sqlite3_column_int(statement, 0)
}
private func createSchema(_ db: OpaquePointer) -> Bool {
let statements = [
"""
CREATE TABLE IF NOT EXISTS cached_sessions(
gateway_id TEXT NOT NULL PRIMARY KEY,
payload TEXT NOT NULL,
updated_at REAL NOT NULL
)
""",
"""
CREATE TABLE IF NOT EXISTS cached_transcripts(
gateway_id TEXT NOT NULL,
session_key TEXT NOT NULL,
payload TEXT NOT NULL,
updated_at REAL NOT NULL,
PRIMARY KEY(gateway_id, session_key)
)
""",
// last_error uses '' for "none" so every column binds non-null.
// rowid `id` breaks created_at ties so flush order stays stable.
"""
CREATE TABLE IF NOT EXISTS outbox_commands(
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_uuid TEXT NOT NULL UNIQUE,
gateway_id TEXT NOT NULL,
session_key TEXT NOT NULL,
text TEXT NOT NULL,
thinking TEXT NOT NULL DEFAULT '',
created_at REAL NOT NULL,
status TEXT NOT NULL,
retry_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT ''
)
""",
"PRAGMA user_version = \(Self.schemaVersion)",
]
for sql in statements {
guard sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK else { return false }
}
return true
}
// MARK: - Statement helpers
@discardableResult
private func execute(_ db: OpaquePointer, sql: String, bindings: [Any]) -> Bool {
var statement: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
cacheLogger.error("cache statement prepare failed")
return false
}
defer { sqlite3_finalize(statement) }
guard self.bind(statement, bindings: bindings) else { return false }
return sqlite3_step(statement) == SQLITE_DONE
}
private func selectInt(_ db: OpaquePointer, sql: String, bindings: [Any]) -> Int? {
var statement: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return nil }
defer { sqlite3_finalize(statement) }
guard self.bind(statement, bindings: bindings) else { return nil }
guard sqlite3_step(statement) == SQLITE_ROW else { return nil }
return Int(sqlite3_column_int64(statement, 0))
}
private func selectPayload(_ db: OpaquePointer, sql: String, bindings: [Any]) -> String? {
var statement: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return nil }
defer { sqlite3_finalize(statement) }
guard self.bind(statement, bindings: bindings) else { return nil }
guard sqlite3_step(statement) == SQLITE_ROW else { return nil }
guard let text = sqlite3_column_text(statement, 0) else { return nil }
return String(cString: text)
}
private func bind(_ statement: OpaquePointer?, bindings: [Any]) -> Bool {
// SQLITE_TRANSIENT: sqlite copies the buffer before the Swift string dies.
let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
for (offset, value) in bindings.enumerated() {
let index = Int32(offset + 1)
let result: Int32 = switch value {
case let text as String:
sqlite3_bind_text(statement, index, text, -1, transient)
case let int as Int:
sqlite3_bind_int64(statement, index, Int64(int))
case let real as Double:
sqlite3_bind_double(statement, index, real)
default:
SQLITE_MISUSE
}
guard result == SQLITE_OK else { return false }
}
return true
}
private func removeDatabaseFiles() {
self.db = nil
Self.removeDatabaseFiles(at: self.databaseURL)
}
}