582 lines
23 KiB
Swift
582 lines
23 KiB
Swift
|
|
import Foundation
|
||
|
|
import SQLite3
|
||
|
|
|
||
|
|
enum SQLiteUsageCacheError: Error {
|
||
|
|
case openFailed(String)
|
||
|
|
case prepareFailed(String)
|
||
|
|
case executeFailed(String)
|
||
|
|
case bindFailed(String)
|
||
|
|
case stepFailed(String)
|
||
|
|
}
|
||
|
|
|
||
|
|
final class SQLiteUsageCache {
|
||
|
|
private static let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
||
|
|
|
||
|
|
private let database: OpaquePointer?
|
||
|
|
private var calendar: Calendar = {
|
||
|
|
var calendar = Calendar.current
|
||
|
|
calendar.firstWeekday = 2
|
||
|
|
return calendar
|
||
|
|
}()
|
||
|
|
|
||
|
|
init(url: URL) throws {
|
||
|
|
try FileManager.default.createDirectory(
|
||
|
|
at: url.deletingLastPathComponent(),
|
||
|
|
withIntermediateDirectories: true
|
||
|
|
)
|
||
|
|
|
||
|
|
var database: OpaquePointer?
|
||
|
|
let flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX
|
||
|
|
guard sqlite3_open_v2(url.path, &database, flags, nil) == SQLITE_OK else {
|
||
|
|
let message = database.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "无法打开 SQLite"
|
||
|
|
sqlite3_close(database)
|
||
|
|
throw SQLiteUsageCacheError.openFailed(message)
|
||
|
|
}
|
||
|
|
self.database = database
|
||
|
|
|
||
|
|
try configure()
|
||
|
|
try migrate()
|
||
|
|
}
|
||
|
|
|
||
|
|
deinit {
|
||
|
|
sqlite3_close(database)
|
||
|
|
}
|
||
|
|
|
||
|
|
func isFileCurrent(path: String, size: Int64, modifiedAt: TimeInterval) throws -> Bool {
|
||
|
|
let statement = try prepare("SELECT size, modified_at, scanned_offset FROM files WHERE path = ? LIMIT 1")
|
||
|
|
defer { sqlite3_finalize(statement) }
|
||
|
|
|
||
|
|
try bind(path, to: statement, at: 1)
|
||
|
|
guard sqlite3_step(statement) == SQLITE_ROW else {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
let cachedSize = sqlite3_column_int64(statement, 0)
|
||
|
|
let cachedModifiedAt = sqlite3_column_double(statement, 1)
|
||
|
|
let scannedOffset = sqlite3_column_int64(statement, 2)
|
||
|
|
return cachedSize == size && scannedOffset == size && abs(cachedModifiedAt - modifiedAt) < 0.001
|
||
|
|
}
|
||
|
|
|
||
|
|
func fileState(path: String) throws -> CachedFileState? {
|
||
|
|
let statement = try prepare("""
|
||
|
|
SELECT size, modified_at, scanned_offset, record_count
|
||
|
|
FROM files
|
||
|
|
WHERE path = ?
|
||
|
|
LIMIT 1
|
||
|
|
""")
|
||
|
|
defer { sqlite3_finalize(statement) }
|
||
|
|
|
||
|
|
try bind(path, to: statement, at: 1)
|
||
|
|
guard sqlite3_step(statement) == SQLITE_ROW else {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
return CachedFileState(
|
||
|
|
size: sqlite3_column_int64(statement, 0),
|
||
|
|
modifiedAt: sqlite3_column_double(statement, 1),
|
||
|
|
scannedOffset: sqlite3_column_int64(statement, 2),
|
||
|
|
recordCount: Int(sqlite3_column_int64(statement, 3))
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
func replaceFileRecords(
|
||
|
|
path: String,
|
||
|
|
size: Int64,
|
||
|
|
modifiedAt: TimeInterval,
|
||
|
|
scan: (_ insert: (UsageRecord) throws -> Void) throws -> Int64
|
||
|
|
) throws {
|
||
|
|
try execute("BEGIN IMMEDIATE TRANSACTION")
|
||
|
|
do {
|
||
|
|
try deleteRecords(path: path)
|
||
|
|
let insertStatement = try prepare("""
|
||
|
|
INSERT INTO records (
|
||
|
|
id, provider, source_path, timestamp, session_id, model,
|
||
|
|
input_tokens, output_tokens, cached_tokens, reasoning_tokens,
|
||
|
|
tool_tokens, total_tokens, day_start, hour_start, month_start
|
||
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
|
|
""")
|
||
|
|
defer { sqlite3_finalize(insertStatement) }
|
||
|
|
|
||
|
|
var recordCount = 0
|
||
|
|
let scannedOffset = try scan { record in
|
||
|
|
recordCount += 1
|
||
|
|
try self.insert(record, id: "\(path)#\(recordCount)", statement: insertStatement)
|
||
|
|
}
|
||
|
|
try upsertFile(
|
||
|
|
path: path,
|
||
|
|
size: size,
|
||
|
|
modifiedAt: modifiedAt,
|
||
|
|
scannedOffset: scannedOffset,
|
||
|
|
recordCount: recordCount
|
||
|
|
)
|
||
|
|
try execute("COMMIT")
|
||
|
|
} catch {
|
||
|
|
try? execute("ROLLBACK")
|
||
|
|
throw error
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func appendFileRecords(
|
||
|
|
path: String,
|
||
|
|
size: Int64,
|
||
|
|
modifiedAt: TimeInterval,
|
||
|
|
startingRecordCount: Int,
|
||
|
|
scan: (_ insert: (UsageRecord) throws -> Void) throws -> Int64
|
||
|
|
) throws {
|
||
|
|
try execute("BEGIN IMMEDIATE TRANSACTION")
|
||
|
|
do {
|
||
|
|
let insertStatement = try prepare("""
|
||
|
|
INSERT INTO records (
|
||
|
|
id, provider, source_path, timestamp, session_id, model,
|
||
|
|
input_tokens, output_tokens, cached_tokens, reasoning_tokens,
|
||
|
|
tool_tokens, total_tokens, day_start, hour_start, month_start
|
||
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
|
|
""")
|
||
|
|
defer { sqlite3_finalize(insertStatement) }
|
||
|
|
|
||
|
|
var appendedCount = 0
|
||
|
|
let scannedOffset = try scan { record in
|
||
|
|
appendedCount += 1
|
||
|
|
let nextRecordIndex = startingRecordCount + appendedCount
|
||
|
|
try self.insert(record, id: "\(path)#\(nextRecordIndex)", statement: insertStatement)
|
||
|
|
}
|
||
|
|
try upsertFile(
|
||
|
|
path: path,
|
||
|
|
size: size,
|
||
|
|
modifiedAt: modifiedAt,
|
||
|
|
scannedOffset: scannedOffset,
|
||
|
|
recordCount: startingRecordCount + appendedCount
|
||
|
|
)
|
||
|
|
try execute("COMMIT")
|
||
|
|
} catch {
|
||
|
|
try? execute("ROLLBACK")
|
||
|
|
throw error
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func pruneMissingFiles(currentPaths: Set<String>) throws {
|
||
|
|
let statement = try prepare("SELECT path FROM files")
|
||
|
|
defer { sqlite3_finalize(statement) }
|
||
|
|
|
||
|
|
var cachedPaths: [String] = []
|
||
|
|
while sqlite3_step(statement) == SQLITE_ROW {
|
||
|
|
cachedPaths.append(columnText(statement, at: 0))
|
||
|
|
}
|
||
|
|
|
||
|
|
for path in cachedPaths where !currentPaths.contains(path) {
|
||
|
|
try deleteRecords(path: path)
|
||
|
|
let deleteFile = try prepare("DELETE FROM files WHERE path = ?")
|
||
|
|
defer { sqlite3_finalize(deleteFile) }
|
||
|
|
try bind(path, to: deleteFile, at: 1)
|
||
|
|
try stepDone(deleteFile)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func makeSummary(now: Date = Date(), calendar: Calendar = Calendar.current) throws -> UsageSummary {
|
||
|
|
self.calendar = calendarWithMondayStart(calendar)
|
||
|
|
|
||
|
|
let providerTotals = try providerBuckets()
|
||
|
|
let todayProviderTotals = try providerBuckets(
|
||
|
|
whereClause: "timestamp >= ? AND timestamp < ?",
|
||
|
|
bindings: dayBounds(for: now).bindings
|
||
|
|
)
|
||
|
|
let dailyDetails = try dailyUsageDetails()
|
||
|
|
let dailyTrend = dailyDetails
|
||
|
|
.sorted { $0.date < $1.date }
|
||
|
|
.suffix(30)
|
||
|
|
.map { detail in
|
||
|
|
DailyPoint(date: detail.date, values: detail.providerTotals)
|
||
|
|
}
|
||
|
|
let trendPointsByMode = try Dictionary(uniqueKeysWithValues: TrendMode.allCases.map { mode in
|
||
|
|
(mode, try trendPoints(mode: mode, now: now))
|
||
|
|
})
|
||
|
|
let toolRows = Provider.allCases.compactMap { provider -> ToolRow? in
|
||
|
|
guard let bucket = providerTotals[provider], bucket.totalTokens > 0 else { return nil }
|
||
|
|
return ToolRow(
|
||
|
|
provider: provider,
|
||
|
|
inputTokens: bucket.inputTokens,
|
||
|
|
outputTokens: bucket.outputTokens,
|
||
|
|
cachedTokens: bucket.cachedTokens,
|
||
|
|
totalTokens: bucket.totalTokens,
|
||
|
|
trendTokens: dailyTrend.map { $0.values[provider, default: 0] }
|
||
|
|
)
|
||
|
|
}
|
||
|
|
.sorted { $0.totalTokens > $1.totalTokens }
|
||
|
|
|
||
|
|
let todayTokens = todayProviderTotals.values.reduce(0) { $0 + $1.totalTokens }
|
||
|
|
let monthTokens = try tokenSum(whereClause: "timestamp >= ? AND timestamp < ?", bindings: monthBounds(for: now).bindings)
|
||
|
|
let totalTokens = providerTotals.values.reduce(0) { $0 + $1.totalTokens }
|
||
|
|
let cachedTokens = providerTotals.values.reduce(0) { $0 + $1.cachedTokens }
|
||
|
|
let cacheHitRate = totalTokens > 0 ? Int((Double(cachedTokens) / Double(totalTokens) * 100).rounded()) : 0
|
||
|
|
let cards = SummaryCards(
|
||
|
|
todayTokens: todayTokens,
|
||
|
|
monthTokens: monthTokens,
|
||
|
|
totalTokens: totalTokens,
|
||
|
|
cacheHitRate: cacheHitRate,
|
||
|
|
sessionCount: try sessionCount()
|
||
|
|
)
|
||
|
|
|
||
|
|
return UsageSummary(
|
||
|
|
generatedAt: now,
|
||
|
|
records: [],
|
||
|
|
cards: cards,
|
||
|
|
providerTotals: providerTotals,
|
||
|
|
todayProviderTotals: todayProviderTotals,
|
||
|
|
dailyTrend: Array(dailyTrend),
|
||
|
|
trendPointsByMode: trendPointsByMode,
|
||
|
|
dailyDetails: dailyDetails.sorted { $0.date > $1.date },
|
||
|
|
toolRows: toolRows,
|
||
|
|
insights: UsageSummary.makeInsights(
|
||
|
|
providerTotals: providerTotals,
|
||
|
|
todayTokens: todayTokens,
|
||
|
|
cacheHitRate: cacheHitRate
|
||
|
|
)
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func configure() throws {
|
||
|
|
try execute("PRAGMA journal_mode = WAL")
|
||
|
|
try execute("PRAGMA synchronous = NORMAL")
|
||
|
|
try execute("PRAGMA temp_store = MEMORY")
|
||
|
|
try execute("PRAGMA busy_timeout = 3000")
|
||
|
|
}
|
||
|
|
|
||
|
|
private func migrate() throws {
|
||
|
|
if try recordsTableNeedsRebuild() {
|
||
|
|
try execute("DROP TABLE IF EXISTS records")
|
||
|
|
try execute("DROP TABLE IF EXISTS files")
|
||
|
|
}
|
||
|
|
try execute("""
|
||
|
|
CREATE TABLE IF NOT EXISTS files (
|
||
|
|
path TEXT PRIMARY KEY,
|
||
|
|
size INTEGER NOT NULL,
|
||
|
|
modified_at REAL NOT NULL,
|
||
|
|
scanned_offset INTEGER NOT NULL DEFAULT 0,
|
||
|
|
record_count INTEGER NOT NULL DEFAULT 0,
|
||
|
|
scanned_at REAL NOT NULL
|
||
|
|
)
|
||
|
|
""")
|
||
|
|
try addColumnIfMissing(
|
||
|
|
table: "files",
|
||
|
|
column: "scanned_offset",
|
||
|
|
definition: "scanned_offset INTEGER NOT NULL DEFAULT 0"
|
||
|
|
)
|
||
|
|
try addColumnIfMissing(
|
||
|
|
table: "files",
|
||
|
|
column: "record_count",
|
||
|
|
definition: "record_count INTEGER NOT NULL DEFAULT 0"
|
||
|
|
)
|
||
|
|
try execute("""
|
||
|
|
CREATE TABLE IF NOT EXISTS records (
|
||
|
|
id TEXT PRIMARY KEY,
|
||
|
|
provider TEXT NOT NULL,
|
||
|
|
source_path TEXT NOT NULL,
|
||
|
|
timestamp REAL NOT NULL,
|
||
|
|
session_id TEXT NOT NULL,
|
||
|
|
model TEXT NOT NULL,
|
||
|
|
input_tokens INTEGER NOT NULL,
|
||
|
|
output_tokens INTEGER NOT NULL,
|
||
|
|
cached_tokens INTEGER NOT NULL,
|
||
|
|
reasoning_tokens INTEGER NOT NULL,
|
||
|
|
tool_tokens INTEGER NOT NULL,
|
||
|
|
total_tokens INTEGER NOT NULL,
|
||
|
|
day_start REAL NOT NULL,
|
||
|
|
hour_start REAL NOT NULL,
|
||
|
|
month_start REAL NOT NULL
|
||
|
|
)
|
||
|
|
""")
|
||
|
|
try execute("CREATE INDEX IF NOT EXISTS records_provider_idx ON records(provider)")
|
||
|
|
try execute("CREATE INDEX IF NOT EXISTS records_source_path_idx ON records(source_path)")
|
||
|
|
try execute("CREATE INDEX IF NOT EXISTS records_timestamp_idx ON records(timestamp)")
|
||
|
|
try execute("CREATE INDEX IF NOT EXISTS records_day_provider_idx ON records(day_start, provider)")
|
||
|
|
try execute("CREATE INDEX IF NOT EXISTS records_hour_provider_idx ON records(hour_start, provider)")
|
||
|
|
try execute("CREATE INDEX IF NOT EXISTS records_month_provider_idx ON records(month_start, provider)")
|
||
|
|
}
|
||
|
|
|
||
|
|
private func deleteRecords(path: String) throws {
|
||
|
|
let statement = try prepare("DELETE FROM records WHERE source_path = ?")
|
||
|
|
defer { sqlite3_finalize(statement) }
|
||
|
|
try bind(path, to: statement, at: 1)
|
||
|
|
try stepDone(statement)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func upsertFile(
|
||
|
|
path: String,
|
||
|
|
size: Int64,
|
||
|
|
modifiedAt: TimeInterval,
|
||
|
|
scannedOffset: Int64,
|
||
|
|
recordCount: Int
|
||
|
|
) throws {
|
||
|
|
let statement = try prepare("""
|
||
|
|
INSERT INTO files (path, size, modified_at, scanned_offset, record_count, scanned_at)
|
||
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
||
|
|
ON CONFLICT(path) DO UPDATE SET
|
||
|
|
size = excluded.size,
|
||
|
|
modified_at = excluded.modified_at,
|
||
|
|
scanned_offset = excluded.scanned_offset,
|
||
|
|
record_count = excluded.record_count,
|
||
|
|
scanned_at = excluded.scanned_at
|
||
|
|
""")
|
||
|
|
defer { sqlite3_finalize(statement) }
|
||
|
|
|
||
|
|
try bind(path, to: statement, at: 1)
|
||
|
|
sqlite3_bind_int64(statement, 2, size)
|
||
|
|
sqlite3_bind_double(statement, 3, modifiedAt)
|
||
|
|
sqlite3_bind_int64(statement, 4, scannedOffset)
|
||
|
|
sqlite3_bind_int64(statement, 5, Int64(recordCount))
|
||
|
|
sqlite3_bind_double(statement, 6, Date().timeIntervalSince1970)
|
||
|
|
try stepDone(statement)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func insert(_ record: UsageRecord, id: String, statement: OpaquePointer?) throws {
|
||
|
|
sqlite3_reset(statement)
|
||
|
|
sqlite3_clear_bindings(statement)
|
||
|
|
|
||
|
|
try bind(id, to: statement, at: 1)
|
||
|
|
try bind(record.provider.rawValue, to: statement, at: 2)
|
||
|
|
try bind(record.sourcePath, to: statement, at: 3)
|
||
|
|
sqlite3_bind_double(statement, 4, record.timestamp.timeIntervalSince1970)
|
||
|
|
try bind(record.sessionID, to: statement, at: 5)
|
||
|
|
try bind(record.model, to: statement, at: 6)
|
||
|
|
sqlite3_bind_int64(statement, 7, Int64(record.inputTokens))
|
||
|
|
sqlite3_bind_int64(statement, 8, Int64(record.outputTokens))
|
||
|
|
sqlite3_bind_int64(statement, 9, Int64(record.cachedTokens))
|
||
|
|
sqlite3_bind_int64(statement, 10, Int64(record.reasoningTokens))
|
||
|
|
sqlite3_bind_int64(statement, 11, Int64(record.toolTokens))
|
||
|
|
sqlite3_bind_int64(statement, 12, Int64(record.totalTokens))
|
||
|
|
sqlite3_bind_double(statement, 13, calendar.startOfDay(for: record.timestamp).timeIntervalSince1970)
|
||
|
|
sqlite3_bind_double(statement, 14, hourStart(for: record.timestamp).timeIntervalSince1970)
|
||
|
|
sqlite3_bind_double(statement, 15, monthStart(for: record.timestamp).timeIntervalSince1970)
|
||
|
|
try stepDone(statement)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func providerBuckets(
|
||
|
|
whereClause: String? = nil,
|
||
|
|
bindings: [TimeInterval] = []
|
||
|
|
) throws -> [Provider: TokenBucket] {
|
||
|
|
var buckets = Dictionary(uniqueKeysWithValues: Provider.allCases.map {
|
||
|
|
($0, TokenBucket(provider: $0))
|
||
|
|
})
|
||
|
|
var sql = """
|
||
|
|
SELECT provider,
|
||
|
|
SUM(input_tokens), SUM(output_tokens), SUM(cached_tokens),
|
||
|
|
SUM(reasoning_tokens), SUM(tool_tokens), SUM(total_tokens), COUNT(*)
|
||
|
|
FROM records
|
||
|
|
"""
|
||
|
|
if let whereClause {
|
||
|
|
sql += " WHERE \(whereClause)"
|
||
|
|
}
|
||
|
|
sql += " GROUP BY provider"
|
||
|
|
|
||
|
|
let statement = try prepare(sql)
|
||
|
|
defer { sqlite3_finalize(statement) }
|
||
|
|
try bindIntervals(bindings, to: statement)
|
||
|
|
|
||
|
|
while sqlite3_step(statement) == SQLITE_ROW {
|
||
|
|
guard let provider = Provider(rawValue: columnText(statement, at: 0)) else { continue }
|
||
|
|
var bucket = TokenBucket(provider: provider)
|
||
|
|
bucket.inputTokens = Int(sqlite3_column_int64(statement, 1))
|
||
|
|
bucket.outputTokens = Int(sqlite3_column_int64(statement, 2))
|
||
|
|
bucket.cachedTokens = Int(sqlite3_column_int64(statement, 3))
|
||
|
|
bucket.reasoningTokens = Int(sqlite3_column_int64(statement, 4))
|
||
|
|
bucket.toolTokens = Int(sqlite3_column_int64(statement, 5))
|
||
|
|
bucket.totalTokens = Int(sqlite3_column_int64(statement, 6))
|
||
|
|
bucket.count = Int(sqlite3_column_int64(statement, 7))
|
||
|
|
buckets[provider] = bucket
|
||
|
|
}
|
||
|
|
|
||
|
|
return buckets
|
||
|
|
}
|
||
|
|
|
||
|
|
private func dailyUsageDetails() throws -> [DailyUsageDetail] {
|
||
|
|
let statement = try prepare("""
|
||
|
|
SELECT day_start, provider,
|
||
|
|
SUM(input_tokens), SUM(output_tokens), SUM(cached_tokens), SUM(total_tokens)
|
||
|
|
FROM records
|
||
|
|
GROUP BY day_start, provider
|
||
|
|
ORDER BY day_start DESC
|
||
|
|
""")
|
||
|
|
defer { sqlite3_finalize(statement) }
|
||
|
|
|
||
|
|
var details: [Date: DailyUsageDetail] = [:]
|
||
|
|
while sqlite3_step(statement) == SQLITE_ROW {
|
||
|
|
guard let provider = Provider(rawValue: columnText(statement, at: 1)) else { continue }
|
||
|
|
let date = Date(timeIntervalSince1970: sqlite3_column_double(statement, 0))
|
||
|
|
if details[date] == nil {
|
||
|
|
details[date] = DailyUsageDetail(date: date)
|
||
|
|
}
|
||
|
|
details[date]?.add(
|
||
|
|
provider: provider,
|
||
|
|
inputTokens: Int(sqlite3_column_int64(statement, 2)),
|
||
|
|
outputTokens: Int(sqlite3_column_int64(statement, 3)),
|
||
|
|
cachedTokens: Int(sqlite3_column_int64(statement, 4)),
|
||
|
|
totalTokens: Int(sqlite3_column_int64(statement, 5))
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
return details.values.sorted { $0.date > $1.date }
|
||
|
|
}
|
||
|
|
|
||
|
|
private func trendPoints(mode: TrendMode, now: Date) throws -> [DailyPoint] {
|
||
|
|
let reference = mode.periodStart(for: now, calendar: calendar)
|
||
|
|
let start = mode.startDate(reference: reference, calendar: calendar)
|
||
|
|
let column = mode.periodColumn
|
||
|
|
let statement = try prepare("""
|
||
|
|
SELECT \(column), provider, SUM(total_tokens)
|
||
|
|
FROM records
|
||
|
|
WHERE \(column) >= ? AND \(column) <= ?
|
||
|
|
GROUP BY \(column), provider
|
||
|
|
""")
|
||
|
|
defer { sqlite3_finalize(statement) }
|
||
|
|
sqlite3_bind_double(statement, 1, start.timeIntervalSince1970)
|
||
|
|
sqlite3_bind_double(statement, 2, reference.timeIntervalSince1970)
|
||
|
|
|
||
|
|
var buckets: [Date: [Provider: Int]] = [:]
|
||
|
|
while sqlite3_step(statement) == SQLITE_ROW {
|
||
|
|
guard let provider = Provider(rawValue: columnText(statement, at: 1)) else { continue }
|
||
|
|
let date = Date(timeIntervalSince1970: sqlite3_column_double(statement, 0))
|
||
|
|
buckets[date, default: Provider.zeroTokenMap][provider, default: 0] = Int(sqlite3_column_int64(statement, 2))
|
||
|
|
}
|
||
|
|
|
||
|
|
var points: [DailyPoint] = []
|
||
|
|
var cursor = start
|
||
|
|
while cursor <= reference {
|
||
|
|
points.append(DailyPoint(date: cursor, values: buckets[cursor, default: Provider.zeroTokenMap]))
|
||
|
|
guard let next = calendar.date(byAdding: mode.calendarComponent, value: 1, to: cursor) else { break }
|
||
|
|
cursor = next
|
||
|
|
}
|
||
|
|
return points
|
||
|
|
}
|
||
|
|
|
||
|
|
private func tokenSum(whereClause: String, bindings: [TimeInterval]) throws -> Int {
|
||
|
|
let statement = try prepare("SELECT COALESCE(SUM(total_tokens), 0) FROM records WHERE \(whereClause)")
|
||
|
|
defer { sqlite3_finalize(statement) }
|
||
|
|
try bindIntervals(bindings, to: statement)
|
||
|
|
guard sqlite3_step(statement) == SQLITE_ROW else { return 0 }
|
||
|
|
return Int(sqlite3_column_int64(statement, 0))
|
||
|
|
}
|
||
|
|
|
||
|
|
private func sessionCount() throws -> Int {
|
||
|
|
let statement = try prepare("""
|
||
|
|
SELECT COUNT(DISTINCT CASE WHEN session_id = '' THEN source_path ELSE session_id END)
|
||
|
|
FROM records
|
||
|
|
""")
|
||
|
|
defer { sqlite3_finalize(statement) }
|
||
|
|
guard sqlite3_step(statement) == SQLITE_ROW else { return 0 }
|
||
|
|
return Int(sqlite3_column_int64(statement, 0))
|
||
|
|
}
|
||
|
|
|
||
|
|
private func recordsTableNeedsRebuild() throws -> Bool {
|
||
|
|
let columns = try tableColumns("records")
|
||
|
|
return !columns.isEmpty && !columns.contains("day_start")
|
||
|
|
}
|
||
|
|
|
||
|
|
private func addColumnIfMissing(table: String, column: String, definition: String) throws {
|
||
|
|
let columns = try tableColumns(table)
|
||
|
|
guard !columns.isEmpty, !columns.contains(column) else { return }
|
||
|
|
try execute("ALTER TABLE \(table) ADD COLUMN \(definition)")
|
||
|
|
}
|
||
|
|
|
||
|
|
private func tableColumns(_ table: String) throws -> Set<String> {
|
||
|
|
let statement = try prepare("PRAGMA table_info(\(table))")
|
||
|
|
defer { sqlite3_finalize(statement) }
|
||
|
|
|
||
|
|
var columns = Set<String>()
|
||
|
|
while sqlite3_step(statement) == SQLITE_ROW {
|
||
|
|
columns.insert(columnText(statement, at: 1))
|
||
|
|
}
|
||
|
|
return columns
|
||
|
|
}
|
||
|
|
|
||
|
|
private func bindIntervals(_ values: [TimeInterval], to statement: OpaquePointer?) throws {
|
||
|
|
for (index, value) in values.enumerated() {
|
||
|
|
sqlite3_bind_double(statement, Int32(index + 1), value)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func dayBounds(for date: Date) -> QueryBounds {
|
||
|
|
let start = calendar.startOfDay(for: date)
|
||
|
|
let end = calendar.date(byAdding: .day, value: 1, to: start) ?? date
|
||
|
|
return QueryBounds(start: start, end: end)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func monthBounds(for date: Date) -> QueryBounds {
|
||
|
|
let start = monthStart(for: date)
|
||
|
|
let end = calendar.date(byAdding: .month, value: 1, to: start) ?? date
|
||
|
|
return QueryBounds(start: start, end: end)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func hourStart(for date: Date) -> Date {
|
||
|
|
let components = calendar.dateComponents([.year, .month, .day, .hour], from: date)
|
||
|
|
return calendar.date(from: components) ?? calendar.startOfDay(for: date)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func monthStart(for date: Date) -> Date {
|
||
|
|
let components = calendar.dateComponents([.year, .month], from: date)
|
||
|
|
return calendar.date(from: components) ?? calendar.startOfDay(for: date)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func calendarWithMondayStart(_ calendar: Calendar) -> Calendar {
|
||
|
|
var copy = calendar
|
||
|
|
copy.firstWeekday = 2
|
||
|
|
return copy
|
||
|
|
}
|
||
|
|
|
||
|
|
private func prepare(_ sql: String) throws -> OpaquePointer? {
|
||
|
|
var statement: OpaquePointer?
|
||
|
|
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else {
|
||
|
|
throw SQLiteUsageCacheError.prepareFailed(errorMessage)
|
||
|
|
}
|
||
|
|
return statement
|
||
|
|
}
|
||
|
|
|
||
|
|
private func execute(_ sql: String) throws {
|
||
|
|
guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else {
|
||
|
|
throw SQLiteUsageCacheError.executeFailed(errorMessage)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func bind(_ text: String, to statement: OpaquePointer?, at index: Int32) throws {
|
||
|
|
let result = text.withCString {
|
||
|
|
sqlite3_bind_text(statement, index, $0, -1, Self.transient)
|
||
|
|
}
|
||
|
|
guard result == SQLITE_OK else {
|
||
|
|
throw SQLiteUsageCacheError.bindFailed(errorMessage)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func stepDone(_ statement: OpaquePointer?) throws {
|
||
|
|
guard sqlite3_step(statement) == SQLITE_DONE else {
|
||
|
|
throw SQLiteUsageCacheError.stepFailed(errorMessage)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func columnText(_ statement: OpaquePointer?, at index: Int32) -> String {
|
||
|
|
guard let text = sqlite3_column_text(statement, index) else {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
return String(cString: UnsafeRawPointer(text).assumingMemoryBound(to: CChar.self))
|
||
|
|
}
|
||
|
|
|
||
|
|
private var errorMessage: String {
|
||
|
|
database.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "SQLite 操作失败"
|
||
|
|
}
|
||
|
|
|
||
|
|
}
|
||
|
|
|
||
|
|
struct CachedFileState {
|
||
|
|
let size: Int64
|
||
|
|
let modifiedAt: TimeInterval
|
||
|
|
let scannedOffset: Int64
|
||
|
|
let recordCount: Int
|
||
|
|
}
|
||
|
|
|
||
|
|
private struct QueryBounds {
|
||
|
|
let start: Date
|
||
|
|
let end: Date
|
||
|
|
|
||
|
|
var bindings: [TimeInterval] {
|
||
|
|
[start.timeIntervalSince1970, end.timeIntervalSince1970]
|
||
|
|
}
|
||
|
|
}
|