Files
nucleic/Sources/NucleicCore/Persistence/GRDBMetadataStore.swift
T

979 lines
42 KiB
Swift

import Foundation
import GRDB
/// GRDB-backed `SessionMetadataStore` (RUNTIME §4). WAL-mode SQLite on disk;
/// an in-memory queue for tests. Named to avoid clashing with GRDB's own
/// `Database` type used inside the read/write closures.
public final class GRDBMetadataStore: SessionMetadataStore {
/// Internal (not private) so same-module extensions in sibling files — e.g. the cast table
/// (`GRDBMetadataStore+Cast.swift`) — share the one connection/migration lifecycle.
let writer: any DatabaseWriter
/// Opens (and migrates) the store. `path == nil` → an in-memory queue (tests);
/// a path → a WAL-mode `DatabasePool` under `Application Support/Nucleic`.
public init(path: String?) throws {
if let path {
try FileManager.default.createDirectory(
at: URL(fileURLWithPath: path).deletingLastPathComponent(),
withIntermediateDirectories: true)
var config = Configuration()
config.prepareDatabase { try $0.execute(sql: "PRAGMA foreign_keys = ON") }
writer = try DatabasePool(path: path, configuration: config)
} else {
writer = try DatabaseQueue()
}
try Self.migrator.migrate(writer)
}
// MARK: - Schema (RUNTIME §4; device table deferred to M4)
private static var migrator: DatabaseMigrator {
var migrator = DatabaseMigrator()
migrator.registerMigration("v1") { db in
try db.execute(sql: """
CREATE TABLE project (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
root_path TEXT NOT NULL,
default_branch TEXT NOT NULL,
default_backend TEXT,
worktree_base TEXT,
setup_script TEXT,
setup_policy TEXT NOT NULL DEFAULT 'block',
created_at DATETIME NOT NULL
);
""")
try db.execute(sql: """
CREATE TABLE session (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
backend TEXT NOT NULL,
backend_session_id TEXT,
title TEXT NOT NULL,
status TEXT NOT NULL,
worktree_path TEXT,
branch TEXT,
base_sha TEXT,
model TEXT,
last_seq INTEGER NOT NULL DEFAULT 0,
transcript_path TEXT NOT NULL,
native_transcript_path TEXT,
diff_files INTEGER, diff_added INTEGER, diff_removed INTEGER,
ahead INTEGER, behind INTEGER,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
""")
try db.execute(sql: "CREATE INDEX idx_session_project ON session(project_id, updated_at);")
try db.execute(sql: """
CREATE TABLE approval (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
tool_call_id TEXT,
tool_name TEXT NOT NULL,
title TEXT NOT NULL,
risk TEXT NOT NULL,
input TEXT NOT NULL,
created_at DATETIME NOT NULL,
resolved_at DATETIME,
decision TEXT, decided_by TEXT
);
""")
try db.execute(sql:
"CREATE INDEX idx_approval_pending ON approval(session_id) WHERE resolved_at IS NULL;")
}
migrator.registerMigration("v2-effort") { db in
try db.execute(sql: "ALTER TABLE session ADD COLUMN effort TEXT;")
}
migrator.registerMigration("v3-flags") { db in
try db.execute(sql: "ALTER TABLE session ADD COLUMN auto INTEGER NOT NULL DEFAULT 0;")
try db.execute(sql: "ALTER TABLE session ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0;")
try db.execute(sql: "ALTER TABLE session ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;")
}
migrator.registerMigration("v4-summary") { db in
try db.execute(sql: "ALTER TABLE session ADD COLUMN summary TEXT;")
}
migrator.registerMigration("v5-todos") { db in
try db.execute(sql: """
CREATE TABLE todo (
id TEXT PRIMARY KEY,
text TEXT NOT NULL,
project_id TEXT,
status TEXT NOT NULL DEFAULT 'open',
dispatched_session_id TEXT,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
""")
try db.execute(sql: "CREATE INDEX idx_todo_updated ON todo(updated_at);")
}
migrator.registerMigration("v6-todo-summary") { db in
try db.execute(sql: "ALTER TABLE todo ADD COLUMN summary TEXT;")
}
migrator.registerMigration("v7-sandbox") { db in
try db.execute(sql: "ALTER TABLE project ADD COLUMN sandbox_config TEXT;")
}
migrator.registerMigration("v8-disposition") { db in
try db.execute(sql: "ALTER TABLE session ADD COLUMN last_turn_disposition TEXT;")
}
migrator.registerMigration("v9-todo-triage") { db in
try db.execute(sql: "ALTER TABLE todo ADD COLUMN triage_level TEXT;")
try db.execute(sql: "ALTER TABLE todo ADD COLUMN triage_rank INTEGER;")
try db.execute(sql: "ALTER TABLE todo ADD COLUMN triage_reason TEXT;")
}
migrator.registerMigration("v10-autoship") { db in
try db.execute(sql: "ALTER TABLE session ADD COLUMN auto_ship INTEGER NOT NULL DEFAULT 0;")
}
migrator.registerMigration("v11-unseen-completion") { db in
try db.execute(sql: "ALTER TABLE session ADD COLUMN unseen_completion INTEGER NOT NULL DEFAULT 0;")
}
migrator.registerMigration("v12-last-event-autoship") { db in
try db.execute(
sql: "ALTER TABLE session ADD COLUMN last_event_was_autoship INTEGER NOT NULL DEFAULT 0;")
}
migrator.registerMigration("v13-autoship-failed") { db in
try db.execute(
sql: "ALTER TABLE session ADD COLUMN auto_ship_failed INTEGER NOT NULL DEFAULT 0;")
}
migrator.registerMigration("v14-parent-refs") { db in
// Parent/root pointers for the lock domain (LOCKING §3.5). parent_ref = release
// target; root_ref = lock domain (the tree's top-level branch); parent_session_id
// links a nested child to its parent session.
try db.execute(sql: "ALTER TABLE session ADD COLUMN parent_ref TEXT;")
try db.execute(sql: "ALTER TABLE session ADD COLUMN root_ref TEXT;")
try db.execute(sql: "ALTER TABLE session ADD COLUMN parent_session_id TEXT;")
}
migrator.registerMigration("v15-last-user-message") { db in
try db.execute(sql: "ALTER TABLE session ADD COLUMN last_user_message_at DATETIME;")
}
migrator.registerMigration("v16-archived-at") { db in
try db.execute(sql: "ALTER TABLE session ADD COLUMN archived_at DATETIME;")
}
migrator.registerMigration("v17-autoship-branch") { db in
// Per-project autoship destination override; NULL → the project's default branch.
try db.execute(sql: "ALTER TABLE project ADD COLUMN auto_ship_branch TEXT;")
// Per-session autoship destination override; NULL → inherit the project's.
try db.execute(sql: "ALTER TABLE session ADD COLUMN ship_branch TEXT;")
}
migrator.registerMigration("v18-project-archived") { db in
// When the project was archived; NULL → active.
try db.execute(sql: "ALTER TABLE project ADD COLUMN archived_at DATETIME;")
}
migrator.registerMigration("v19-autoship-conflict") { db in
// Sticky "autoship hit a merge conflict but stayed armed" marker; 0 → no conflict.
try db.execute(
sql: "ALTER TABLE session ADD COLUMN auto_ship_conflict INTEGER NOT NULL DEFAULT 0;")
}
migrator.registerMigration("v20-nvrsion") { db in
// Per-project nvrsion (Beta) config, JSON-encoded like sandbox_config; NULL → off
// (NVRSION §8, §10). New fields are additive within the JSON, no further migration.
try db.execute(sql: "ALTER TABLE project ADD COLUMN nvrsion_config TEXT;")
}
migrator.registerMigration("v21-activity-cache") { db in
// Derived per-session rollup powering the home dashboard (message count + per-day
// histogram), so it renders from a query instead of re-scanning every transcript on
// the main thread. Pure cache — rebuildable from the transcript; `last_seq` is the
// cursor it was computed at (recompute when the session's live lastSeq advances).
// CASCADE so a deleted session drops its cache row.
try db.execute(sql: """
CREATE TABLE session_activity (
session_id TEXT PRIMARY KEY REFERENCES session(id) ON DELETE CASCADE,
last_seq INTEGER NOT NULL,
message_count INTEGER NOT NULL,
activity_json TEXT NOT NULL
);
""")
}
migrator.registerMigration("v22-activity-tokens") { db in
// Extend the per-session rollup with token usage: a grand total and a per-day
// histogram (same shape as activity_json), so the dashboard can show total tokens and
// the activity grid can shade by tokens-per-day.
try db.execute(sql: """
ALTER TABLE session_activity ADD COLUMN token_count INTEGER NOT NULL DEFAULT 0;
""")
try db.execute(sql: """
ALTER TABLE session_activity ADD COLUMN tokens_json TEXT NOT NULL DEFAULT '{}';
""")
// Existing rows carry no token data but still match their session's last_seq, so they'd
// be served as "fresh" and never rescanned — leaving historical tokens permanently 0
// for idle sessions. The rollup is a pure cache, so clear it: the next dashboard load
// treats every session as stale and rebuilds it (off-main) with token data folded in.
try db.execute(sql: "DELETE FROM session_activity;")
}
migrator.registerMigration("v23-transfer") { db in
// Session transfer Mac→Mac (mesh P5). Tombstone columns on `session`: a moved session
// is `archived = true` PLUS these, so it rides the existing archived read/sweep path
// (NOT a new SessionStatus case, which would break shipped iPhones' strict decode).
try db.execute(sql: "ALTER TABLE session ADD COLUMN moved_to_device_id TEXT;")
try db.execute(sql: "ALTER TABLE session ADD COLUMN moved_at DATETIME;")
// The in-flight transfer lock — one row per active transfer. Visible to sweeps/reconcile
// so a mid-transfer session is never pruned, and scanned on relaunch to reconcile
// deterministically (a `tombstoned` source re-sends commit; a `ready` dest awaits it).
// No FK to session(id): the destination stages a transfer BEFORE the session row exists.
try db.execute(sql: """
CREATE TABLE session_transfer (
transfer_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
direction TEXT NOT NULL,
peer_device_id TEXT NOT NULL,
state TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
""")
// A session may have at most one active transfer at a time (quiesce/2-phase-commit
// safety). Terminal states (committed/activated) are excluded so a completed transfer
// doesn't block a future one; the lock row is cleared on completion anyway.
try db.execute(sql: """
CREATE UNIQUE INDEX idx_session_transfer_active
ON session_transfer(session_id)
WHERE state NOT IN ('committed', 'activated');
""")
}
migrator.registerMigration("v24-transfer-provenance") { db in
// Where a transferred session arrived from (mesh P5): the source Mac's `HostID`, set by
// the importer at staging. Drives a "Arrived from <Mac>" note. Nullable — nil for a
// session created here — so existing rows migrate untouched.
try db.execute(sql: "ALTER TABLE session ADD COLUMN arrived_from_device_id TEXT;")
try db.execute(sql: "ALTER TABLE session ADD COLUMN arrived_at DATETIME;")
}
migrator.registerMigration("v25-subagent-parent") { db in
// The spawning session's id when this session is a machine-spawned subagent (Orchestra's
// `nucleic_subagent` tool). Nullable — nil for a user-started chat — so existing rows
// migrate untouched. Drives hiding the worker from the sidebar and listing it under its
// parent's Subagents panel. See `Session.spawnedBySessionID`.
try db.execute(sql: "ALTER TABLE session ADD COLUMN spawned_by_session_id TEXT;")
}
migrator.registerMigration("v26-subagent-root") { db in
// The *root* of a subagent's spawn lineage — the top-level chat the whole worker tree
// descends from. A subagent can itself spawn subagents (Orchestra nesting); this rolls
// every descendant up under the one root chat, while `spawned_by_session_id` keeps the
// direct spawner. Nullable — nil for a user-started chat and for a subagent spawned
// before this column existed (its direct-parent tag still lists it) — so existing rows
// migrate untouched. See `Session.rootSpawnedBySessionID`.
try db.execute(sql: "ALTER TABLE session ADD COLUMN root_spawned_by_session_id TEXT;")
}
migrator.registerMigration("v27-cast") { db in
// Mesh casting (CastMessages.swift): one table for casts this host published
// (origin_host_id == its own HostID) AND casts mirrored from peers. The PRIMARY KEY
// *is* the mesh-wide cast identity, so idempotent ingest is INSERT OR IGNORE and
// dedup needs no extra bookkeeping. State channels are compacted at write time
// (older rows with the same key deleted); stream channels are retention-swept.
try db.execute(sql: """
CREATE TABLE cast_event (
origin_host_id TEXT NOT NULL,
channel TEXT NOT NULL,
cast_seq INTEGER NOT NULL,
mode TEXT NOT NULL,
key TEXT,
type_id TEXT NOT NULL,
payload BLOB NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0,
sent_at DATETIME NOT NULL,
received_at DATETIME NOT NULL,
PRIMARY KEY (origin_host_id, channel, cast_seq)
);
""")
try db.execute(sql:
"CREATE INDEX idx_cast_state_key ON cast_event(origin_host_id, channel, key);")
}
migrator.registerMigration("v28-mesh-dispatch") { db in
// Durable mesh-dispatch queue (mesh dispatch): a chat the user asked to run on the
// mesh when no runner was free is parked here on the DISPATCHING host and retried
// when one comes online. The dispatching host is the single driver (no cross-device
// claim race). `project_id` is this host's own project id; `id` is the row/dispatch id.
try db.execute(sql: """
CREATE TABLE mesh_dispatch (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
message TEXT NOT NULL,
model TEXT,
effort TEXT,
auto INTEGER,
created_at DATETIME NOT NULL
);
""")
}
migrator.registerMigration("v29-carbon") { db in
// HISTORICAL — do not edit. Originally the "Carbon" (mesh work queue) origin marker.
// The column is renamed to `covalence_origin_device_id` in v30 (branding: the network
// layer is now "Covalence"). This migration's identifier and SQL are frozen so existing
// databases replay identically before the v30 rename runs.
try db.execute(sql: "ALTER TABLE session ADD COLUMN carbon_origin_device_id TEXT;")
}
migrator.registerMigration("v30-covalence-rename") { db in
// Covalence (mesh work queue): the device a Covalence-dispatched chat was issued from.
// Non-null ⇒ the session is Covalence-managed (between-turn rebalancing; the origin
// device is never a destination). Renames the v29 column to the Covalence brand; the
// value is preserved for every existing row. See `Session.covalenceOriginDeviceID`.
try db.execute(
sql: "ALTER TABLE session RENAME COLUMN carbon_origin_device_id TO covalence_origin_device_id;")
}
migrator.registerMigration("v31-session-keywords") { db in
// Cached search keywords for the sidebar's session index (see `Session.keywords`),
// generated as background AFM work and persisted so relaunch reuses them.
try db.execute(sql: "ALTER TABLE session ADD COLUMN keywords TEXT;")
}
migrator.registerMigration("v32-session-revert-epoch") { db in
// Monotonic count of transcript reverts (undo), bumped alongside the seq-counter
// reset so mirrors/caches can detect a revert they missed (see
// `Session.revertEpoch`). Existing rows start at 0 = never reverted.
try db.execute(
sql: "ALTER TABLE session ADD COLUMN revert_epoch INTEGER NOT NULL DEFAULT 0;")
}
migrator.registerMigration("v33-activity-revert-epoch") { db in
// The revert epoch the dashboard activity rollup was computed at (see
// `ActivityCacheEntry.revertEpoch`): a revert-then-regrow can return a transcript to
// the exact `last_seq` an entry was keyed to with different content, so freshness
// needs both cursors. Existing entries default to 0 — an ever-reverted session's
// entry reads stale once and recomputes.
try db.execute(
sql: "ALTER TABLE session_activity ADD COLUMN revert_epoch INTEGER NOT NULL DEFAULT 0;")
}
migrator.registerMigration("v34-tool-summary") { db in
// Cached AI tool-call summary lines (see `ToolSummaryCacheEntry`), so reopening a
// chat renders the headlines the on-device model already wrote instead of
// re-running the whole map→reduce over its transcript. Pure cache — rebuildable by
// re-summarizing; CASCADE so a deleted session drops its lines. Keyed by
// `tool_call_id` (backend-assigned, stable across runs), never by a per-process hash.
try db.execute(sql: """
CREATE TABLE tool_summary (
session_id TEXT NOT NULL REFERENCES session(id) ON DELETE CASCADE,
tool_call_id TEXT NOT NULL,
kind TEXT NOT NULL,
line TEXT NOT NULL,
inputs TEXT NOT NULL DEFAULT '',
PRIMARY KEY (session_id, tool_call_id, kind)
);
""")
}
return migrator
}
// MARK: - Projects
public func saveProject(_ project: Project) async throws {
try await writer.write { db in
try ProjectRow(project).upsert(db)
}
}
public func loadProjects() async throws -> [Project] {
try await writer.read { db in
try ProjectRow.order(Column("name")).fetchAll(db).map { $0.toProject() }
}
}
public func deleteProject(id: ProjectID) async throws {
_ = try await writer.write { db in
try ProjectRow.deleteOne(db, key: id.rawValue)
}
}
// MARK: - Sessions
public func saveSession(_ session: Session) async throws {
try await writer.write { db in
try SessionRow(session).upsert(db)
}
}
public func loadSession(id: SessionID) async throws -> Session? {
try await writer.read { db in
try SessionRow.fetchOne(db, key: id.rawValue)?.toSession()
}
}
public func loadSessions(projectID: ProjectID) async throws -> [Session] {
try await writer.read { db in
try SessionRow
.filter(Column("project_id") == projectID.rawValue)
.order(Column("updated_at").desc)
.fetchAll(db).map { $0.toSession() }
}
}
public func loadAllSessions() async throws -> [Session] {
try await writer.read { db in
try SessionRow.order(Column("updated_at").desc).fetchAll(db).map { $0.toSession() }
}
}
public func deleteSession(id: SessionID) async throws {
_ = try await writer.write { db in
try SessionRow.deleteOne(db, key: id.rawValue)
}
}
// MARK: - Activity cache (derived dashboard rollup)
public func loadActivityCache() async throws -> [SessionID: ActivityCacheEntry] {
try await writer.read { db in
try ActivityRow.fetchAll(db).reduce(into: [:]) { acc, row in
acc[SessionID(rawValue: row.session_id)] = row.toEntry()
}
}
}
public func saveActivityCache(_ sessionID: SessionID, _ entry: ActivityCacheEntry) async throws {
let row = ActivityRow(sessionID: sessionID, entry: entry)
try await writer.write { db in try row.upsert(db) }
}
// MARK: - Tool summary cache (derived AI headlines)
public func loadToolSummaries(sessionID: SessionID) async throws -> [ToolSummaryCacheEntry] {
let id = sessionID.rawValue
return try await writer.read { db in
try ToolSummaryRow
.filter(Column("session_id") == id)
.fetchAll(db)
.compactMap { $0.toEntry() }
}
}
public func saveToolSummaries(
_ entries: [ToolSummaryCacheEntry], sessionID: SessionID
) async throws {
guard !entries.isEmpty else { return }
let rows = entries.map { ToolSummaryRow(sessionID: sessionID, entry: $0) }
try await writer.write { db in
for row in rows { try row.upsert(db) }
}
}
// MARK: - Approvals
public func saveApproval(_ request: ApprovalRequest) async throws {
try await writer.write { db in
try ApprovalRow(request).upsert(db)
}
}
public func resolveApproval(_ resolved: ApprovalResolved) async throws {
let decisionJSON = try Self.encodeDecision(resolved.decision)
let id = resolved.id.rawValue
let decidedBy = resolved.decidedBy
let decidedAt = resolved.decidedAt
try await writer.write { db in
try db.execute(
sql: """
UPDATE approval SET resolved_at = :at, decision = :decision, decided_by = :by
WHERE id = :id
""",
arguments: ["at": decidedAt, "decision": decisionJSON, "by": decidedBy, "id": id])
}
}
public func pendingApprovals(sessionID: SessionID) async throws -> [ApprovalRequest] {
let sid = sessionID.rawValue
return try await writer.read { db in
try ApprovalRow
.filter(Column("session_id") == sid && Column("resolved_at") == nil)
.order(Column("created_at"))
.fetchAll(db)
.compactMap { try? $0.toRequest() }
}
}
// MARK: - Todos
public func saveTodo(_ todo: Todo) async throws {
try await writer.write { db in
try TodoRow(todo).upsert(db)
}
}
public func loadTodos() async throws -> [Todo] {
try await writer.read { db in
try TodoRow.order(Column("updated_at").desc).fetchAll(db).map { $0.toTodo() }
}
}
public func deleteTodo(id: TodoID) async throws {
_ = try await writer.write { db in
try TodoRow.deleteOne(db, key: id.rawValue)
}
}
// MARK: - Decision JSON helpers
static func encodeDecision(_ decision: Decision) throws -> String {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return String(decoding: try encoder.encode(decision), as: UTF8.self)
}
// MARK: - Session transfer (mesh P5)
public func beginTransfer(_ lock: SessionTransferLock) async throws {
do {
try await writer.write { db in try TransferRow(lock).insert(db) }
} catch let error as DatabaseError where error.resultCode == .SQLITE_CONSTRAINT {
// The UNIQUE-while-active index rejected a second active transfer for this session.
throw SessionTransferConflict(sessionID: lock.sessionID)
}
}
public func updateTransferState(transferID: String, to state: SessionTransferState, at: Date) async throws {
try await writer.write { db in
try db.execute(
sql: "UPDATE session_transfer SET state = ?, updated_at = ? WHERE transfer_id = ?",
arguments: [state.rawValue, at, transferID])
}
}
public func clearTransfer(transferID: String) async throws {
_ = try await writer.write { db in
try TransferRow.deleteOne(db, key: transferID)
}
}
public func transfer(transferID: String) async throws -> SessionTransferLock? {
try await writer.read { db in
try TransferRow.fetchOne(db, key: transferID)?.toLock()
}
}
public func activeTransfer(sessionID: SessionID) async throws -> SessionTransferLock? {
try await writer.read { db in
try TransferRow
.filter(Column("session_id") == sessionID.rawValue)
.fetchAll(db)
.map { $0.toLock() }
.first { $0.state.isActive }
}
}
public func allActiveTransfers() async throws -> [SessionTransferLock] {
try await writer.read { db in
try TransferRow.fetchAll(db).map { $0.toLock() }.filter { $0.state.isActive }
}
}
public func tombstoneSession(id: SessionID, movedToDeviceID: String, at: Date) async throws {
try await writer.write { db in
try db.execute(
sql: """
UPDATE session
SET archived = 1, archived_at = ?, moved_to_device_id = ?, moved_at = ?,
updated_at = ?
WHERE id = ?
""",
arguments: [at, movedToDeviceID, at, at, id.rawValue])
}
}
public func activateTransferredSession(_ session: Session, transferID: String) async throws {
try await writer.write { db in
// Idempotent: if the session already exists (a re-delivered commit), leave it and just
// ensure the lock is terminal.
if try SessionRow.fetchOne(db, key: session.id.rawValue) == nil {
try SessionRow(session).insert(db)
}
try db.execute(
sql: "UPDATE session_transfer SET state = ?, updated_at = ? WHERE transfer_id = ?",
arguments: [SessionTransferState.activated.rawValue, session.updatedAt, transferID])
}
}
}
// MARK: - Row records (1:1 with columns; converted to/from domain types)
private struct TransferRow: Codable, FetchableRecord, PersistableRecord {
static let databaseTableName = "session_transfer"
var transfer_id: String
var session_id: String
var direction: String
var peer_device_id: String
var state: String
var created_at: Date
var updated_at: Date
init(_ lock: SessionTransferLock) {
transfer_id = lock.transferID
session_id = lock.sessionID.rawValue
direction = lock.direction.rawValue
peer_device_id = lock.peerDeviceID
state = lock.state.rawValue
created_at = lock.createdAt
updated_at = lock.updatedAt
}
func toLock() -> SessionTransferLock {
SessionTransferLock(
transferID: transfer_id,
sessionID: SessionID(rawValue: session_id),
direction: TransferDirection(rawValue: direction) ?? .outbound,
peerDeviceID: peer_device_id,
state: SessionTransferState(rawValue: state) ?? .offering,
createdAt: created_at,
updatedAt: updated_at)
}
}
private struct ProjectRow: Codable, FetchableRecord, PersistableRecord {
static let databaseTableName = "project"
var id: String
var name: String
var root_path: String
var default_branch: String
var auto_ship_branch: String?
var default_backend: String?
var worktree_base: String?
var setup_script: String?
var setup_policy: String
var sandbox_config: String?
var nvrsion_config: String?
var created_at: Date
var archived_at: Date?
init(_ p: Project) {
id = p.id.rawValue
name = p.name
root_path = p.rootPath
default_branch = p.defaultBranch.value
auto_ship_branch = p.autoShipBranch?.value
default_backend = p.defaultBackend?.rawValue
worktree_base = p.worktreeBase
setup_script = p.setupScript
setup_policy = p.setupPolicy.rawValue
sandbox_config = p.sandbox.flatMap { sandbox in
(try? JSONEncoder().encode(sandbox)).map { String(decoding: $0, as: UTF8.self) }
}
nvrsion_config = p.nvrsion.flatMap { nvrsion in
(try? JSONEncoder().encode(nvrsion)).map { String(decoding: $0, as: UTF8.self) }
}
created_at = p.createdAt
archived_at = p.archivedAt
}
func toProject() -> Project {
Project(
id: ProjectID(rawValue: id),
name: name,
rootPath: root_path,
defaultBranch: GitRef(default_branch),
autoShipBranch: auto_ship_branch.map { GitRef($0) },
defaultBackend: default_backend.flatMap(BackendID.init(rawValue:)),
worktreeBase: worktree_base,
setupScript: setup_script,
setupPolicy: SetupPolicy(rawValue: setup_policy) ?? .block,
sandbox: sandbox_config.flatMap { json in
try? JSONDecoder().decode(ProjectSandbox.self, from: Data(json.utf8))
},
nvrsion: nvrsion_config.flatMap { json in
try? JSONDecoder().decode(ProjectNvrsion.self, from: Data(json.utf8))
},
createdAt: created_at,
archivedAt: archived_at)
}
}
private struct SessionRow: Codable, FetchableRecord, PersistableRecord {
static let databaseTableName = "session"
var id: String
var project_id: String
var backend: String
var backend_session_id: String?
var title: String
var status: String
var worktree_path: String?
var branch: String?
var base_sha: String?
var parent_ref: String?
var root_ref: String?
var parent_session_id: String?
var spawned_by_session_id: String?
var root_spawned_by_session_id: String?
var model: String?
var effort: String?
var last_seq: Int64
var revert_epoch: Int64
var transcript_path: String
var native_transcript_path: String?
var diff_files: Int?
var diff_added: Int?
var diff_removed: Int?
var ahead: Int?
var behind: Int?
var auto: Bool
var auto_ship: Bool
var ship_branch: String?
var favorite: Bool
var archived: Bool
var archived_at: Date?
var moved_to_device_id: String?
var moved_at: Date?
var arrived_from_device_id: String?
var arrived_at: Date?
var covalence_origin_device_id: String?
var unseen_completion: Bool
var last_event_was_autoship: Bool
var auto_ship_failed: Bool
var auto_ship_conflict: Bool
var summary: String?
var keywords: String?
var last_turn_disposition: String?
var last_user_message_at: Date?
var created_at: Date
var updated_at: Date
init(_ s: Session) {
id = s.id.rawValue
project_id = s.projectID.rawValue
backend = s.backend.rawValue
backend_session_id = s.backendSessionID
title = s.title
status = s.status.rawValue
worktree_path = s.worktreePath
branch = s.branch
base_sha = s.baseSHA
parent_ref = s.parentRef
root_ref = s.rootRef
parent_session_id = s.parentSessionID?.rawValue
spawned_by_session_id = s.spawnedBySessionID?.rawValue
root_spawned_by_session_id = s.rootSpawnedBySessionID?.rawValue
model = s.model
effort = s.effort
last_seq = Int64(s.lastSeq)
revert_epoch = Int64(s.revertEpoch)
transcript_path = s.transcriptPath
native_transcript_path = s.nativeTranscriptPath
diff_files = s.diffStat?.filesChanged
diff_added = s.diffStat?.added
diff_removed = s.diffStat?.removed
ahead = s.ahead
behind = s.behind
auto = s.auto
auto_ship = s.autoShip
ship_branch = s.shipBranch
favorite = s.favorite
archived = s.archived
archived_at = s.archivedAt
moved_to_device_id = s.movedToDeviceID
moved_at = s.movedAt
arrived_from_device_id = s.arrivedFromDeviceID
arrived_at = s.arrivedAt
covalence_origin_device_id = s.covalenceOriginDeviceID
unseen_completion = s.unseenCompletion
last_event_was_autoship = s.lastEventWasAutoship
auto_ship_failed = s.autoShipFailed
auto_ship_conflict = s.autoShipConflict
summary = s.summary
keywords = s.keywords
last_turn_disposition = s.lastTurnDisposition?.rawValue
last_user_message_at = s.lastUserMessageAt
created_at = s.createdAt
updated_at = s.updatedAt
}
func toSession() -> Session {
let diffStat = diff_files.map {
DiffStat(filesChanged: $0, added: diff_added ?? 0, removed: diff_removed ?? 0)
}
return Session(
id: SessionID(rawValue: id),
projectID: ProjectID(rawValue: project_id),
backend: BackendID(rawValue: backend) ?? .claudeCode,
backendSessionID: backend_session_id,
title: title,
status: SessionStatus(rawValue: status) ?? .idle,
worktreePath: worktree_path,
branch: branch,
baseSHA: base_sha,
parentRef: parent_ref,
rootRef: root_ref,
parentSessionID: parent_session_id.map { SessionID(rawValue: $0) },
spawnedBySessionID: spawned_by_session_id.map { SessionID(rawValue: $0) },
rootSpawnedBySessionID: root_spawned_by_session_id.map { SessionID(rawValue: $0) },
model: model,
effort: effort,
lastSeq: UInt64(max(0, last_seq)),
revertEpoch: UInt64(max(0, revert_epoch)),
transcriptPath: transcript_path,
nativeTranscriptPath: native_transcript_path,
diffStat: diffStat,
ahead: ahead,
behind: behind,
auto: auto,
autoShip: auto_ship,
shipBranch: ship_branch,
favorite: favorite,
archived: archived,
archivedAt: archived_at,
movedToDeviceID: moved_to_device_id,
movedAt: moved_at,
arrivedFromDeviceID: arrived_from_device_id,
arrivedAt: arrived_at,
covalenceOriginDeviceID: covalence_origin_device_id,
unseenCompletion: unseen_completion,
lastEventWasAutoship: last_event_was_autoship,
autoShipFailed: auto_ship_failed,
autoShipConflict: auto_ship_conflict,
summary: summary,
keywords: keywords,
lastTurnDisposition: last_turn_disposition.flatMap(TurnDisposition.init(rawValue:)),
lastUserMessageAt: last_user_message_at,
createdAt: created_at,
updatedAt: updated_at)
}
}
private struct TodoRow: Codable, FetchableRecord, PersistableRecord {
static let databaseTableName = "todo"
var id: String
var text: String
var project_id: String?
var status: String
var dispatched_session_id: String?
var summary: String?
var triage_level: String?
var triage_rank: Int?
var triage_reason: String?
var created_at: Date
var updated_at: Date
init(_ t: Todo) {
id = t.id.rawValue
text = t.text
project_id = t.projectID?.rawValue
status = t.status.rawValue
dispatched_session_id = t.dispatchedSessionID?.rawValue
summary = t.summary
triage_level = t.triageLevel?.rawValue
triage_rank = t.triageRank
triage_reason = t.triageReason
created_at = t.createdAt
updated_at = t.updatedAt
}
func toTodo() -> Todo {
Todo(
id: TodoID(rawValue: id),
text: text,
projectID: project_id.map(ProjectID.init(rawValue:)),
status: TodoStatus(rawValue: status) ?? .open,
dispatchedSessionID: dispatched_session_id.map(SessionID.init(rawValue:)),
summary: summary,
triageLevel: triage_level.flatMap(TriageLevel.init(rawValue:)),
triageRank: triage_rank,
triageReason: triage_reason,
createdAt: created_at,
updatedAt: updated_at)
}
}
private struct ActivityRow: Codable, FetchableRecord, PersistableRecord {
static let databaseTableName = "session_activity"
var session_id: String
var last_seq: Int64
var revert_epoch: Int64
var message_count: Int
var activity_json: String // JSON map of day-epoch-seconds (string) → message count
var token_count: Int
var tokens_json: String // JSON map of day-epoch-seconds (string) → tokens used
init(sessionID: SessionID, entry: ActivityCacheEntry) {
session_id = sessionID.rawValue
last_seq = Int64(entry.lastSeq)
revert_epoch = Int64(entry.revertEpoch)
message_count = entry.messageCount
activity_json = Self.encodeByDay(entry.activityByDay)
token_count = entry.tokenCount
tokens_json = Self.encodeByDay(entry.tokensByDay)
}
func toEntry() -> ActivityCacheEntry {
ActivityCacheEntry(
lastSeq: UInt64(max(0, last_seq)), revertEpoch: UInt64(max(0, revert_epoch)),
messageCount: message_count,
activityByDay: Self.decodeByDay(activity_json),
tokenCount: token_count, tokensByDay: Self.decodeByDay(tokens_json))
}
/// A `[Date: Int]` day histogram as a JSON object keyed by integer epoch-seconds (as a
/// string, since JSON object keys are strings). Shared by the message and token series.
private static func encodeByDay(_ byDay: [Date: Int]) -> String {
let map = Dictionary(uniqueKeysWithValues: byDay.map {
(String(Int($0.key.timeIntervalSince1970)), $0.value)
})
return (try? JSONEncoder().encode(map))
.map { String(decoding: $0, as: UTF8.self) } ?? "{}"
}
private static func decodeByDay(_ json: String) -> [Date: Int] {
let map = (try? JSONDecoder().decode([String: Int].self, from: Data(json.utf8))) ?? [:]
return Dictionary(uniqueKeysWithValues: map.compactMap { key, value -> (Date, Int)? in
guard let secs = TimeInterval(key) else { return nil }
return (Date(timeIntervalSince1970: secs), value)
})
}
}
private struct ToolSummaryRow: Codable, FetchableRecord, PersistableRecord {
static let databaseTableName = "tool_summary"
var session_id: String
var tool_call_id: String
var kind: String
var line: String
var inputs: String
init(sessionID: SessionID, entry: ToolSummaryCacheEntry) {
session_id = sessionID.rawValue
tool_call_id = entry.toolCallID
kind = entry.kind.rawValue
line = entry.line
inputs = entry.inputs
}
/// nil for a row whose `kind` this build doesn't know — a cache written by a newer
/// version. Dropping it just re-summarizes; it must never fail the whole load.
func toEntry() -> ToolSummaryCacheEntry? {
guard let kind = ToolSummaryKind(rawValue: kind) else { return nil }
return ToolSummaryCacheEntry(
toolCallID: tool_call_id, kind: kind, line: line, inputs: inputs)
}
}
private struct ApprovalRow: Codable, FetchableRecord, PersistableRecord {
static let databaseTableName = "approval"
var id: String
var session_id: String
var tool_call_id: String?
var tool_name: String
var title: String
var risk: String
var input: String // canonical JSON
var created_at: Date
var resolved_at: Date?
var decision: String?
var decided_by: String?
init(_ r: ApprovalRequest) {
id = r.id.rawValue
session_id = r.sessionID.rawValue
tool_call_id = r.toolCallID
tool_name = r.toolName
title = r.title
risk = r.risk.rawValue
input = r.input.canonicalString()
created_at = r.createdAt
resolved_at = nil
decision = nil
decided_by = nil
}
func toRequest() throws -> ApprovalRequest {
ApprovalRequest(
id: ApprovalID(rawValue: id),
sessionID: SessionID(rawValue: session_id),
toolCallID: tool_call_id,
toolName: tool_name,
input: (try? JSONValue(parsing: input)) ?? .null,
title: title,
risk: Risk(rawValue: risk) ?? .unknown,
createdAt: created_at)
}
}