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.
1865 lines
67 KiB
Swift
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) {}
|
|
}
|