Files
luma/Sources/LumaCore/Persistence/ProjectStore.swift
Ole André Vadla Ravnås 3f70d259d3 core: Persist pattern placements
Store them with the REPL cell and the address insight, as a JSON
column each, and bind the decode view to them. Older rows and wire
cells without the field decode as none.
2026-10-01 20:49:11 +02:00

1865 lines
67 KiB
Swift

import Foundation
import GRDB
public enum ProjectStoreError: Error, LocalizedError {
case schemaVersionMismatch(expected: Int, found: Int)
public var errorDescription: String? {
switch self {
case .schemaVersionMismatch(let expected, let found):
return "Project database schema version \(found) does not match expected \(expected). Open with an older build or recreate the project."
}
}
}
public final class ProjectStore: Sendable {
public static let didCommitNotification = Notification.Name("LumaCore.ProjectStore.didCommit")
public static let currentSchemaVersion: Int = 1
public let instanceID = UUID()
private let db: DatabaseQueue
public init(path: String) throws {
var config = Configuration()
config.foreignKeysEnabled = true
config.busyMode = .timeout(5)
db = try DatabaseQueue(path: path, configuration: config)
try db.write(Self.openSchema)
let id = instanceID
let observer = CommitNotifyingObserver(instanceID: id)
try db.write { db in
db.add(transactionObserver: observer, extent: .databaseLifetime)
}
}
/// Exports a consistent SQLite snapshot to `destination`, including any
/// data still sitting in the WAL. Uses SQLite's `VACUUM INTO`, which
/// produces a fresh, self-contained database file with no sidecar WAL
/// or -shm files.
public func exportSnapshot(to destination: URL) throws {
let fm = FileManager.default
if fm.fileExists(atPath: destination.path) {
try fm.removeItem(at: destination)
}
try db.writeWithoutTransaction { db in
let escaped = destination.path.replacingOccurrences(of: "'", with: "''")
try db.execute(sql: "VACUUM INTO '\(escaped)'")
}
}
/// Standalone variant for callers that don't have a live `ProjectStore`
/// open — opens a short-lived connection to `source`, runs the vacuum,
/// then closes.
public static func exportSnapshot(from source: URL, to destination: URL) throws {
let store = try ProjectStore(path: source.path)
try store.exportSnapshot(to: destination)
}
public func observeSessions(
onChange: @escaping @Sendable ([ProcessSession]) -> Void
) -> StoreObservation {
StoreObservation(
ValueObservation
.tracking { db in
try ProcessSession
.order(Column("created_at").desc)
.fetchAll(db)
}
.start(in: db, scheduling: .async(onQueue: .main), onError: { _ in }, onChange: onChange)
)
}
public func observeNotebookEntries(
onChange: @escaping @Sendable ([NotebookEntry]) -> Void
) -> StoreObservation {
StoreObservation(
ValueObservation
.tracking { db in
try NotebookEntry
.order(Column("position").asc, Column("id").asc)
.fetchAll(db)
}
.start(in: db, scheduling: .async(onQueue: .main), onError: { _ in }, onChange: onChange)
)
}
public func observeREPLCells(
sessionID: UUID,
onChange: @escaping @Sendable ([REPLCell]) -> Void
) -> StoreObservation {
StoreObservation(
ValueObservation
.tracking { db in
try REPLCell
.filter(Column("session_id") == sessionID)
.order(Column("timestamp").asc)
.fetchAll(db)
}
.start(in: db, scheduling: .async(onQueue: .main), onError: { _ in }, onChange: onChange)
)
}
public func observeAllInstruments(
onChange: @escaping @Sendable ([UUID: [InstrumentInstance]]) -> Void
) -> StoreObservation {
StoreObservation(
ValueObservation
.tracking { db in
try InstrumentInstance.fetchAll(db)
}
.start(in: db, scheduling: .async(onQueue: .main), onError: { _ in }) { rows in
onChange(Dictionary(grouping: rows, by: \.sessionID))
}
)
}
public func observeAllInsights(
onChange: @escaping @Sendable ([UUID: [AddressInsight]]) -> Void
) -> StoreObservation {
StoreObservation(
ValueObservation
.tracking { db in
try AddressInsight.fetchAll(db)
}
.start(in: db, scheduling: .async(onQueue: .main), onError: { _ in }) { rows in
onChange(Dictionary(grouping: rows, by: \.sessionID))
}
)
}
public func observeAllITraces(
onChange: @escaping @Sendable ([UUID: [ITrace]]) -> Void
) -> StoreObservation {
StoreObservation(
ValueObservation
.tracking { db in
try ITrace.fetchAll(db)
}
.start(in: db, scheduling: .async(onQueue: .main), onError: { _ in }) { rows in
onChange(Dictionary(grouping: rows, by: \.sessionID))
}
)
}
public func observeInstalledPackages(
onChange: @escaping @Sendable ([InstalledPackage]) -> Void
) -> StoreObservation {
StoreObservation(
ValueObservation
.tracking { db in
try InstalledPackage
.order(Column("added_at").asc)
.fetchAll(db)
}
.start(in: db, scheduling: .async(onQueue: .main), onError: { _ in }, onChange: onChange)
)
}
public func fetchSessions() throws -> [ProcessSession] {
try db.read { db in
try ProcessSession
.order(Column("created_at").desc)
.fetchAll(db)
}
}
public func fetchSession(id: UUID) throws -> ProcessSession? {
try db.read { db in
try ProcessSession.fetchOne(db, key: id)
}
}
public func save(_ session: ProcessSession) throws {
try db.write { db in
try session.save(db)
}
}
public func deleteSession(id: UUID) throws {
try db.write { db in
_ = try ProcessSession.deleteOne(db, key: id)
}
}
public func fetchInstrument(id: UUID) throws -> InstrumentInstance? {
try db.read { db in
try InstrumentInstance.fetchOne(db, key: id)
}
}
public func fetchInstruments(sessionID: UUID) throws -> [InstrumentInstance] {
try db.read { db in
try InstrumentInstance
.filter(Column("session_id") == sessionID)
.fetchAll(db)
}
}
public func save(_ instance: InstrumentInstance) throws {
try db.write { db in
try instance.save(db)
}
}
public func deleteInstrument(id: UUID) throws {
try db.write { db in
_ = try InstrumentInstance.deleteOne(db, key: id)
}
}
public func fetchREPLCells(sessionID: UUID) throws -> [REPLCell] {
try db.read { db in
try REPLCell
.filter(Column("session_id") == sessionID)
.order(Column("timestamp").asc)
.fetchAll(db)
}
}
public func fetchREPLCell(id: UUID) throws -> REPLCell? {
try db.read { db in
try REPLCell.fetchOne(db, key: id)
}
}
public func save(_ cell: REPLCell) throws {
try db.write { db in
try cell.save(db)
}
}
public func deleteREPLCells(sessionID: UUID) throws {
try db.write { db in
_ = try REPLCell
.filter(Column("session_id") == sessionID)
.deleteAll(db)
}
}
public func fetchNotebookEntries() throws -> [NotebookEntry] {
try db.read { db in
try NotebookEntry
.order(Column("position").asc, Column("id").asc)
.fetchAll(db)
}
}
public func maxNotebookEntryPosition() throws -> Double? {
try db.read { db in
try Double.fetchOne(
db,
sql: "SELECT MAX(position) FROM notebook_entry"
)
}
}
public func fetchNotebookEntry(id: UUID) throws -> NotebookEntry? {
try db.read { db in
try NotebookEntry.fetchOne(db, key: id)
}
}
public func save(_ entry: NotebookEntry) throws {
try db.write { db in
try entry.save(db)
}
}
public func deleteNotebookEntry(id: UUID) throws {
try db.write { db in
_ = try NotebookEntry.deleteOne(db, key: id)
}
}
public func saveOutboxOp(_ op: NotebookOp) throws {
try db.write { db in
try saveOutboxOp(op, in: db)
}
}
public func saveOutboxOps(_ ops: [NotebookOp]) throws {
try db.write { db in
for op in ops {
try saveOutboxOp(op, in: db)
}
}
}
public func fetchOutboxOps() throws -> [NotebookOp] {
try db.read { db in
let rows = try NotebookOutboxRecord
.order(Column("created_at").asc, Column("op_id").asc)
.fetchAll(db)
return rows.compactMap { $0.toOp() }
}
}
public func removeOutboxOp(opID: UUID) throws {
try db.write { db in
_ = try NotebookOutboxRecord.deleteOne(db, key: opID.uuidString)
}
}
public func clearOutbox() throws {
try db.write { db in
_ = try NotebookOutboxRecord.deleteAll(db)
}
}
private func saveOutboxOp(_ op: NotebookOp, in db: Database) throws {
let payload = op.toJSON()
let data = try JSONSerialization.data(
withJSONObject: payload,
options: [.sortedKeys]
)
let json = String(data: data, encoding: .utf8) ?? "{}"
let binary: Data? = {
if case let .add(add) = op { return add.entry.binaryData }
return nil
}()
let record = NotebookOutboxRecord(
opID: op.opID.uuidString,
kind: op.kind,
entryID: op.entryID.uuidString,
payloadJSON: json,
binaryData: binary,
createdAt: Date()
)
try record.save(db)
}
public func saveSessionOutboxOp(_ op: SessionOp) throws {
try db.write { db in
try saveSessionOutboxOp(op, in: db)
}
}
public func saveSessionOutboxOps(_ ops: [SessionOp]) throws {
try db.write { db in
for op in ops {
try saveSessionOutboxOp(op, in: db)
}
}
}
public func fetchSessionOutboxOps() throws -> [SessionOp] {
try db.read { db in
let rows = try SessionOutboxRecord
.order(Column("created_at").asc, Column("op_id").asc)
.fetchAll(db)
return rows.compactMap { $0.toOp() }
}
}
public func removeSessionOutboxOp(opID: UUID) throws {
try db.write { db in
_ = try SessionOutboxRecord.deleteOne(db, key: opID.uuidString)
}
}
public func clearSessionOutbox() throws {
try db.write { db in
_ = try SessionOutboxRecord.deleteAll(db)
}
}
private func saveSessionOutboxOp(_ op: SessionOp, in db: Database) throws {
let payload = op.toJSON()
let data = try JSONSerialization.data(
withJSONObject: payload,
options: [.sortedKeys]
)
let json = String(data: data, encoding: .utf8) ?? "{}"
let record = SessionOutboxRecord(
opID: op.opID.uuidString,
kind: op.kind,
sessionID: op.sessionID.uuidString,
payloadJSON: json,
createdAt: Date()
)
try record.save(db)
}
public func fetchAllSessionUIStates() throws -> [UUID: SessionUIState] {
try db.read { db in
let rows = try SessionUIState.fetchAll(db)
return Dictionary(uniqueKeysWithValues: rows.map { ($0.sessionID, $0) })
}
}
public func save(_ state: SessionUIState) throws {
try db.write { db in
try state.save(db)
}
}
public func fetchAllCustomInstrumentDefUIStates() throws -> [UUID: CustomInstrumentDefUIState] {
try db.read { db in
let rows = try CustomInstrumentDefUIState.fetchAll(db)
return Dictionary(uniqueKeysWithValues: rows.map { ($0.defID, $0) })
}
}
public func save(_ state: CustomInstrumentDefUIState) throws {
try db.write { db in
try state.save(db)
}
}
public func fetchITraces(sessionID: UUID) throws -> [ITrace] {
try db.read { db in
try ITrace
.filter(Column("session_id") == sessionID)
.order(Column("started_at").asc)
.fetchAll(db)
}
}
public func save(_ trace: ITrace) throws {
try db.write { db in
try trace.save(db)
}
}
public func deleteITrace(id: UUID) throws {
try db.write { db in
_ = try ITrace.deleteOne(db, key: id)
}
}
public func fetchInsights(sessionID: UUID) throws -> [AddressInsight] {
try db.read { db in
try AddressInsight
.filter(Column("session_id") == sessionID)
.fetchAll(db)
}
}
public func save(_ insight: AddressInsight) throws {
try db.write { db in
try insight.save(db)
}
}
public func deleteInsight(id: UUID) throws {
try db.write { db in
_ = try AddressInsight.deleteOne(db, key: id)
}
}
public func fetchAddressNote(id: UUID) throws -> AddressNote? {
try db.read { db in
try AddressNote.fetchOne(db, key: id)
}
}
public func fetchAddressNoteMessage(id: UUID) throws -> AddressNoteMessage? {
try db.read { db in
try AddressNoteMessage.fetchOne(db, key: id)
}
}
public func fetchAddressNotes(sessionID: UUID) throws -> [AddressNote] {
try db.read { db in
try AddressNote
.filter(Column("session_id") == sessionID)
.order(Column("created_at"))
.fetchAll(db)
}
}
public func fetchAddressNoteMessages(noteID: UUID) throws -> [AddressNoteMessage] {
try db.read { db in
try AddressNoteMessage
.filter(Column("note_id") == noteID)
.order(Column("index"))
.fetchAll(db)
}
}
public func save(_ note: AddressNote) throws {
try db.write { db in
try note.save(db)
}
}
public func fetchModuleAnalysis(sessionID: UUID, modulePath: String) throws -> ModuleAnalysis? {
try db.read { db in
try loadModuleAnalysis(db, sessionID: sessionID, modulePath: modulePath)
}
}
public func save(_ analysis: ModuleAnalysis) throws {
try db.write { db in
try writeModuleAnalysis(db, analysis: analysis)
}
}
public func upsertModuleSymbol(sessionID: UUID, modulePath: String, offset: UInt64, name: String) throws {
let record = ModuleSymbolRow(
sessionID: sessionID,
modulePath: modulePath,
offset: Int64(bitPattern: offset),
name: name
)
try db.write { db in try record.upsert(db) }
}
public func enclosingModuleSymbol(sessionID: UUID, modulePath: String, offset: UInt64) throws -> (offset: UInt64, name: String)? {
let key = Int64(bitPattern: offset)
return try db.read { db in
guard let row = try ModuleSymbolRow
.filter(Column("session_id") == sessionID)
.filter(Column("module_path") == modulePath)
.filter(Column("offset") <= key)
.order(Column("offset").desc)
.fetchOne(db)
else { return nil }
return (UInt64(bitPattern: row.offset), row.name)
}
}
public func enclosingFunction(sessionID: UUID, modulePath: String, offset: UInt64) throws -> (functionOffset: UInt64, name: String?)? {
let key = Int64(bitPattern: offset)
return try db.read { db in
guard let block = try ModuleAnalysisBlockRow
.filter(Column("session_id") == sessionID)
.filter(Column("module_path") == modulePath)
.filter(Column("offset") <= key)
.filter(Column("offset") + Column("size") > key)
.fetchOne(db)
else { return nil }
let function = try ModuleAnalysisFunctionRow
.filter(Column("session_id") == sessionID)
.filter(Column("module_path") == modulePath)
.filter(Column("offset") == block.functionOffset)
.fetchOne(db)
return (UInt64(bitPattern: block.functionOffset), function?.name)
}
}
private func loadModuleAnalysis(_ db: Database, sessionID: UUID, modulePath: String) throws -> ModuleAnalysis? {
guard let header = try ModuleAnalysisRow
.filter(Column("session_id") == sessionID)
.filter(Column("module_path") == modulePath)
.fetchOne(db)
else { return nil }
let mappedRanges = (try? JSONDecoder().decode([ProcessNode.ModuleRange].self, from: header.mappedRanges)) ?? []
let functionRows = try ModuleAnalysisFunctionRow
.filter(Column("session_id") == sessionID)
.filter(Column("module_path") == modulePath)
.order(Column("offset"))
.fetchAll(db)
let blockRows = try ModuleAnalysisBlockRow
.filter(Column("session_id") == sessionID)
.filter(Column("module_path") == modulePath)
.order(Column("function_offset"), Column("offset"))
.fetchAll(db)
var blocksByFunction: [Int64: [ModuleAnalysis.Function.Block]] = [:]
for row in blockRows {
blocksByFunction[row.functionOffset, default: []].append(
.init(offset: UInt64(bitPattern: row.offset), size: UInt64(bitPattern: row.size))
)
}
let functions: [ModuleAnalysis.Function] = functionRows.map { row in
ModuleAnalysis.Function(
offset: UInt64(bitPattern: row.offset),
name: row.name,
source: ModuleAnalysis.Function.Source(rawValue: row.source) ?? .symbol,
blocks: blocksByFunction[row.offset] ?? []
)
}
return ModuleAnalysis(
sessionID: sessionID,
modulePath: modulePath,
moduleUUID: header.moduleUUID,
mappedRanges: mappedRanges,
functions: functions,
analyzedAt: header.analyzedAt
)
}
private func writeModuleAnalysis(_ db: Database, analysis: ModuleAnalysis) throws {
let header = ModuleAnalysisRow(
sessionID: analysis.sessionID,
modulePath: analysis.modulePath,
moduleUUID: analysis.moduleUUID,
mappedRanges: try JSONEncoder().encode(analysis.mappedRanges),
analyzedAt: analysis.analyzedAt
)
try header.save(db)
try ModuleAnalysisFunctionRow
.filter(Column("session_id") == analysis.sessionID)
.filter(Column("module_path") == analysis.modulePath)
.deleteAll(db)
for function in analysis.functions {
let functionOffset = Int64(bitPattern: function.offset)
try ModuleAnalysisFunctionRow(
sessionID: analysis.sessionID,
modulePath: analysis.modulePath,
offset: functionOffset,
name: function.name,
source: function.source.rawValue
).insert(db)
for block in function.blocks {
try ModuleAnalysisBlockRow(
sessionID: analysis.sessionID,
modulePath: analysis.modulePath,
functionOffset: functionOffset,
offset: Int64(bitPattern: block.offset),
size: Int64(bitPattern: block.size)
).insert(db)
}
}
}
public func fetchMemoryPage(sessionID: UUID, moduleUUID: String, offset: UInt64) throws -> MemoryPageModule? {
try db.read { db in
try MemoryPageModule
.filter(Column("session_id") == sessionID)
.filter(Column("module_uuid") == moduleUUID)
.filter(Column("offset") == Int64(bitPattern: offset))
.fetchOne(db)
}
}
public func save(_ page: MemoryPageModule) throws {
try db.write { db in
try page.save(db)
}
}
public func fetchMemoryPage(sessionID: UUID, processIdentity: String, address: UInt64) throws -> MemoryPageAnon? {
try db.read { db in
try MemoryPageAnon
.filter(Column("session_id") == sessionID)
.filter(Column("process_identity") == processIdentity)
.filter(Column("address") == Int64(bitPattern: address))
.fetchOne(db)
}
}
public func save(_ page: MemoryPageAnon) throws {
try db.write { db in
try page.save(db)
}
}
public func deleteMemoryPage(sessionID: UUID, moduleUUID: String, offset: UInt64) throws {
try db.write { db in
_ = try MemoryPageModule
.filter(Column("session_id") == sessionID)
.filter(Column("module_uuid") == moduleUUID)
.filter(Column("offset") == Int64(bitPattern: offset))
.deleteAll(db)
}
}
public func deleteMemoryPage(sessionID: UUID, processIdentity: String, address: UInt64) throws {
try db.write { db in
_ = try MemoryPageAnon
.filter(Column("session_id") == sessionID)
.filter(Column("process_identity") == processIdentity)
.filter(Column("address") == Int64(bitPattern: address))
.deleteAll(db)
}
}
public func deleteMemoryPagesModule(sessionID: UUID, moduleUUID: String) throws {
try db.write { db in
_ = try MemoryPageModule
.filter(Column("session_id") == sessionID)
.filter(Column("module_uuid") == moduleUUID)
.deleteAll(db)
}
}
public func deleteModuleAnalysis(sessionID: UUID, modulePath: String) throws {
try db.write { db in
_ = try ModuleAnalysisRow
.filter(Column("session_id") == sessionID)
.filter(Column("module_path") == modulePath)
.deleteAll(db)
}
}
public func deleteMemoryPagesAnon(sessionID: UUID, exceptIdentity: String) throws {
try db.write { db in
_ = try MemoryPageAnon
.filter(Column("session_id") == sessionID)
.filter(Column("process_identity") != exceptIdentity)
.deleteAll(db)
}
}
public func save(_ message: AddressNoteMessage) throws {
try db.write { db in
try message.save(db)
}
}
public func deleteAddressNote(id: UUID) throws {
try db.write { db in
_ = try AddressNote.deleteOne(db, key: id)
}
}
public func deleteAddressNoteMessage(id: UUID) throws {
try db.write { db in
_ = try AddressNoteMessage.deleteOne(db, key: id)
}
}
public func saveAddressNoteOutboxOp(_ op: AddressNoteOp) throws {
try db.write { db in
let payload = op.toJSON()
let data = try JSONSerialization.data(
withJSONObject: payload,
options: [.sortedKeys]
)
let json = String(data: data, encoding: .utf8) ?? "{}"
let record = AddressNoteOutboxRecord(
opID: op.opID.uuidString,
kind: op.kind,
noteID: op.noteID.uuidString,
payloadJSON: json,
createdAt: Date()
)
try record.save(db)
}
}
public func fetchAddressNoteOutboxOps() throws -> [AddressNoteOp] {
try db.read { db in
let rows = try AddressNoteOutboxRecord
.order(Column("created_at").asc, Column("op_id").asc)
.fetchAll(db)
return rows.compactMap { $0.toOp() }
}
}
public func removeAddressNoteOutboxOp(opID: UUID) throws {
try db.write { db in
_ = try AddressNoteOutboxRecord.deleteOne(db, key: opID.uuidString)
}
}
public func clearAddressNoteOutbox() throws {
try db.write { db in
_ = try AddressNoteOutboxRecord.deleteAll(db)
}
}
public func observeMissions(
onChange: @escaping @Sendable ([Mission]) -> Void
) -> StoreObservation {
StoreObservation(
ValueObservation
.tracking { db in
try Mission
.order(Column("created_at").desc)
.fetchAll(db)
}
.start(in: db, scheduling: .async(onQueue: .main), onError: { _ in }, onChange: onChange)
)
}
public func observeMissionTurns(
missionID: UUID,
onChange: @escaping @Sendable ([MissionTurn]) -> Void
) -> StoreObservation {
StoreObservation(
ValueObservation
.tracking { db in
try MissionTurn
.filter(Column("mission_id") == missionID)
.order(Column("index").asc)
.fetchAll(db)
}
.start(in: db, scheduling: .async(onQueue: .main), onError: { _ in }, onChange: onChange)
)
}
public func observeMissionActions(
missionID: UUID,
onChange: @escaping @Sendable ([MissionAction]) -> Void
) -> StoreObservation {
StoreObservation(
ValueObservation
.tracking { db in
try MissionAction
.filter(Column("mission_id") == missionID)
.order(Column("requested_at").asc)
.fetchAll(db)
}
.start(in: db, scheduling: .async(onQueue: .main), onError: { _ in }, onChange: onChange)
)
}
public func fetchAllPendingMissionActions() throws -> [MissionAction] {
try db.read { db in
try MissionAction
.filter(Column("status") == MissionActionStatus.pending.rawValue)
.order(Column("requested_at").asc)
.fetchAll(db)
}
}
public func observeAllPendingMissionActions(
onChange: @escaping @Sendable ([MissionAction]) -> Void
) -> StoreObservation {
StoreObservation(
ValueObservation
.tracking { db in
try MissionAction
.filter(Column("status") == MissionActionStatus.pending.rawValue)
.order(Column("requested_at").asc)
.fetchAll(db)
}
.start(in: db, scheduling: .async(onQueue: .main), onError: { _ in }, onChange: onChange)
)
}
public func observeMissionFindings(
missionID: UUID,
onChange: @escaping @Sendable ([MissionFinding]) -> Void
) -> StoreObservation {
StoreObservation(
ValueObservation
.tracking { db in
try MissionFinding
.filter(Column("mission_id") == missionID)
.order(Column("created_at").asc)
.fetchAll(db)
}
.start(in: db, scheduling: .async(onQueue: .main), onError: { _ in }, onChange: onChange)
)
}
public func fetchMissions() throws -> [Mission] {
try db.read { db in
try Mission.order(Column("created_at").desc).fetchAll(db)
}
}
public func fetchMission(id: UUID) throws -> Mission? {
try db.read { db in
try Mission.fetchOne(db, key: id)
}
}
public func save(_ mission: Mission) throws {
try db.write { db in
var m = mission
m.updatedAt = Date()
try m.save(db)
}
}
@discardableResult
public func updateMission(id: UUID, _ mutate: (inout Mission) -> Void) -> Mission? {
try? db.write { db in
guard var mission = try Mission.fetchOne(db, key: id) else { return nil }
mutate(&mission)
mission.updatedAt = Date()
try mission.save(db)
return mission
}
}
public func deleteMission(id: UUID) throws {
try db.write { db in
_ = try Mission.deleteOne(db, key: id)
}
}
public func fetchMissionTurns(missionID: UUID) throws -> [MissionTurn] {
try db.read { db in
try MissionTurn
.filter(Column("mission_id") == missionID)
.order(Column("index").asc)
.fetchAll(db)
}
}
public func save(_ turn: MissionTurn) throws {
try db.write { db in
try turn.save(db)
}
}
public func nextMissionTurnIndex(missionID: UUID) throws -> Int {
try db.read { db in
let max = try Int.fetchOne(
db,
sql: "SELECT MAX(\"index\") FROM mission_turn WHERE mission_id = ?",
arguments: [missionID.uuidString]
) ?? -1
return max + 1
}
}
public func fetchMissionActions(missionID: UUID) throws -> [MissionAction] {
try db.read { db in
try MissionAction
.filter(Column("mission_id") == missionID)
.order(Column("requested_at").asc)
.fetchAll(db)
}
}
public func countMissionActions(missionID: UUID) throws -> Int {
try db.read { db in
try Int.fetchOne(
db,
sql: "SELECT COUNT(*) FROM mission_action WHERE mission_id = ?",
arguments: [missionID.uuidString]
) ?? 0
}
}
public func hasPendingMissionActions(missionID: UUID) throws -> Bool {
try db.read { db in
let count = try Int.fetchOne(
db,
sql: "SELECT 1 FROM mission_action WHERE mission_id = ? AND status = ? LIMIT 1",
arguments: [missionID.uuidString, MissionActionStatus.pending.rawValue]
)
return count != nil
}
}
public func fetchPendingMissionActions(missionID: UUID) throws -> [MissionAction] {
try db.read { db in
try MissionAction
.filter(Column("mission_id") == missionID && Column("status") == MissionActionStatus.pending.rawValue)
.order(Column("requested_at").asc)
.fetchAll(db)
}
}
public func fetchMissionAction(id: UUID) throws -> MissionAction? {
try db.read { db in
try MissionAction.fetchOne(db, key: id)
}
}
public func save(_ action: MissionAction) throws {
try db.write { db in
try action.save(db)
}
}
public func fetchMissionFindings(missionID: UUID) throws -> [MissionFinding] {
try db.read { db in
try MissionFinding
.filter(Column("mission_id") == missionID)
.order(Column("created_at").asc)
.fetchAll(db)
}
}
public func save(_ finding: MissionFinding) throws {
try db.write { db in
try finding.save(db)
}
}
public func deleteMissionFinding(id: UUID) throws {
try db.write { db in
_ = try MissionFinding.deleteOne(db, key: id)
}
}
public func fetchMissionEvidence(findingID: UUID) throws -> [MissionEvidence] {
try db.read { db in
try MissionEvidence
.filter(Column("finding_id") == findingID)
.fetchAll(db)
}
}
public func save(_ evidence: MissionEvidence) throws {
try db.write { db in
try evidence.save(db)
}
}
public func saveMissionOutboxOp(_ op: MissionOp) throws {
try db.write { db in
try saveMissionOutboxOp(op, in: db)
}
}
public func fetchMissionOutboxOps() throws -> [MissionOp] {
try db.read { db in
let rows = try MissionOutboxRecord
.order(Column("created_at").asc, Column("op_id").asc)
.fetchAll(db)
return rows.compactMap { $0.toOp() }
}
}
public func removeMissionOutboxOp(opID: UUID) throws {
try db.write { db in
_ = try MissionOutboxRecord.deleteOne(db, key: opID.uuidString)
}
}
public func clearMissionOutbox() throws {
try db.write { db in
_ = try MissionOutboxRecord.deleteAll(db)
}
}
private func saveMissionOutboxOp(_ op: MissionOp, in db: Database) throws {
let payload = op.toJSON()
let data = try JSONSerialization.data(
withJSONObject: payload,
options: [.sortedKeys]
)
let json = String(data: data, encoding: .utf8) ?? "{}"
let record = MissionOutboxRecord(
opID: op.opID.uuidString,
kind: op.kind,
missionID: op.missionID.uuidString,
payloadJSON: json,
createdAt: Date()
)
try record.save(db)
}
public func fetchRemoteDevices() throws -> [RemoteDeviceConfig] {
try db.read { db in
try RemoteDeviceConfig.fetchAll(db)
}
}
public func save(_ config: RemoteDeviceConfig) throws {
try db.write { db in
try config.save(db)
}
}
public func deleteRemoteDevice(id: UUID) throws {
try db.write { db in
_ = try RemoteDeviceConfig.deleteOne(db, key: id)
}
}
public func fetchVirtualMachines() throws -> [VirtualMachineRecord] {
try db.read { db in
try VirtualMachineRecord.fetchAll(db)
}
}
public func save(_ machine: VirtualMachineRecord) throws {
try db.write { db in
try machine.save(db)
}
}
public func deleteVirtualMachine(id: UUID) throws {
try db.write { db in
_ = try VirtualMachineRecord.deleteOne(db, key: id)
}
}
public func fetchPackagesState() throws -> ProjectPackagesState {
try db.write { db in
var state: ProjectPackagesState
if let existing = try ProjectPackagesState.fetchOne(db) {
state = existing
} else {
state = ProjectPackagesState()
try state.save(db)
}
state.packages = try InstalledPackage
.filter(Column("packages_state_id") == state.id)
.order(Column("added_at").asc)
.fetchAll(db)
return state
}
}
public func save(_ state: ProjectPackagesState) throws {
try db.write { db in
try state.save(db)
try InstalledPackage
.filter(Column("packages_state_id") == state.id)
.deleteAll(db)
for var pkg in state.packages {
pkg.packagesStateID = state.id
try pkg.insert(db)
}
}
}
public func fetchCollaborationState() throws -> ProjectCollaborationState {
try db.read { db in
try ProjectCollaborationState.fetchOne(db) ?? ProjectCollaborationState()
}
}
public func save(_ state: ProjectCollaborationState) throws {
try db.write { db in
try state.save(db)
}
}
public func observeCustomInstrumentDefs(
onChange: @escaping @Sendable ([CustomInstrumentDef]) -> Void
) -> StoreObservation {
StoreObservation(
ValueObservation
.tracking { db in
try CustomInstrumentDef
.order(Column("created_at").asc)
.fetchAll(db)
}
.start(in: db, scheduling: .async(onQueue: .main), onError: { _ in }, onChange: onChange)
)
}
public func fetchCustomInstrumentDefs() throws -> [CustomInstrumentDef] {
try db.read { db in
try CustomInstrumentDef
.order(Column("created_at").asc)
.fetchAll(db)
}
}
public func fetchCustomInstrumentDef(id: UUID) throws -> CustomInstrumentDef? {
try db.read { db in
try CustomInstrumentDef
.filter(Column("id") == id.uuidString)
.fetchOne(db)
}
}
public func save(_ def: CustomInstrumentDef) throws {
try db.write { db in
try def.save(db)
}
}
public func deleteCustomInstrumentDef(id: UUID) throws {
try db.write { db in
_ = try CustomInstrumentDef
.filter(Column("id") == id.uuidString)
.deleteAll(db)
}
}
public func observeCustomInstrumentFiles(
onChange: @escaping @Sendable ([UUID: [CustomInstrumentFile]]) -> Void
) -> StoreObservation {
StoreObservation(
ValueObservation
.tracking { db in
try CustomInstrumentFile
.order(Column("def_id").asc, Column("path").asc)
.fetchAll(db)
}
.start(in: db, scheduling: .async(onQueue: .main), onError: { _ in }) { files in
onChange(Dictionary(grouping: files, by: \.defID))
}
)
}
public func fetchAllCustomInstrumentFiles() throws -> [CustomInstrumentFile] {
try db.read { db in
try CustomInstrumentFile
.order(Column("def_id").asc, Column("path").asc)
.fetchAll(db)
}
}
public func fetchCustomInstrumentFiles(defID: UUID) throws -> [CustomInstrumentFile] {
try db.read { db in
try CustomInstrumentFile
.filter(Column("def_id") == defID.uuidString)
.order(Column("path").asc)
.fetchAll(db)
}
}
public func fetchCustomInstrumentFile(defID: UUID, path: String) throws -> CustomInstrumentFile? {
try db.read { db in
try CustomInstrumentFile
.filter(Column("def_id") == defID.uuidString && Column("path") == path)
.fetchOne(db)
}
}
public func save(_ file: CustomInstrumentFile) throws {
try db.write { db in
try file.save(db)
}
}
public func deleteCustomInstrumentFile(defID: UUID, path: String) throws {
try db.write { db in
try db.execute(
sql: "DELETE FROM custom_instrument_file WHERE def_id = ? AND path = ?",
arguments: [defID.uuidString, path]
)
}
}
public func renameCustomInstrumentFile(defID: UUID, from: String, to: String) throws {
try db.write { db in
try db.execute(
sql: "UPDATE custom_instrument_file SET path = ? WHERE def_id = ? AND path = ?",
arguments: [to, defID.uuidString, from]
)
}
}
public func replaceCustomInstrumentFiles(defID: UUID, files: [CustomInstrumentFile]) throws {
try db.write { db in
try db.execute(
sql: "DELETE FROM custom_instrument_file WHERE def_id = ?",
arguments: [defID.uuidString]
)
for file in files {
try file.save(db)
}
}
}
public func saveCustomInstrumentOutboxOp(_ op: CustomInstrumentOp) throws {
try db.write { db in
try saveCustomInstrumentOutboxOp(op, in: db)
}
}
public func fetchCustomInstrumentOutboxOps() throws -> [CustomInstrumentOp] {
try db.read { db in
let rows = try CustomInstrumentOutboxRecord
.order(Column("created_at").asc, Column("op_id").asc)
.fetchAll(db)
return rows.compactMap { $0.toOp() }
}
}
public func removeCustomInstrumentOutboxOp(opID: UUID) throws {
try db.write { db in
_ = try CustomInstrumentOutboxRecord.deleteOne(db, key: opID.uuidString)
}
}
public func clearCustomInstrumentOutbox() throws {
try db.write { db in
_ = try CustomInstrumentOutboxRecord.deleteAll(db)
}
}
public func fetchWidgetStates(instanceID: UUID) throws -> [String: WidgetState] {
try db.read { db in
let rows = try Row.fetchAll(
db,
sql: "SELECT widget_id, state_json FROM widget_state WHERE instance_id = ?",
arguments: [instanceID.uuidString]
)
var result: [String: WidgetState] = [:]
for row in rows {
let widgetID: String = row["widget_id"]
let json: String = row["state_json"]
result[widgetID] = try JSONDecoder().decode(WidgetState.self, from: Data(json.utf8))
}
return result
}
}
public func saveWidgetState(
instanceID: UUID,
widgetID: String,
sessionID: UUID,
state: WidgetState
) throws {
let json = String(decoding: try JSONEncoder().encode(state), as: UTF8.self)
try db.write { db in
try db.execute(
sql: """
INSERT INTO widget_state (instance_id, widget_id, session_id, state_json, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(instance_id, widget_id) DO UPDATE SET
state_json = excluded.state_json,
session_id = excluded.session_id,
updated_at = excluded.updated_at
""",
arguments: [instanceID.uuidString, widgetID, sessionID.uuidString, json, Date()]
)
}
}
public func deleteWidgetState(instanceID: UUID, widgetID: String) throws {
try db.write { db in
try db.execute(
sql: "DELETE FROM widget_state WHERE instance_id = ? AND widget_id = ?",
arguments: [instanceID.uuidString, widgetID]
)
}
}
public func deleteWidgetStates(instanceID: UUID) throws {
try db.write { db in
try db.execute(
sql: "DELETE FROM widget_state WHERE instance_id = ?",
arguments: [instanceID.uuidString]
)
}
}
private func saveCustomInstrumentOutboxOp(_ op: CustomInstrumentOp, in db: Database) throws {
let payload = op.toJSON()
let data = try JSONSerialization.data(
withJSONObject: payload,
options: [.sortedKeys]
)
let json = String(data: data, encoding: .utf8) ?? "{}"
let record = CustomInstrumentOutboxRecord(
opID: op.opID.uuidString,
kind: op.kind,
defID: op.defID.uuidString,
payloadJSON: json,
createdAt: Date()
)
try record.save(db)
}
public func fetchProjectUIState() throws -> ProjectUIState {
try db.read { db in
try ProjectUIState.fetchOne(db) ?? ProjectUIState()
}
}
public func save(_ state: ProjectUIState) throws {
try db.write { db in
try state.save(db)
}
}
public func fetchTargetPickerState() throws -> TargetPickerState {
try db.read { db in
try TargetPickerState.fetchOne(db) ?? TargetPickerState()
}
}
public func save(_ state: TargetPickerState) throws {
try db.write { db in
try state.save(db)
}
}
private static func openSchema(_ db: Database) throws {
let storedVersion = try Int.fetchOne(db, sql: "PRAGMA user_version") ?? 0
if try !db.tableExists("process_session") {
try createSchema(db)
try db.execute(sql: "PRAGMA user_version = \(currentSchemaVersion)")
return
}
guard storedVersion == currentSchemaVersion else {
throw ProjectStoreError.schemaVersionMismatch(
expected: currentSchemaVersion,
found: storedVersion
)
}
try createSchema(db)
}
private static func createSchema(_ db: Database) throws {
try db.create(table: "process_session", ifNotExists: true) { t in
t.primaryKey("id", .text).notNull()
t.column("kind", .blob).notNull()
t.column("host", .blob)
t.column("device_id", .text).notNull()
t.column("device_name", .text).notNull()
t.column("process_name", .text).notNull()
t.column("icon_png_data", .blob)
t.column("phase", .integer).notNull()
t.column("arming_state", .blob)
t.column("last_arm_pattern", .text)
t.column("detach_reason", .integer).notNull()
t.column("last_error", .text)
t.column("created_at", .datetime).notNull()
t.column("last_known_pid", .integer).notNull()
t.column("last_attached_at", .datetime)
t.column("process_info", .blob)
t.column("last_known_main_module", .blob)
t.column("last_known_modules", .blob)
t.column("last_known_threads", .blob)
}
try db.create(table: "instrument_instance", ifNotExists: true) { t in
t.primaryKey("id", .text).notNull()
t.column("session_id", .text).notNull()
.references("process_session", onDelete: .cascade)
t.column("kind", .text).notNull()
t.column("source_identifier", .text).notNull()
t.column("state", .text).notNull().defaults(to: "enabled")
t.column("config_json", .blob).notNull()
}
try db.create(table: "repl_cell", ifNotExists: true) { t in
t.primaryKey("id", .text).notNull()
t.column("session_id", .text).notNull()
.references("process_session", onDelete: .cascade)
t.column("author", .blob)
t.column("code", .text).notNull()
t.column("language", .text).notNull().defaults(to: REPLLanguage.javascript.rawValue)
t.column("result", .blob).notNull()
t.column("placements", .blob)
t.column("timestamp", .datetime).notNull()
t.column("is_session_boundary", .boolean).notNull().defaults(to: false)
}
try db.create(table: "notebook_entry", ifNotExists: true) { t in
t.primaryKey("id", .text).notNull()
t.column("kind", .text).notNull()
t.column("editors", .blob).notNull()
t.column("timestamp", .datetime).notNull()
t.column("position", .double).notNull().defaults(to: 0)
t.column("title", .text).notNull()
t.column("details", .text).notNull()
t.column("styled_details", .blob)
t.column("js_value", .blob)
t.column("pharo_snapshot", .blob)
t.column("pharo_result_fuel", .blob)
t.column("binary_data", .blob)
t.column("session_id", .text)
t.column("process_name", .text)
}
try db.create(table: "notebook_outbox", ifNotExists: true) { t in
t.primaryKey("op_id", .text).notNull()
t.column("kind", .text).notNull()
t.column("entry_id", .text).notNull()
t.column("payload_json", .text).notNull()
t.column("binary_data", .blob)
t.column("created_at", .datetime).notNull()
}
try db.create(table: "session_outbox", ifNotExists: true) { t in
t.primaryKey("op_id", .text).notNull()
t.column("kind", .text).notNull()
t.column("session_id", .text).notNull()
t.column("payload_json", .text).notNull()
t.column("created_at", .datetime).notNull()
}
try db.create(table: "itrace", ifNotExists: true) { t in
t.primaryKey("id", .text).notNull()
t.column("session_id", .text).notNull()
.references("process_session", onDelete: .cascade)
t.column("origin", .blob).notNull()
t.column("display_name", .text).notNull()
t.column("started_at", .datetime).notNull()
t.column("stopped_at", .datetime)
t.column("metadata_json", .blob).notNull()
t.column("data_size", .integer).notNull().defaults(to: 0)
t.column("lost", .integer).notNull().defaults(to: 0)
}
try db.create(table: "session_ui_state", ifNotExists: true) { t in
t.primaryKey("session_id", .text).notNull()
.references("process_session", onDelete: .cascade)
t.column("sidebar_expansion", .text).notNull().defaults(to: SidebarExpansion.expanded.rawValue)
t.column("modules_expansion", .text).notNull().defaults(to: SidebarExpansion.collapsed.rawValue)
t.column("threads_expansion", .text).notNull().defaults(to: SidebarExpansion.collapsed.rawValue)
t.column("collapsed_hook_instruments", .text).notNull().defaults(to: "[]")
t.column("detail_section", .text)
t.column("last_selected_module_id", .text)
t.column("last_selected_thread_id", .integer)
t.column("ambient_mission_id", .text)
t.column("repl_language", .text).notNull().defaults(to: REPLLanguage.javascript.rawValue)
t.column("repl_draft", .text)
t.column("repl_seek_anchor", .text)
}
try db.create(table: "address_insight", ifNotExists: true) { t in
t.primaryKey("id", .text).notNull()
t.column("session_id", .text).notNull()
.references("process_session", onDelete: .cascade)
t.column("created_at", .datetime).notNull()
t.column("user_title", .text)
t.column("kind", .integer).notNull()
t.column("anchor", .blob).notNull()
t.column("byte_count", .integer).notNull()
t.column("placements", .blob)
t.column("last_resolved_address", .integer)
t.column("parent_insight_id", .text)
}
try db.create(table: "address_note", ifNotExists: true) { t in
t.primaryKey("id", .text).notNull()
t.column("session_id", .text).notNull()
.references("process_session", onDelete: .cascade)
t.column("anchor", .blob).notNull()
t.column("title", .text)
t.column("editors", .blob).notNull()
t.column("created_at", .datetime).notNull()
t.column("updated_at", .datetime).notNull()
}
try db.create(table: "address_note_message", ifNotExists: true) { t in
t.primaryKey("id", .text).notNull()
t.column("note_id", .text).notNull()
.references("address_note", onDelete: .cascade)
t.column("index", .integer).notNull()
t.column("role", .text).notNull()
t.column("author", .blob)
t.column("body_markdown", .text).notNull()
t.column("provider_id", .text)
t.column("model_id", .text)
t.column("action_id", .text)
t.column("created_at", .datetime).notNull()
}
try db.create(table: "memory_page_module", ifNotExists: true) { t in
t.column("session_id", .text).notNull()
.references("process_session", onDelete: .cascade)
t.column("module_uuid", .text).notNull()
t.column("offset", .integer).notNull()
t.column("bytes", .blob).notNull()
t.column("captured_at", .datetime).notNull()
t.primaryKey(["session_id", "module_uuid", "offset"])
}
try db.create(table: "memory_page_anon", ifNotExists: true) { t in
t.column("session_id", .text).notNull()
.references("process_session", onDelete: .cascade)
t.column("process_identity", .text).notNull()
t.column("address", .integer).notNull()
t.column("bytes", .blob).notNull()
t.column("captured_at", .datetime).notNull()
t.primaryKey(["session_id", "process_identity", "address"])
}
try db.create(table: "module_analysis", ifNotExists: true) { t in
t.column("session_id", .text).notNull()
.references("process_session", onDelete: .cascade)
t.column("module_path", .text).notNull()
t.column("module_uuid", .text)
t.column("mapped_ranges", .blob).notNull()
t.column("analyzed_at", .datetime).notNull()
t.primaryKey(["session_id", "module_path"])
}
try db.create(table: "module_analysis_function", ifNotExists: true) { t in
t.column("session_id", .text).notNull()
t.column("module_path", .text).notNull()
t.column("offset", .integer).notNull()
t.column("name", .text)
t.column("source", .text).notNull()
t.primaryKey(["session_id", "module_path", "offset"])
t.foreignKey(
["session_id", "module_path"],
references: "module_analysis",
columns: ["session_id", "module_path"],
onDelete: .cascade
)
}
try db.create(table: "module_analysis_block", ifNotExists: true) { t in
t.column("session_id", .text).notNull()
t.column("module_path", .text).notNull()
t.column("function_offset", .integer).notNull()
t.column("offset", .integer).notNull()
t.column("size", .integer).notNull()
t.primaryKey(["session_id", "module_path", "function_offset", "offset"])
t.foreignKey(
["session_id", "module_path", "function_offset"],
references: "module_analysis_function",
columns: ["session_id", "module_path", "offset"],
onDelete: .cascade
)
}
try db.create(
index: "idx_module_analysis_block_lookup",
on: "module_analysis_block",
columns: ["session_id", "module_path", "offset"],
ifNotExists: true
)
try db.create(table: "module_symbol", ifNotExists: true) { t in
t.column("session_id", .text).notNull()
.references("process_session", onDelete: .cascade)
t.column("module_path", .text).notNull()
t.column("offset", .integer).notNull()
t.column("name", .text).notNull()
t.primaryKey(["session_id", "module_path", "offset"])
}
try db.create(table: "remote_device_config", ifNotExists: true) { t in
t.primaryKey("id", .text).notNull()
t.column("address", .text).notNull()
t.column("certificate", .text)
t.column("origin", .text)
t.column("token", .text)
t.column("keepalive_interval", .integer)
}
try db.create(table: "virtual_machine", ifNotExists: true) { t in
t.primaryKey("id", .text).notNull()
t.column("name", .text).notNull()
t.column("template_id", .text).notNull()
t.column("parameters_json", .text).notNull()
t.column("agent_path", .text)
t.column("has_ready_snapshot", .boolean).notNull().defaults(to: false)
}
try db.create(table: "project_packages_state", ifNotExists: true) { t in
t.primaryKey("id", .text).notNull()
t.column("package_json", .blob)
t.column("package_lock_json", .blob)
}
try db.create(table: "installed_package", ifNotExists: true) { t in
t.primaryKey("id", .text).notNull()
t.column("packages_state_id", .text).notNull()
.references("project_packages_state", onDelete: .cascade)
t.column("name", .text).notNull()
t.column("version", .text).notNull()
t.column("global_alias", .text)
t.column("added_at", .datetime).notNull()
}
try db.create(table: "project_collaboration_state", ifNotExists: true) { t in
t.primaryKey("id", .text).notNull()
t.column("lab_id", .text)
}
try db.create(table: "custom_instrument_def", ifNotExists: true) { t in
t.primaryKey("id", .text).notNull()
t.column("name", .text).notNull()
t.column("icon", .text).notNull()
t.column("compatibility_json", .text).notNull().defaults(to: "{}")
t.column("entrypoint", .text).notNull()
t.column("features_json", .text).notNull().defaults(to: "[]")
t.column("widgets_json", .text).notNull().defaults(to: "[]")
t.column("created_at", .datetime).notNull()
t.column("updated_at", .datetime).notNull()
}
try db.create(table: "custom_instrument_file", ifNotExists: true) { t in
t.column("def_id", .text).notNull()
.references("custom_instrument_def", onDelete: .cascade)
t.column("path", .text).notNull()
t.column("content", .text).notNull()
t.primaryKey(["def_id", "path"])
}
try db.create(table: "custom_instrument_def_ui_state", ifNotExists: true) { t in
t.primaryKey("def_id", .text).notNull()
.references("custom_instrument_def", onDelete: .cascade)
t.column("sidebar_expansion", .text).notNull().defaults(to: SidebarExpansion.expanded.rawValue)
}
try db.create(table: "widget_state", ifNotExists: true) { t in
t.column("instance_id", .text).notNull()
t.column("widget_id", .text).notNull()
t.column("session_id", .text).notNull()
t.column("state_json", .text).notNull()
t.column("updated_at", .datetime).notNull()
t.primaryKey(["instance_id", "widget_id"])
}
try db.create(table: "custom_instrument_outbox", ifNotExists: true) { t in
t.primaryKey("op_id", .text).notNull()
t.column("kind", .text).notNull()
t.column("def_id", .text).notNull()
t.column("payload_json", .text).notNull()
t.column("created_at", .datetime).notNull()
}
try db.create(table: "project_ui_state", ifNotExists: true) { t in
t.primaryKey("id", .text).notNull()
t.column("selected_item_json", .text)
t.column("event_stream_collapsed", .boolean).notNull().defaults(to: true)
t.column("event_stream_bottom_height", .double).notNull().defaults(to: 0)
t.column("side_panel", .text)
t.column("pharo_snippets_json", .text)
t.column("pharo_page_width", .double)
t.column("pharo_page_maximized", .boolean).notNull().defaults(to: false)
}
try db.create(table: "target_picker_state", ifNotExists: true) { t in
t.primaryKey("id", .text).notNull()
t.column("last_selected_device_id", .text)
t.column("last_mode_raw", .text)
t.column("last_spawn_submode_raw", .text)
t.column("last_spawn_application_id", .text)
t.column("last_spawn_program_path", .text)
t.column("last_selected_process_name", .text)
}
try db.create(table: "mission", ifNotExists: true) { t in
t.primaryKey("id", .text).notNull()
t.column("editors", .blob).notNull()
t.column("created_at", .datetime).notNull()
t.column("updated_at", .datetime).notNull()
t.column("title", .text)
t.column("goal_text", .text).notNull()
t.column("pending_user_text", .text).notNull().defaults(to: "")
t.column("status", .text).notNull()
t.column("provider_id", .text).notNull()
t.column("model_id", .text).notNull()
t.column("system_prompt_hash", .text)
t.column("token_budget_input", .integer).notNull().defaults(to: 0)
t.column("token_budget_output", .integer).notNull().defaults(to: 0)
t.column("tokens_used_input", .integer).notNull().defaults(to: 0)
t.column("tokens_used_output", .integer).notNull().defaults(to: 0)
t.column("cache_read_tokens", .integer).notNull().defaults(to: 0)
t.column("cache_create_tokens", .integer).notNull().defaults(to: 0)
t.column("thinking_budget", .integer).notNull().defaults(to: 0)
t.column("reasoning_effort", .text)
t.column("temperature", .double)
}
try db.create(table: "mission_turn", ifNotExists: true) { t in
t.primaryKey("id", .text).notNull()
t.column("mission_id", .text).notNull()
.references("mission", onDelete: .cascade)
t.column("index", .integer).notNull()
t.column("created_at", .datetime).notNull()
t.column("role", .text).notNull()
t.column("content_json", .text).notNull()
t.column("model_id", .text)
t.column("stop_reason", .text)
t.column("input_tokens", .integer).notNull().defaults(to: 0)
t.column("output_tokens", .integer).notNull().defaults(to: 0)
t.column("cache_read_tokens", .integer).notNull().defaults(to: 0)
t.column("cache_create_tokens", .integer).notNull().defaults(to: 0)
}
try db.create(table: "mission_action", ifNotExists: true) { t in
t.primaryKey("id", .text).notNull()
t.column("mission_id", .text).notNull()
.references("mission", onDelete: .cascade)
t.column("turn_id", .text)
.references("mission_turn", onDelete: .setNull)
t.column("tool_name", .text).notNull()
t.column("args_json", .text).notNull()
t.column("status", .text).notNull()
t.column("is_observe", .boolean).notNull().defaults(to: false)
t.column("session_id", .text)
t.column("requested_at", .datetime).notNull()
t.column("decided_at", .datetime)
t.column("completed_at", .datetime)
t.column("result_json", .text)
t.column("result_summary", .text)
t.column("result_attachments_json", .text)
t.column("error", .text)
t.column("rationale", .text)
t.column("rejection_reason", .text)
t.column("tool_call_id", .text)
}
try db.create(table: "mission_finding", ifNotExists: true) { t in
t.primaryKey("id", .text).notNull()
t.column("mission_id", .text).notNull()
.references("mission", onDelete: .cascade)
t.column("created_at", .datetime).notNull()
t.column("title", .text).notNull()
t.column("body_markdown", .text).notNull()
t.column("confidence", .text).notNull()
t.column("kind", .text).notNull()
t.column("status", .text).notNull()
t.column("session_id", .text)
t.column("anchor_json", .text)
t.column("pinned_insight_id", .text)
}
try db.create(table: "mission_evidence", ifNotExists: true) { t in
t.primaryKey("id", .text).notNull()
t.column("finding_id", .text).notNull()
.references("mission_finding", onDelete: .cascade)
t.column("kind", .text).notNull()
t.column("ref_json", .text).notNull()
t.column("note", .text)
}
try db.create(table: "mission_outbox", ifNotExists: true) { t in
t.primaryKey("op_id", .text).notNull()
t.column("kind", .text).notNull()
t.column("mission_id", .text).notNull()
t.column("payload_json", .text).notNull()
t.column("created_at", .datetime).notNull()
}
try db.create(table: "address_note_outbox", ifNotExists: true) { t in
t.primaryKey("op_id", .text).notNull()
t.column("kind", .text).notNull()
t.column("note_id", .text).notNull()
t.column("payload_json", .text).notNull()
t.column("created_at", .datetime).notNull()
}
try addColumnsForExistingDatabases(db)
}
private static func addColumnsForExistingDatabases(_ db: Database) throws {
let javascript = REPLLanguage.javascript.rawValue
try addColumnIfMissing(db, table: "repl_cell", column: "language", definition: "TEXT NOT NULL DEFAULT '\(javascript)'")
try addColumnIfMissing(db, table: "repl_cell", column: "placements", definition: "BLOB")
try addColumnIfMissing(db, table: "address_insight", column: "placements", definition: "BLOB")
try addColumnIfMissing(db, table: "notebook_entry", column: "styled_details", definition: "BLOB")
try addColumnIfMissing(db, table: "notebook_entry", column: "pharo_snapshot", definition: "BLOB")
try addColumnIfMissing(db, table: "notebook_entry", column: "pharo_result_fuel", definition: "BLOB")
try addColumnIfMissing(db, table: "project_ui_state", column: "pharo_snippets_json", definition: "TEXT")
try addColumnIfMissing(db, table: "project_ui_state", column: "pharo_page_width", definition: "DOUBLE")
try addColumnIfMissing(db, table: "project_ui_state", column: "pharo_page_maximized", definition: "BOOLEAN NOT NULL DEFAULT 0")
let collapsed = SidebarExpansion.collapsed.rawValue
try addColumnIfMissing(db, table: "session_ui_state", column: "modules_expansion", definition: "TEXT NOT NULL DEFAULT '\(collapsed)'")
try addColumnIfMissing(db, table: "session_ui_state", column: "threads_expansion", definition: "TEXT NOT NULL DEFAULT '\(collapsed)'")
try addColumnIfMissing(db, table: "session_ui_state", column: "collapsed_hook_instruments", definition: "TEXT NOT NULL DEFAULT '[]'")
try addColumnIfMissing(db, table: "session_ui_state", column: "repl_language", definition: "TEXT NOT NULL DEFAULT '\(javascript)'")
try addColumnIfMissing(db, table: "session_ui_state", column: "repl_draft", definition: "TEXT")
try addColumnIfMissing(db, table: "session_ui_state", column: "repl_seek_anchor", definition: "TEXT")
}
private static func addColumnIfMissing(_ db: Database, table: String, column: String, definition: String) throws {
guard try !db.columns(in: table).contains(where: { $0.name == column }) else { return }
try db.execute(sql: "ALTER TABLE \(table) ADD COLUMN \(column) \(definition)")
}
}
private final class CommitNotifyingObserver: TransactionObserver {
let instanceID: UUID
init(instanceID: UUID) {
self.instanceID = instanceID
}
func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool { true }
func databaseDidChange(with event: DatabaseEvent) {}
func databaseDidCommit(_ db: Database) {
let id = instanceID
DispatchQueue.main.async {
NotificationCenter.default.post(
name: ProjectStore.didCommitNotification,
object: nil,
userInfo: ["instanceID": id]
)
}
}
func databaseDidRollback(_ db: Database) {}
}