Track usage independent of conversation save state; fix Analytics toolbar button

Conversation text is still only persisted when explicitly saved (⌘S),
but tokens/cost/model/provider are now logged to a new usage_events
table for every completed AI response regardless — the Analytics
view now reads from this table instead of messages, so it reflects
real usage even for conversations that were never saved. Adds a
By Provider chart mode alongside Over Time/By Model.

Also fixes the Analytics entry point: ToolbarItem(placement: .navigation)
silently doesn't render in a plain .sheet-presented NavigationStack on
macOS. Moved the button inline next to the segmented picker, matching
the codebase's existing convention (e.g. the model-favorites star filter).
This commit is contained in:
2026-08-12 13:49:12 +02:00
parent d460230158
commit 30e18f92a5
7 changed files with 514 additions and 17 deletions
+34
View File
@@ -1,6 +1,40 @@
{
"sourceLanguage" : "en",
"strings" : {
"By Provider" : {
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Efter udbyder"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nach Anbieter"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Par fournisseur"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "Etter leverandør"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "Efter leverantör"
}
}
}
},
"Today" : {
"localizations" : {
"da" : {
+41
View File
@@ -108,6 +108,47 @@ struct ModelUsageStat: Identifiable, Sendable {
}
}
/// Usage grouped by provider (openrouter/anthropic/openai/ollama/appleOnDevice), from `usage_events`.
struct ProviderUsageStat: Identifiable, Sendable {
var id: String { provider }
let provider: String
var questionCount: Int
var totalTokens: Int
var totalCost: Double
var hasCostData: Bool
var lastUsed: Date
nonisolated init(
provider: String,
questionCount: Int,
totalTokens: Int,
totalCost: Double,
hasCostData: Bool,
lastUsed: Date
) {
self.provider = provider
self.questionCount = questionCount
self.totalTokens = totalTokens
self.totalCost = totalCost
self.hasCostData = hasCostData
self.lastUsed = lastUsed
}
var totalTokensDisplay: String {
if totalTokens >= 1_000_000 {
return String(format: "%.1fM", Double(totalTokens) / 1_000_000)
} else if totalTokens >= 1000 {
return String(format: "%.1fK", Double(totalTokens) / 1000)
} else {
return "\(totalTokens)"
}
}
var totalCostDisplay: String {
hasCostData ? String(format: "$%.4f", totalCost) : "N/A"
}
}
/// One day's usage totals, for time-series charting in the Analytics view.
struct DailyUsageStat: Identifiable, Sendable {
var id: Date { day }
+231
View File
@@ -65,6 +65,19 @@ struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
var modelId: String?
}
struct UsageEventRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
static let databaseTableName = "usage_events"
var id: String
var timestamp: String
var provider: String
var modelId: String
var promptTokens: Int?
var completionTokens: Int?
var cost: Double?
var conversationId: String?
}
struct SettingRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
static let databaseTableName = "settings"
@@ -404,6 +417,26 @@ final class DatabaseService: Sendable {
}
}
migrator.registerMigration("v13") { db in
// Usage tracking independent of conversation persistence: conversation *text* is only
// ever saved when the user explicitly saves (S), but usage (tokens/cost/model/provider)
// should be recorded for every completed AI response regardless one row per finished
// generation. conversationId is a best-effort link (set only if the conversation had
// already been saved at least once at logging time) and is not a foreign key, since the
// referenced conversation may never exist.
try db.create(table: "usage_events") { t in
t.column("id", .text).primaryKey()
t.column("timestamp", .text).notNull()
t.column("provider", .text).notNull()
t.column("modelId", .text).notNull()
t.column("promptTokens", .integer)
t.column("completionTokens", .integer)
t.column("cost", .double)
t.column("conversationId", .text)
}
try db.create(index: "idx_usage_events_timestamp", on: "usage_events", columns: ["timestamp"])
}
return migrator
}
@@ -959,6 +992,204 @@ final class DatabaseService: Sendable {
}
}
// MARK: - Usage Events (save-independent usage tracking)
/// Records one completed AI generation's usage independent of whether the conversation it
/// belongs to is ever explicitly saved. `conversationId` is a best-effort link, set only if the
/// conversation had already been saved at least once when this was logged.
nonisolated func logUsageEvent(
provider: String,
modelId: String,
promptTokens: Int?,
completionTokens: Int?,
cost: Double?,
conversationId: UUID?
) throws {
let record = UsageEventRecord(
id: UUID().uuidString,
timestamp: Self.isoString(from: Date()),
provider: provider,
modelId: modelId,
promptTokens: promptTokens,
completionTokens: completionTokens,
cost: cost,
conversationId: conversationId?.uuidString
)
try dbQueue.write { db in
try record.insert(db)
}
}
/// Overall usage-event totals (each row = one completed generation = one "question").
nonisolated func getUsageEventTotals(from: Date? = nil, to: Date? = nil) throws -> UsageStats {
try dbQueue.read { db in
let (whereSQL, arguments) = Self.timestampRangeClause(from: from, to: to)
let sql = """
SELECT COUNT(*) AS cnt,
COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens,
COALESCE(SUM(cost), 0) AS cost,
COUNT(cost) AS costCount,
MIN(timestamp) AS minTs,
MAX(timestamp) AS maxTs
FROM usage_events
\(whereSQL)
"""
guard let row = try Row.fetchOne(db, sql: sql, arguments: StatementArguments(arguments))
else {
return UsageStats()
}
let costCount: Int = row["costCount"]
let cnt: Int = row["cnt"]
let minTs: String? = row["minTs"]
let maxTs: String? = row["maxTs"]
return UsageStats(
totalMessages: cnt,
totalQuestions: cnt,
totalTokens: row["tokens"],
totalCost: row["cost"],
hasCostData: costCount > 0,
firstMessageDate: minTs.flatMap { Self.isoDate(from: $0) },
lastMessageDate: maxTs.flatMap { Self.isoDate(from: $0) }
)
}
}
/// Usage events grouped by model.
nonisolated func getUsageEventsByModel(from: Date? = nil, to: Date? = nil) throws -> [ModelUsageStat] {
try dbQueue.read { db in
let (whereSQL, arguments) = Self.timestampRangeClause(from: from, to: to)
let sql = """
SELECT modelId,
COUNT(*) AS cnt,
COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens,
COALESCE(SUM(cost), 0) AS cost,
COUNT(cost) AS costCount,
MAX(timestamp) AS lastUsed
FROM usage_events
\(whereSQL)
GROUP BY modelId
"""
let rows = try Row.fetchAll(db, sql: sql, arguments: StatementArguments(arguments))
let stats: [ModelUsageStat] = rows.compactMap { row in
guard let modelId: String = row["modelId"],
let lastUsedString: String = row["lastUsed"],
let lastUsed = Self.isoDate(from: lastUsedString)
else { return nil }
let costCount: Int = row["costCount"]
let cnt: Int = row["cnt"]
return ModelUsageStat(
modelId: modelId,
messageCount: cnt,
questionCount: cnt,
totalTokens: row["tokens"],
totalCost: row["cost"],
hasCostData: costCount > 0,
lastUsed: lastUsed
)
}
return stats.sorted { lhs, rhs in
if lhs.hasCostData || rhs.hasCostData {
return lhs.totalCost > rhs.totalCost
}
return lhs.totalTokens > rhs.totalTokens
}
}
}
/// Usage events grouped by provider.
nonisolated func getUsageEventsByProvider(from: Date? = nil, to: Date? = nil) throws -> [ProviderUsageStat] {
try dbQueue.read { db in
let (whereSQL, arguments) = Self.timestampRangeClause(from: from, to: to)
let sql = """
SELECT provider,
COUNT(*) AS cnt,
COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens,
COALESCE(SUM(cost), 0) AS cost,
COUNT(cost) AS costCount,
MAX(timestamp) AS lastUsed
FROM usage_events
\(whereSQL)
GROUP BY provider
"""
let rows = try Row.fetchAll(db, sql: sql, arguments: StatementArguments(arguments))
let stats: [ProviderUsageStat] = rows.compactMap { row in
guard let provider: String = row["provider"],
let lastUsedString: String = row["lastUsed"],
let lastUsed = Self.isoDate(from: lastUsedString)
else { return nil }
let costCount: Int = row["costCount"]
return ProviderUsageStat(
provider: provider,
questionCount: row["cnt"],
totalTokens: row["tokens"],
totalCost: row["cost"],
hasCostData: costCount > 0,
lastUsed: lastUsed
)
}
return stats.sorted { lhs, rhs in
if lhs.hasCostData || rhs.hasCostData {
return lhs.totalCost > rhs.totalCost
}
return lhs.totalTokens > rhs.totalTokens
}
}
}
/// Usage events bucketed by calendar day (GMT, matching `isoStyle`), for the Analytics view.
nonisolated func getDailyUsageEvents(from: Date, to: Date) throws -> [DailyUsageStat] {
try dbQueue.read { db in
let sql = """
SELECT substr(timestamp, 1, 10) AS day,
COUNT(*) AS cnt,
COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens,
COALESCE(SUM(cost), 0) AS cost,
COUNT(cost) AS costCount
FROM usage_events
WHERE timestamp >= ? AND timestamp <= ?
GROUP BY day
ORDER BY day
"""
let rows = try Row.fetchAll(
db, sql: sql,
arguments: [Self.isoString(from: from), Self.isoString(from: to)]
)
var gmtCalendar = Calendar(identifier: .gregorian)
gmtCalendar.timeZone = TimeZone(identifier: "GMT")!
return rows.compactMap { row -> DailyUsageStat? in
guard let dayString: String = row["day"] else { return nil }
let parts = dayString.split(separator: "-").compactMap { Int($0) }
guard parts.count == 3 else { return nil }
var components = DateComponents()
components.year = parts[0]
components.month = parts[1]
components.day = parts[2]
guard let day = gmtCalendar.date(from: components) else { return nil }
let costCount: Int = row["costCount"]
let cnt: Int = row["cnt"]
return DailyUsageStat(
day: day,
messageCount: cnt,
questionCount: cnt,
totalTokens: row["tokens"],
totalCost: row["cost"],
hasCostData: costCount > 0
)
}
}
}
nonisolated func getUsageByConversation(limit: Int = 20) throws -> [ConversationUsageStat] {
try dbQueue.read { db in
let rows = try Row.fetchAll(db, sql: """
+23
View File
@@ -1153,6 +1153,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
let cost = Self.resolveCost(usage: usage, pricing: model.pricing)
messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost)
}
}
}
@@ -1216,6 +1217,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
let cost = Self.resolveCost(usage: usage, pricing: model.pricing)
messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost)
}
}
@@ -1572,6 +1574,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
let cost = usage.rawCostUSD
messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost)
}
}
} catch {
@@ -1863,6 +1866,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
outputTokens: usage.completionTokens,
cost: cost
)
logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost)
}
} else {
let assistantMessage = Message(
@@ -1890,6 +1894,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
outputTokens: usage.completionTokens,
cost: cost
)
logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost)
}
}
@@ -2610,6 +2615,24 @@ Don't narrate future actions ("Let me...") - just use the tools.
return calculateCost(usage: usage, pricing: pricing)
}
/// Records usage independent of whether this conversation ever gets explicitly saved (S)
/// conversation *text* is opt-in, but tokens/cost/model/provider are tracked for every
/// completed generation regardless, so the Analytics view reflects real usage either way.
private func logUsageEvent(modelId: String, promptTokens: Int?, completionTokens: Int?, cost: Double?) {
do {
try DatabaseService.shared.logUsageEvent(
provider: currentProvider.rawValue,
modelId: modelId,
promptTokens: promptTokens,
completionTokens: completionTokens,
cost: cost,
conversationId: currentConversationId
)
} catch {
Log.db.error("Failed to log usage event: \(error.localizedDescription)")
}
}
/// Summarize a chunk of messages into a concise summary
private func summarizeMessageChunk(_ messages: [Message]) async -> String? {
guard let provider = providerRegistry.getProvider(for: currentProvider),
+14 -13
View File
@@ -43,12 +43,21 @@ struct StatsView: View {
var body: some View {
NavigationStack {
VStack(spacing: 0) {
Picker("", selection: $selectedTab) {
Text("Session").tag(StatsTab.session)
Text("All-Time").tag(StatsTab.allTime)
HStack(spacing: 8) {
Picker("", selection: $selectedTab) {
Text("Session").tag(StatsTab.session)
Text("All-Time").tag(StatsTab.allTime)
}
.pickerStyle(.segmented)
.labelsHidden()
Button {
showAnalytics = true
} label: {
Image(systemName: "chart.bar.xaxis")
}
.help("View detailed usage analytics")
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 4)
@@ -64,14 +73,6 @@ struct StatsView: View {
}
.navigationTitle("Statistics")
.toolbar {
ToolbarItem(placement: .navigation) {
Button {
showAnalytics = true
} label: {
Label("Analytics", systemImage: "chart.bar.xaxis")
}
.help("View detailed usage analytics")
}
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
dismiss()
+86 -4
View File
@@ -51,6 +51,7 @@ private enum AnalyticsMetric: String, CaseIterable, Identifiable {
private enum AnalyticsChartMode: String, CaseIterable, Identifiable {
case overTime
case byModel
case byProvider
var id: String { rawValue }
}
@@ -64,6 +65,7 @@ struct UsageAnalyticsView: View {
@State private var overallStats = UsageStats()
@State private var modelStats: [ModelUsageStat] = []
@State private var providerStats: [ProviderUsageStat] = []
@State private var dailyStats: [DailyUsageStat] = []
var body: some View {
@@ -85,6 +87,10 @@ struct UsageAnalyticsView: View {
if chartMode == .byModel && !modelStats.isEmpty {
modelBreakdownList
}
if chartMode == .byProvider && !providerStats.isEmpty {
providerBreakdownList
}
}
.padding(20)
}
@@ -156,10 +162,11 @@ struct UsageAnalyticsView: View {
Picker("", selection: $chartMode) {
Text("Over Time").tag(AnalyticsChartMode.overTime)
Text("By Model").tag(AnalyticsChartMode.byModel)
Text("By Provider").tag(AnalyticsChartMode.byProvider)
}
.pickerStyle(.segmented)
.labelsHidden()
.frame(maxWidth: 280)
.frame(maxWidth: 400)
}
@ViewBuilder
@@ -169,6 +176,8 @@ struct UsageAnalyticsView: View {
timeSeriesChart
case .byModel:
byModelChart
case .byProvider:
byProviderChart
}
}
@@ -209,6 +218,26 @@ struct UsageAnalyticsView: View {
}
}
private var byProviderChart: some View {
Group {
if providerStats.isEmpty {
emptyChartPlaceholder
} else {
Chart(providerStats) { stat in
SectorMark(
angle: .value("Value", providerValue(for: stat)),
innerRadius: .ratio(0.55),
angularInset: 1.5
)
.foregroundStyle(by: .value("Provider", providerDisplayName(stat.provider)))
.cornerRadius(4)
}
.frame(height: 240)
.chartLegend(position: .bottom, alignment: .center, spacing: 8)
}
}
}
private var emptyChartPlaceholder: some View {
VStack(spacing: 8) {
Image(systemName: "chart.bar.xaxis")
@@ -252,6 +281,40 @@ struct UsageAnalyticsView: View {
.cornerRadius(10)
}
// MARK: - By Provider List
private var providerBreakdownList: some View {
VStack(alignment: .leading, spacing: 4) {
Text("By Provider")
.font(.headline)
.padding(.bottom, 4)
ForEach(providerStats) { stat in
HStack {
Text(providerDisplayName(stat.provider))
.font(.body)
.lineLimit(1)
Spacer()
Text(providerValueDisplay(for: stat))
.font(.body.monospacedDigit())
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
if stat.id != providerStats.last?.id {
Divider()
}
}
}
.padding(16)
.background(Color.confabSurface.opacity(0.5))
.cornerRadius(10)
}
private func providerDisplayName(_ rawProvider: String) -> String {
Settings.Provider(rawValue: rawProvider)?.displayName ?? rawProvider
}
// MARK: - Value helpers
private func dailyValue(for stat: DailyUsageStat) -> Double {
@@ -278,15 +341,34 @@ struct UsageAnalyticsView: View {
}
}
private func providerValue(for stat: ProviderUsageStat) -> Double {
switch metric {
case .tokens: return Double(stat.totalTokens)
case .questions: return Double(stat.questionCount)
case .cost: return stat.totalCost
}
}
private func providerValueDisplay(for stat: ProviderUsageStat) -> String {
switch metric {
case .tokens: return stat.totalTokensDisplay
case .questions: return "\(stat.questionCount)"
case .cost: return stat.totalCostDisplay
}
}
// MARK: - Data Loading
/// Sourced from `usage_events`, not `messages` reflects usage for every completed
/// generation regardless of whether the conversation it belongs to was ever saved.
private func loadStats() {
let range = timeframe.dateRange()
overallStats = (try? DatabaseService.shared.getOverallUsageStats(from: range.start, to: range.end)) ?? UsageStats()
modelStats = (try? DatabaseService.shared.getUsageByModel(from: range.start, to: range.end)) ?? []
overallStats = (try? DatabaseService.shared.getUsageEventTotals(from: range.start, to: range.end)) ?? UsageStats()
modelStats = (try? DatabaseService.shared.getUsageEventsByModel(from: range.start, to: range.end)) ?? []
providerStats = (try? DatabaseService.shared.getUsageEventsByProvider(from: range.start, to: range.end)) ?? []
let dailyFrom = range.start ?? overallStats.firstMessageDate ?? range.end
dailyStats = (try? DatabaseService.shared.getDailyUsage(from: dailyFrom, to: range.end)) ?? []
dailyStats = (try? DatabaseService.shared.getDailyUsageEvents(from: dailyFrom, to: range.end)) ?? []
}
}
+85
View File
@@ -25,6 +25,17 @@ struct DatabaseServiceMigrationTests {
}
}
@Test("v13 adds the usage_events table with the expected columns")
func v13AddsUsageEventsTable() {
let db = DatabaseService.makeInMemory()
#expect(db.tableExists("usage_events"))
let columns = Set(db.columnNames(in: "usage_events"))
let expected: Set<String> = [
"id", "timestamp", "provider", "modelId", "promptTokens", "completionTokens", "cost", "conversationId",
]
#expect(expected.isSubset(of: columns))
}
@Test("v4 adds modelId to messages and primaryModel to conversations")
func v4AddsModelColumns() {
let db = DatabaseService.makeInMemory()
@@ -299,6 +310,80 @@ struct DatabaseServiceUsageStatsTests {
}
}
@Suite("DatabaseService usage events, against a throwaway in-memory queue")
struct DatabaseServiceUsageEventsTests {
@Test("Logged usage events are readable regardless of any saved conversation")
func logUsageEventPersistsWithNoConversation() throws {
let db = DatabaseService.makeInMemory()
try db.logUsageEvent(
provider: "openrouter", modelId: "gpt-5", promptTokens: 100, completionTokens: 50,
cost: 0.02, conversationId: nil
)
let totals = try db.getUsageEventTotals()
#expect(totals.totalQuestions == 1)
#expect(totals.totalTokens == 150)
#expect(totals.hasCostData == true)
#expect(abs(totals.totalCost - 0.02) < 0.0001)
}
@Test("Usage events respect an optional date range")
func usageEventTotalsRespectsDateRange() throws {
let db = DatabaseService.makeInMemory()
let now = Date()
let old = now.addingTimeInterval(-10 * 24 * 3600)
try db.logUsageEvent(provider: "openrouter", modelId: "gpt-5", promptTokens: 10, completionTokens: 10, cost: 0.01, conversationId: nil)
// Directly insert an old-dated row via the private record path isn't exposed, so approximate
// "old" coverage by asserting the fresh row is excluded when the range is set entirely in the past.
let rangeExcludingNow = try db.getUsageEventTotals(from: old, to: old.addingTimeInterval(3600))
#expect(rangeExcludingNow.totalQuestions == 0)
let rangeIncludingNow = try db.getUsageEventTotals(from: now.addingTimeInterval(-3600), to: now.addingTimeInterval(3600))
#expect(rangeIncludingNow.totalQuestions == 1)
}
@Test("Usage events group by model and by provider independently")
func usageEventsGroupByModelAndProvider() throws {
let db = DatabaseService.makeInMemory()
try db.logUsageEvent(provider: "openrouter", modelId: "gpt-5", promptTokens: 10, completionTokens: 10, cost: 0.01, conversationId: nil)
try db.logUsageEvent(provider: "openrouter", modelId: "claude-sonnet", promptTokens: 5, completionTokens: 5, cost: 0.02, conversationId: nil)
try db.logUsageEvent(provider: "anthropic", modelId: "claude-sonnet", promptTokens: 20, completionTokens: 20, cost: 0.03, conversationId: nil)
let byModel = try db.getUsageEventsByModel()
#expect(byModel.count == 2)
let sonnet = byModel.first { $0.modelId == "claude-sonnet" }
#expect(sonnet?.questionCount == 2)
#expect(sonnet?.totalTokens == 50)
let byProvider = try db.getUsageEventsByProvider()
#expect(byProvider.count == 2)
let openrouter = byProvider.first { $0.provider == "openrouter" }
#expect(openrouter?.questionCount == 2)
#expect(openrouter?.totalTokens == 30)
let anthropic = byProvider.first { $0.provider == "anthropic" }
#expect(anthropic?.questionCount == 1)
#expect(anthropic?.totalTokens == 40)
}
@Test("Daily usage events bucket by calendar day (GMT, matching stored timestamps)")
func dailyUsageEventsBucketByDay() throws {
let db = DatabaseService.makeInMemory()
try db.logUsageEvent(provider: "openrouter", modelId: "gpt-5", promptTokens: 10, completionTokens: 10, cost: 0.01, conversationId: nil)
var gmtCalendar = Calendar(identifier: .gregorian)
gmtCalendar.timeZone = TimeZone(identifier: "GMT")!
let farPast = gmtCalendar.date(from: DateComponents(year: 2020, month: 1, day: 1))!
let farFuture = gmtCalendar.date(from: DateComponents(year: 2030, month: 1, day: 1))!
let daily = try db.getDailyUsageEvents(from: farPast, to: farFuture)
#expect(daily.count == 1)
#expect(daily.first?.totalTokens == 20)
#expect(daily.first?.questionCount == 1)
}
}
@Suite("DatabaseService folders, against a throwaway in-memory queue")
struct DatabaseServiceFolderTests {