forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathRewindDatabase.swift
More file actions
3630 lines (3217 loc) · 144 KB
/
Copy pathRewindDatabase.swift
File metadata and controls
3630 lines (3217 loc) · 144 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import Foundation
@preconcurrency import GRDB
import OmiSupport
import os
/// Actor-based database manager for Rewind screenshots
actor RewindDatabase {
static let shared = RewindDatabase()
private static let terminationStateLock = OSAllocatedUnfairLock<Bool>(initialState: false)
nonisolated static var isTerminationInProgress: Bool {
terminationStateLock.withLock { $0 }
}
private var dbQueue: DatabasePool?
/// Track if we recovered from corruption (for UI notification)
private(set) var didRecoverFromCorruption = false
/// Track initialization state to prevent concurrent init attempts
private var initializationTask: Task<Void, Error>?
/// Path to the running flag file (used to detect unclean shutdown)
private var runningFlagPath: String?
/// Whether the *previous* session ended uncleanly, latched at the first
/// observation in this process. `.omi_running` is created at the end of
/// `performInitialization()`, so the answer stops being observable once the
/// database opens — and any of the lazily-initializing storage actors can get
/// there first. That race is why `App Startup Timing` reported
/// `had_unclean_shutdown = true` on ~every sample.
private var uncleanShutdownVerdict: Bool?
/// The user ID this database is configured for (nil = not yet configured → "anonymous")
private var configuredUserId: String?
/// The user ID that was actually used to open the current database
private var openedForUserId: String?
/// Generation counter — incremented on close() so stale task completions don't corrupt state.
/// NOTE: `initialize()` captures this before `performInitialization()` and clears
/// `initializationTask` only if it is unchanged afterward, so it MUST NOT be bumped
/// during a normal open. Pool-swap detection uses the separate `poolEpoch` below.
private var initGeneration: Int = 0
/// Epoch of the current `dbQueue`, bumped on every close AND every pool (re)open.
/// Storage actors cache this alongside the pool and revalidate to detect a swap
/// (recovery replaces the pool) — kept distinct from `initGeneration` so bumping it
/// on open does not break the in-flight-close detection in `initialize()`.
private var poolEpoch: Int = 0
/// Lock-gated (`OSAllocatedUnfairLock`) so concurrent access from the actor,
/// `MainActor` (`AgentVMService`), and nonisolated shutdown is race-free (SCA-8).
private static let currentUserIdLock = OSAllocatedUnfairLock<String?>(initialState: nil)
static var currentUserId: String? {
get { currentUserIdLock.withLock { $0 } }
set { currentUserIdLock.withLock { $0 = newValue } }
}
/// Runtime error tracking: consecutive SQLITE_IOERR/CORRUPT errors during normal queries.
/// When this hits the threshold, we close the database so the next initialize() attempt
/// goes through the full recovery path (WAL cleanup, corruption detection, fresh DB).
private var consecutiveQueryIOErrors = 0
private let maxQueryIOErrorsBeforeRecovery = 5
// MARK: - Initialization
private init() {}
/// Whether the database has been successfully initialized
var isInitialized: Bool { dbQueue != nil }
/// Get the database pool for other storage actors
func getDatabaseQueue() -> DatabasePool? {
return dbQueue
}
/// Monotonic epoch of the current `dbQueue`. Bumped on every close and every
/// pool (re)open, so a storage actor that cached a pool can detect a swap
/// (corruption/maintenance recovery replaces the pool) and drop its stale
/// reference instead of reading/writing a closed or unlinked file.
func poolGeneration() -> Int {
return poolEpoch
}
/// Atomically read the current pool and its epoch together, so a caching
/// storage actor stores a consistent (pool, generation) pair.
func getDatabaseQueueWithGeneration() -> (pool: DatabasePool?, generation: Int) {
return (dbQueue, poolEpoch)
}
/// Report a query error from a storage actor or subsystem.
/// Tracks consecutive SQLITE_IOERR/CORRUPT errors. When the threshold is reached,
/// closes the database so the next initialize() call triggers recovery.
func reportQueryError(_ error: Error) {
guard dbQueue != nil else { return } // DB already closed, nothing to do
if isBusyDatabaseError(error) {
log(
"RewindDatabase: SQLITE_BUSY contention "
+ "(failure_class=db_lock_contention recovery_action=backoff recovery_result=degraded)")
DesktopDiagnosticsManager.shared.recordDbLockContention(source: "rewind_database")
return
}
guard isRecoverableDatabaseError(error) else { return }
consecutiveQueryIOErrors += 1
if consecutiveQueryIOErrors >= maxQueryIOErrorsBeforeRecovery {
logError(
"RewindDatabase: \(consecutiveQueryIOErrors) consecutive recoverable SQLite errors during queries, closing database for recovery"
)
close()
// Next getDatabaseQueue() returns nil → callers get databaseNotInitialized
// Next initialize() call will go through full recovery path
}
}
/// A sanitized SQLite corruption/I/O classifier. Avoid logging DB paths or row data.
private func isRecoverableDatabaseError(_ error: Error) -> Bool {
if let dbError = error as? DatabaseError {
if isBusyDatabaseError(error) { return false }
let code = dbError.resultCode
let extendedCode = dbError.extendedResultCode.rawValue
return code == .SQLITE_IOERR || code == .SQLITE_CORRUPT || extendedCode == 6922
}
// GRDB can bridge a SQLite failure through NSError before a storage actor
// reports it. NSError.code is meaningful only with its domain, so restrict
// this to known SQLite/GRDB domains to avoid rotating local storage when an
// unrelated POSIX or application error happens to share a numeric code.
let nsError = error as NSError
if isKnownSQLiteDomain(nsError.domain),
nsError.code == 10 || nsError.code == 11 || nsError.code == 6922
{
return true
}
let description = error.localizedDescription.lowercased()
return description.contains("sqlite error 10")
|| description.contains("sqlite error 11")
|| description.contains("sqlite error 6922")
}
private func isBusyDatabaseError(_ error: Error) -> Bool {
guard let dbError = error as? DatabaseError else { return false }
return dbError.resultCode == .SQLITE_BUSY
}
/// NSError domains that carry canonical SQLite result codes. GRDB bridges
/// SQLite errors through these before the storage actor reports a typed
/// `DatabaseError`; other domains may reuse the same numeric codes for
/// unrelated POSIX or application failures.
private func isKnownSQLiteDomain(_ domain: String) -> Bool {
domain == "GRDB"
|| domain == "GRDB.DatabaseError"
|| domain == "SQLite3"
|| domain == "NSSQLiteErrorDomain"
}
/// Handle corruption/I/O failures from cleanup and other maintenance operations.
/// Those paths can otherwise run repeatedly and emit the same Sentry error forever.
private func recoverFromMaintenanceError(_ error: Error, operation: String) async {
guard dbQueue != nil else { return }
guard isRecoverableDatabaseError(error) else { return }
let omiDir = userBaseDirectory()
let dbPath = omiDir.appendingPathComponent("omi.db").path
logError("RewindDatabase: recoverable SQLite error during \(operation); backing up and recreating local database")
// Close before file-level recovery so SQLite releases handles/WAL state.
close()
guard FileManager.default.fileExists(atPath: dbPath) else { return }
do {
try await handleCorruptedDatabase(at: dbPath, in: omiDir, triggerError: error)
try await initialize()
log("RewindDatabase: recovered and reopened database after \(operation)")
} catch {
// Keep this sanitized: operation name and SQLite/file recovery action only.
logError("RewindDatabase: recovery after \(operation) failed; next initialization will retry")
}
}
/// Report a successful query, resetting the runtime error counter.
func reportQuerySuccess() {
if consecutiveQueryIOErrors > 0 {
consecutiveQueryIOErrors = 0
}
}
/// Return true for SQLite failures that specifically implicate action_items_fts.
/// Keep this narrow so unrelated write/constraint failures are never hidden by an FTS repair retry.
func isActionItemsFTSError(_ error: Error) -> Bool {
guard let dbError = error as? DatabaseError else { return false }
let message = "\(dbError)".lowercased()
guard message.contains("action_items_fts") || message.contains("vtable constructor failed") else {
return false
}
return dbError.resultCode == .SQLITE_IOERR
|| dbError.resultCode == .SQLITE_CORRUPT
|| message.contains("no such table")
|| message.contains("malformed")
|| message.contains("database disk image is malformed")
|| dbError.extendedResultCode.rawValue == 6922
}
/// Rebuild only the action_items full-text-search table and triggers from durable action_items rows.
/// This intentionally never drops or rewrites action_items itself.
func repairActionItemsFTS(reason: String) async throws {
guard let queue = dbQueue else {
throw DatabaseError(resultCode: .SQLITE_MISUSE, message: "database is not initialized")
}
try await repairActionItemsFTS(in: queue, reason: reason)
}
/// Rebuild action_items_fts on a specific database queue. Callers that caught an FTS-trigger
/// write failure should pass the same queue they will retry on, avoiding stale queue races.
func repairActionItemsFTS(in queue: DatabasePool, reason: String) async throws {
try await queue.write { db in
try Self.recreateActionItemsFTS(in: db)
}
log("RewindDatabase: Rebuilt action_items_fts after \(reason)")
}
private static let actionItemsFTSShadowTables = [
"action_items_fts_data",
"action_items_fts_idx",
"action_items_fts_content",
"action_items_fts_docsize",
"action_items_fts_config",
]
private static func recreateActionItemsFTS(in db: Database) throws {
try dropActionItemsFTSIfPresent(in: db)
try installActionItemsFTS(in: db, populateExistingRows: true)
}
private static func installActionItemsFTS(in db: Database, populateExistingRows: Bool) throws {
try db.execute(
sql: """
CREATE VIRTUAL TABLE action_items_fts USING fts5(
description,
content='action_items',
content_rowid='id',
tokenize='unicode61'
)
""")
try db.execute(
sql: """
CREATE TRIGGER action_items_fts_ai AFTER INSERT ON action_items BEGIN
INSERT INTO action_items_fts(rowid, description)
VALUES (new.id, new.description);
END
""")
try db.execute(
sql: """
CREATE TRIGGER action_items_fts_ad AFTER DELETE ON action_items BEGIN
INSERT INTO action_items_fts(action_items_fts, rowid, description)
VALUES ('delete', old.id, old.description);
END
""")
try db.execute(
sql: """
CREATE TRIGGER action_items_fts_au AFTER UPDATE ON action_items BEGIN
INSERT INTO action_items_fts(action_items_fts, rowid, description)
VALUES ('delete', old.id, old.description);
INSERT INTO action_items_fts(rowid, description)
VALUES (new.id, new.description);
END
""")
guard populateExistingRows else { return }
try db.execute(
sql: """
INSERT INTO action_items_fts(rowid, description)
SELECT id, description FROM action_items
""")
}
private static func dropActionItemsFTSIfPresent(in db: Database) throws {
try db.execute(sql: "DROP TRIGGER IF EXISTS action_items_fts_ai")
try db.execute(sql: "DROP TRIGGER IF EXISTS action_items_fts_ad")
try db.execute(sql: "DROP TRIGGER IF EXISTS action_items_fts_au")
do {
try db.execute(sql: "DROP TABLE IF EXISTS action_items_fts")
} catch {
try dropActionItemsFTSShadowsIfPresent(in: db)
try db.execute(sql: "DROP TABLE IF EXISTS action_items_fts")
}
}
private static func dropActionItemsFTSShadowsIfPresent(in db: Database) throws {
for table in actionItemsFTSShadowTables {
try db.execute(sql: "DROP TABLE IF EXISTS \(table)")
}
}
/// Close the old effective owner's pool and configure the next owner.
///
/// Production calls this only from `RuntimeOwnerIdentity` while the
/// exclusive effective-owner transition reservation is held. Opening is
/// intentionally lazy, but after this method returns no caller can obtain a
/// pool for the previous owner.
func retargetEffectiveOwner(to userId: String?) {
let resolvedId = (userId?.isEmpty == false) ? userId! : "anonymous"
let targetChanged =
configuredUserId != resolvedId
|| (openedForUserId != nil && openedForUserId != resolvedId)
if targetChanged {
close()
}
configuredUserId = resolvedId
RewindDatabase.currentUserId = resolvedId
log("RewindDatabase: Configured for user \(resolvedId)")
}
/// Direct configuration remains available to storage-isolation tests and
/// legacy callers, but it now has the same close-before-retarget semantics
/// as the production effective-owner boundary.
func configure(userId: String?) {
retargetEffectiveOwner(to: userId)
}
/// Close the database, allowing re-initialization for a different user.
func close() {
if let runningFlagPath {
try? FileManager.default.removeItem(atPath: runningFlagPath)
}
if let dbQueue {
do {
try dbQueue.close()
} catch {
log("RewindDatabase: pool close reported an error; stale generation remains revoked")
}
}
dbQueue = nil
initializationTask = nil
runningFlagPath = nil
openedForUserId = nil
// The database identity is being torn down, so the latched verdict no longer
// describes anything. The next performInitialization() makes a fresh
// authoritative observation for whichever user it opens.
uncleanShutdownVerdict = nil
initGeneration += 1
poolEpoch += 1
log("RewindDatabase: Closed database (generation \(initGeneration), pool epoch \(poolEpoch))")
}
/// Switch to a different user's database.
func switchUser(to userId: String?) async throws {
close()
configure(userId: userId)
try await initialize()
}
/// Returns the per-user base directory: ~/Library/Application Support/Omi/users/{userId}/
/// Falls back to the static currentUserId (set synchronously at app start) when
/// configure() hasn't been called yet (e.g., TierManager triggers init early).
private func targetUserId() -> String {
if RewindDatabase.currentUserId == nil,
UserDefaults.standard.string(forKey: .authUserId) == nil
{
return "anonymous"
}
return configuredUserId ?? RewindDatabase.currentUserId ?? "anonymous"
}
private func userBaseDirectory(for userId: String? = nil) -> URL {
let userId = userId ?? targetUserId()
return DesktopLocalProfile.applicationSupportURL()
.appendingPathComponent("users", isDirectory: true)
.appendingPathComponent(userId, isDirectory: true)
}
/// Static version of userBaseDirectory for nonisolated markCleanShutdown
private static func staticUserBaseDirectory() -> URL {
let userId = currentUserId ?? "anonymous"
return DesktopLocalProfile.applicationSupportURL()
.appendingPathComponent("users", isDirectory: true)
.appendingPathComponent(userId, isDirectory: true)
}
/// Mark a clean shutdown by removing the running flag file.
/// Call from applicationWillTerminate to avoid unnecessary integrity checks on next launch.
/// This is nonisolated so it can be called synchronously from the main thread during termination.
nonisolated static func markCleanShutdown() {
terminationStateLock.withLock { $0 = true }
let userDir = staticUserBaseDirectory()
let flagPath = userDir.appendingPathComponent(".omi_running").path
try? FileManager.default.removeItem(atPath: flagPath)
log("RewindDatabase: Clean shutdown flagged")
}
/// Check if the previous session ended with an unclean shutdown (crash, force quit, etc.)
///
/// Order-independent: whoever observes first latches the verdict for the whole
/// process, and `performInitialization()` latches it before it writes this
/// session's own running flag. A later caller therefore reads the previous
/// session's state, not this one's.
func hadUncleanShutdown() -> Bool {
if let uncleanShutdownVerdict { return uncleanShutdownVerdict }
let flagPath = userBaseDirectory().appendingPathComponent(".omi_running").path
let verdict = FileManager.default.fileExists(atPath: flagPath)
uncleanShutdownVerdict = verdict
return verdict
}
/// Initialize the database with migrations.
/// If the DB is already open for the correct user, returns immediately.
/// If the DB is open for a different user (e.g., "anonymous" before configure was called),
/// closes it and reopens for the configured user.
func initialize() async throws {
let targetUser = targetUserId()
// Already initialized for the correct user
if dbQueue != nil && openedForUserId == targetUser {
return
}
// Initialized for wrong user — close and reopen
if dbQueue != nil {
log("RewindDatabase: Re-initializing for user \(targetUser) (was \(openedForUserId ?? "nil"))")
close()
}
// If initialization is in progress, wait for it then re-check
if let existingTask = initializationTask {
_ = try? await existingTask.value
guard targetUserId() == targetUser else { throw CancellationError() }
// After waiting, check if the result is for the right user
if dbQueue != nil && openedForUserId == targetUser {
return
}
// Wrong user or failed — close and proceed
if dbQueue != nil {
close()
}
}
// Start initialization
let myGeneration = initGeneration
let task = Task {
try await performInitialization(
expectedUserId: targetUser,
expectedGeneration: myGeneration)
}
initializationTask = task
do {
try await task.value
// Only clear if no close() happened since we started (generation unchanged)
if initGeneration == myGeneration {
initializationTask = nil
}
} catch {
if initGeneration == myGeneration {
initializationTask = nil
}
throw error
}
}
/// Actual initialization logic (called only once at a time)
private func performInitialization(
expectedUserId: String,
expectedGeneration: Int
) async throws {
guard dbQueue == nil else { return }
// Resolve the directory once. `retargetEffectiveOwner` may run while
// this method is suspended in GRDB/file I/O; a stale initializer must
// never drift to the next owner's path or publish its old pool later.
let omiDir = userBaseDirectory(for: expectedUserId)
// Create directory if needed (withIntermediateDirectories creates parents too)
try FileManager.default.createDirectory(at: omiDir, withIntermediateDirectories: true)
// Migrate data from legacy path if this is first launch with per-user paths.
// Remember an anonymous-directory source so context-bucket migration can
// fall back to the signed-out legacy defaults key after an early init.
let migratedLegacyOwnerID = migrateFromLegacyPathIfNeeded(to: omiDir)
let dbPath = omiDir.appendingPathComponent("omi.db").path
let flagPath = omiDir.appendingPathComponent(".omi_running").path
runningFlagPath = flagPath
log("RewindDatabase: Opening database at \(dbPath)")
// Detect unclean shutdown: if the running flag file exists, the previous launch
// didn't exit cleanly (crash, force quit, power loss)
let previousCrashed = FileManager.default.fileExists(atPath: flagPath)
// This is the authoritative, user-scoped observation and it happens before
// this session's flag is written below. Latch it here so a startup-timing
// reader that arrives after the database opened still reports the previous
// session, whatever order the storage actors initialized in.
if uncleanShutdownVerdict == nil {
uncleanShutdownVerdict = previousCrashed
}
if previousCrashed {
log("RewindDatabase: Unclean shutdown detected (running flag exists)")
}
// Clean up stale WAL files that can cause disk I/O errors (SQLite error 10)
if FileManager.default.fileExists(atPath: dbPath) {
cleanupStaleWALFiles(at: dbPath)
}
var config = Configuration()
config.prepareDatabase { db in
// Try to enable WAL mode for better crash resistance and performance
// WAL mode keeps writes in a separate file, making corruption much less likely
// If WAL fails (disk I/O error, permissions), continue with default journal mode
do {
try db.execute(sql: "PRAGMA journal_mode = WAL")
// synchronous = NORMAL is safe with WAL and much faster than FULL
try db.execute(sql: "PRAGMA synchronous = NORMAL")
// Auto-checkpoint every 1000 pages (~4MB) for WAL
try db.execute(sql: "PRAGMA wal_autocheckpoint = 1000")
} catch {
// WAL mode failed - log but continue with default journal mode
// This can happen with disk I/O errors, permission issues, or full disk
log("RewindDatabase: WAL mode unavailable (\(error.localizedDescription)), using default journal mode")
}
// Enable foreign keys (required)
try db.execute(sql: "PRAGMA foreign_keys = ON")
// Set busy timeout to avoid "database is locked" errors (5 seconds)
try db.execute(sql: "PRAGMA busy_timeout = 5000")
}
let queue: DatabasePool
do {
queue = try DatabasePool(path: dbPath, configuration: config)
} catch {
// If opening fails (e.g. disk I/O error on WAL), try once more without WAL files
log("RewindDatabase: Failed to open database: \(error), cleaning WAL and retrying...")
removeWALFiles(at: dbPath)
do {
queue = try DatabasePool(path: dbPath, configuration: config)
} catch let retryError {
// If still failing, check for database corruption:
// - SQLITE_CORRUPT (error 11): malformed database
// - SQLITE_IOERR_CORRUPTFS (extended code 6922): filesystem reports file
// corruption, commonly caused by migrating WAL files to a new path
let isCorrupted: Bool
if let dbError = retryError as? DatabaseError {
let isCorruptError = dbError.resultCode == .SQLITE_CORRUPT
let isCorruptFS = dbError.extendedResultCode.rawValue == 6922 // SQLITE_IOERR_CORRUPTFS
isCorrupted = isCorruptError || isCorruptFS
} else {
isCorrupted = "\(retryError)".contains("malformed")
}
if isCorrupted && FileManager.default.fileExists(atPath: dbPath) {
log("RewindDatabase: Database is corrupted (error: \(retryError)), attempting recovery...")
try await handleCorruptedDatabase(at: dbPath, in: omiDir, triggerError: retryError)
// Retry with recovered or fresh database
queue = try DatabasePool(path: dbPath, configuration: config)
} else {
throw retryError
}
}
}
// Post-open health check: verify we can actually run queries on the opened database.
// This catches cases where the DB opens successfully (PRAGMAs pass) but data queries
// fail with SQLITE_IOERR — e.g., stale WAL files from migration, page-level corruption.
var activeQueue = queue
do {
try await activeQueue.read { db in
_ = try Int.fetchOne(db, sql: "SELECT count(*) FROM sqlite_master")
}
} catch {
if let dbError = error as? DatabaseError,
dbError.resultCode == .SQLITE_IOERR || dbError.resultCode == .SQLITE_CORRUPT
{
log("RewindDatabase: Database opened but queries fail (\(error)), removing WAL and retrying...")
removeWALFiles(at: dbPath)
let retryQueue = try DatabasePool(path: dbPath, configuration: config)
try await retryQueue.read { db in
_ = try Int.fetchOne(db, sql: "SELECT count(*) FROM sqlite_master")
}
activeQueue = retryQueue
} else {
throw error
}
}
guard initGeneration == expectedGeneration,
targetUserId() == expectedUserId
else {
try? activeQueue.close()
throw CancellationError()
}
// Migrate BEFORE publishing the pool. `initialize()` treats
// `dbQueue != nil && openedForUserId == targetUser` as "already
// initialized", so publishing first and then throwing out of the schema
// ladder would latch a half-migrated schema in permanently: every later
// initialize() returns early and every caller is handed a pool whose
// tables do not match the code. Leaving both unset means the next
// initialize() retries the migration from the top.
do {
try migrate(
activeQueue,
ownerID: expectedUserId,
legacyOwnerFallback: migratedLegacyOwnerID)
} catch {
try? activeQueue.close()
throw error
}
dbQueue = activeQueue
// Bump the pool epoch on every (re)open so storage actors that cached the
// previous pool revalidate and drop it — recovery may have replaced the
// underlying omi.db file, leaving the old pool pointing at a stale inode.
// This is `poolEpoch`, NOT `initGeneration`: initialize() relies on
// initGeneration staying unchanged across a normal open to clear its
// initializationTask.
poolEpoch += 1
openedForUserId = expectedUserId
consecutiveQueryIOErrors = 0
// After unclean shutdown, do a cheap schema sanity check (not a full DB scan).
// PRAGMA quick_check scans the ENTIRE database regardless of the (N) argument
// (N only limits error reporting), so on large databases (e.g. 4+ GB) it can take 60-90s.
if previousCrashed {
log("RewindDatabase: Running lightweight integrity check after unclean shutdown...")
try verifyDatabaseIntegrity(activeQueue)
} else {
// Still log journal mode on clean startup (cheap PRAGMA, no full check)
try await activeQueue.read { db in
let journalMode = try String.fetchOne(db, sql: "PRAGMA journal_mode")
log("RewindDatabase: Journal mode is \(journalMode ?? "unknown")")
}
}
// Set running flag — will be cleared on clean shutdown
FileManager.default.createFile(atPath: flagPath, contents: nil)
log("RewindDatabase: Initialized successfully")
}
// MARK: - Legacy Migration
/// Migrate data from the legacy shared path (Omi/) or from the anonymous fallback
/// (Omi/users/anonymous/) to the per-user path (Omi/users/{userId}/).
/// Handles both first-time migration (DB move) and partial re-runs (directory merges).
/// Fold a directory's `omi.db-wal` into its `omi.db` so committed-but-
/// uncheckpointed writes survive the migration cleanup that deletes WAL/SHM.
/// Returns `true` when there is nothing to checkpoint or the checkpoint
/// succeeded, and `false` when a non-empty WAL exists but could not be
/// checkpointed — the caller MUST abort (not delete the WAL) in that case to
/// avoid a silent rollback / data loss.
private func checkpointWALBeforeMigration(in dir: URL, label: String, fileManager: FileManager) -> Bool {
let db = dir.appendingPathComponent("omi.db")
let wal = dir.appendingPathComponent("omi.db-wal")
guard fileManager.fileExists(atPath: db.path),
fileManager.fileExists(atPath: wal.path)
else {
return true // no WAL to fold in — nothing can be lost by the cleanup loop
}
do {
let pool = try DatabasePool(path: db.path, configuration: Configuration())
try pool.write { db in
try db.execute(sql: "PRAGMA wal_checkpoint(TRUNCATE)")
}
try pool.close()
log("RewindDatabase: Checkpointed WAL at \(label) before migration")
return true
} catch {
log(
"RewindDatabase: \(label) WAL checkpoint failed, aborting migration to avoid data loss: \(error.localizedDescription)"
)
return false
}
}
private func migrateFromLegacyPathIfNeeded(to userDir: URL) -> String? {
// The legacy `Omi` root is shared historical state. A named bundle that
// now has an identity-derived profile must never claim it: the first
// bundle to launch would otherwise move data belonging to Omi/Omi Dev
// or another old named bundle into its isolated root.
guard Self.shouldMigrateLegacyStorage(isolatedStorage: DesktopLocalProfile.usesIsolatedStorage) else {
return nil
}
let fileManager = FileManager.default
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let omiDir = appSupport.appendingPathComponent("Omi", isDirectory: true)
// Determine migration source: prefer legacy root (Omi/omi.db), fall back to anonymous dir.
// The anonymous fallback covers the case where TierManager or another early caller
// triggered initialize() before configure(userId:) was called, causing data to land
// in users/anonymous/ instead of the real user's directory.
let legacyDB = omiDir.appendingPathComponent("omi.db")
let anonymousDir =
omiDir
.appendingPathComponent("users", isDirectory: true)
.appendingPathComponent("anonymous", isDirectory: true)
let effectiveUserId = targetUserId()
let sourceDir: URL
if fileManager.fileExists(atPath: legacyDB.path) {
sourceDir = omiDir
} else if effectiveUserId != "anonymous",
fileManager.fileExists(atPath: anonymousDir.path)
{
// Check if anonymous dir has anything worth migrating (DB, Videos, Screenshots, backups)
let hasContent = ["omi.db", "Screenshots", "Videos", "backups"].contains {
fileManager.fileExists(atPath: anonymousDir.appendingPathComponent($0).path)
}
guard hasContent else { return nil }
sourceDir = anonymousDir
} else {
return nil // Nothing to migrate
}
// Don't migrate to ourselves
guard sourceDir.path != userDir.path else { return nil }
let migratedLegacyOwnerID = sourceDir.path == anonymousDir.path ? "signed-out" : nil
log("RewindDatabase: Migrating data from \(sourceDir.path) to \(userDir.path)")
// Items to migrate: omi.db, Screenshots/, Videos/, backups/
// IMPORTANT: Do NOT move omi.db-wal, omi.db-shm, or .omi_running:
// - WAL/SHM files are path-bound. Moving them to a new directory makes them
// invalid, causing SQLITE_IOERR_CORRUPTFS (error 6922) on the next open.
// SQLite will cleanly recover without stale WAL files.
// - .omi_running would falsely trigger unclean-shutdown recovery at the
// destination, running an expensive integrity check on the migrated DB.
let itemsToMove = [
"omi.db", "Screenshots", "Videos", "backups",
]
// Fold each directory's WAL into its omi.db BEFORE the cleanup loop below
// deletes the path-bound WAL/SHM. An unclean prior shutdown leaves a
// non-empty WAL holding committed-but-uncheckpointed transactions (a clean
// close checkpoints and removes it); deleting that WAL outright would roll
// the DB back to its last checkpoint and silently drop up to
// wal_autocheckpoint (1000 pages, ~4MB) of recent writes:
// - dest WAL: recent writes such as the knowledge graph saved during
// onboarding, before the app restart for permissions.
// - source WAL: the legacy data being migrated.
// If a checkpoint FAILS, those writes are still only in the WAL, so we must
// NOT proceed to delete it — abort the whole migration and leave source +
// dest intact. initialize() retries migration on the next launch, once the
// transient cause (locked DB, momentary IO error) has likely cleared.
guard checkpointWALBeforeMigration(in: userDir, label: "dest", fileManager: fileManager) else {
return nil
}
guard checkpointWALBeforeMigration(in: sourceDir, label: "source", fileManager: fileManager) else {
return nil
}
// Delete WAL/SHM and running flag at source AND destination — do NOT migrate them.
// Stale WAL/SHM at the destination (from a prior partial migration or crash) would
// also cause SQLITE_IOERR_CORRUPTFS when SQLite opens the migrated DB.
for staleFile in ["omi.db-wal", "omi.db-shm", ".omi_running"] {
for dir in [sourceDir, userDir] {
let path = dir.appendingPathComponent(staleFile)
if fileManager.fileExists(atPath: path.path) {
try? fileManager.removeItem(at: path)
let label = dir == sourceDir ? "source" : "dest"
log("RewindDatabase: Deleted \(staleFile) from \(label) (not migrating)")
}
}
}
for name in itemsToMove {
let source = sourceDir.appendingPathComponent(name)
let dest = userDir.appendingPathComponent(name)
guard fileManager.fileExists(atPath: source.path) else { continue }
var isDir: ObjCBool = false
fileManager.fileExists(atPath: source.path, isDirectory: &isDir)
do {
if isDir.boolValue && fileManager.fileExists(atPath: dest.path) {
// Both source and dest dirs exist — merge contents (move each child item)
let children = try fileManager.contentsOfDirectory(atPath: source.path)
var moved = 0
for child in children {
let childSrc = source.appendingPathComponent(child)
let childDst = dest.appendingPathComponent(child)
if fileManager.fileExists(atPath: childDst.path) { continue }
try fileManager.moveItem(at: childSrc, to: childDst)
moved += 1
}
// Remove source dir if now empty
let remaining = try? fileManager.contentsOfDirectory(atPath: source.path)
if remaining?.isEmpty == true {
try? fileManager.removeItem(at: source)
}
log("RewindDatabase: Merged \(name) (\(moved) items moved)")
} else if fileManager.fileExists(atPath: dest.path) {
// File already exists at dest — remove stale source copy
try? fileManager.removeItem(at: source)
log("RewindDatabase: Removed stale \(name) from source (already at dest)")
} else {
try fileManager.moveItem(at: source, to: dest)
log("RewindDatabase: Migrated \(name)")
}
} catch {
log("RewindDatabase: Failed to migrate \(name): \(error.localizedDescription)")
}
}
// Clean up source dir if it's now empty (don't leave empty anonymous/ dirs around)
if sourceDir != omiDir {
let remaining = try? fileManager.contentsOfDirectory(atPath: sourceDir.path)
if remaining?.isEmpty == true {
try? fileManager.removeItem(at: sourceDir)
log("RewindDatabase: Removed empty source dir \(sourceDir.lastPathComponent)")
}
}
log("RewindDatabase: Legacy migration complete")
return migratedLegacyOwnerID
}
static func shouldMigrateLegacyStorage(isolatedStorage: Bool) -> Bool {
!isolatedStorage
}
// MARK: - Corruption Detection & Recovery
/// Check if database file is corrupted using quick_check
/// Returns true if corrupted, false if OK
private func checkDatabaseCorruption(at path: String) async -> Bool {
// Open in read-write mode (NOT readonly) because WAL recovery requires write access.
// Opening readonly with a pending WAL file causes SQLITE_CANTOPEN (error 14),
// which is a false positive - the database isn't actually corrupted.
do {
let testQueue = try DatabaseQueue(path: path)
let result = try await testQueue.read { db -> String in
try String.fetchOne(db, sql: "PRAGMA quick_check(1)") ?? "ok"
}
return result.lowercased() != "ok"
} catch {
// If we can't even open the database, it's definitely corrupted
log("RewindDatabase: Database failed to open for integrity check: \(error)")
return true
}
}
/// Clean up stale WAL/SHM files that can cause disk I/O errors (SQLite error 10, code 3850)
/// This happens when the app crashes and leaves behind WAL files that are in a bad state
private func cleanupStaleWALFiles(at dbPath: String) {
let walPath = dbPath + "-wal"
let shmPath = dbPath + "-shm"
let fileManager = FileManager.default
// Only clean up if WAL file exists and is empty (indicates stale/orphaned WAL)
// Non-empty WAL files may contain uncommitted data we don't want to lose
if fileManager.fileExists(atPath: walPath),
let attrs = try? fileManager.attributesOfItem(atPath: walPath),
let size = attrs[.size] as? Int64, size == 0
{
try? fileManager.removeItem(atPath: walPath)
try? fileManager.removeItem(atPath: shmPath)
log("RewindDatabase: Cleaned up stale empty WAL/SHM files")
}
}
/// Force-remove WAL/SHM files (last resort when database won't open)
private func removeWALFiles(at dbPath: String) {
let fileManager = FileManager.default
for ext in ["-wal", "-shm"] {
let filePath = dbPath + ext
if fileManager.fileExists(atPath: filePath) {
try? fileManager.removeItem(atPath: filePath)
log("RewindDatabase: Removed \(ext) file for recovery")
}
}
}
/// Number of records recovered from corrupted database (0 if none)
private(set) var recoveredRecordCount: Int = 0
/// Handle corrupted database: attempt recovery, backup, and recreate
private func handleCorruptedDatabase(
at dbPath: String,
in omiDir: URL,
triggerError: Error? = nil
) async throws {
let fileManager = FileManager.default
// Create backup directory
let backupDir = omiDir.appendingPathComponent("backups", isDirectory: true)
try fileManager.createDirectory(at: backupDir, withIntermediateDirectories: true)
// Generate backup filename with timestamp
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd_HHmmss"
let timestamp = formatter.string(from: Date())
let backupPath = backupDir.appendingPathComponent("omi_corrupted_\(timestamp).db")
// Backup the corrupted database (for potential manual recovery)
log("RewindDatabase: Backing up corrupted database to \(backupPath.path)")
try fileManager.copyItem(atPath: dbPath, toPath: backupPath.path)
// Attempt to recover data from corrupted database
let recoveredPath = omiDir.appendingPathComponent("omi_recovered.db").path
let recoveredCount = await attemptDataRecovery(from: dbPath, to: recoveredPath)
recoveredRecordCount = recoveredCount
if recoveredCount > 0 {
log("RewindDatabase: Recovered \(recoveredCount) screenshot records from corrupted database")
// Use recovered database instead of creating fresh one
try fileManager.removeItem(atPath: dbPath)
try fileManager.moveItem(atPath: recoveredPath, toPath: dbPath)
// Remove WAL/SHM files from corrupted database
for ext in ["-wal", "-shm", "-journal"] {
let file = dbPath + ext
if fileManager.fileExists(atPath: file) {
try? fileManager.removeItem(atPath: file)
}
}
log("RewindDatabase: Using recovered database with \(recoveredCount) records")
} else {
// No data recovered, remove corrupted database and start fresh
log("RewindDatabase: No data could be recovered, creating fresh database")
// Clean up recovery attempt if it exists
if fileManager.fileExists(atPath: recoveredPath) {
try? fileManager.removeItem(atPath: recoveredPath)
}
// Remove corrupted database and associated WAL/SHM files
let filesToRemove = [
dbPath,
dbPath + "-wal",
dbPath + "-shm",
dbPath + "-journal",
]
for file in filesToRemove {
if fileManager.fileExists(atPath: file) {
try fileManager.removeItem(atPath: file)
log("RewindDatabase: Removed \(file)")
}
}
}
logError(
"RewindDatabase: Corrupted database backed up and removed. A fresh database will be created.",
context: StorageFailureDiagnostics.context(
pathClass: "rewind-db",
containingURL: omiDir,
databaseURL: URL(fileURLWithPath: dbPath),
error: triggerError,
appIsTerminating: Self.isTerminationInProgress))
// Clean up old backups (keep only last 5)
try await cleanupOldBackups(in: backupDir, keepCount: 5)
}
/// Attempt to recover data from a corrupted database using sqlite3 .recover
/// Returns the number of screenshot records recovered
private func attemptDataRecovery(from corruptedPath: String, to recoveredPath: String) async -> Int {
let fileManager = FileManager.default
// Remove any existing recovered database
if fileManager.fileExists(atPath: recoveredPath) {
try? fileManager.removeItem(atPath: recoveredPath)
}
// Run sqlite3 recovery in a detached task to avoid blocking the actor
// Process.waitUntilExit() is synchronous and would deadlock the actor
let (success, recoveredSQL) = await withCheckedContinuation {
(continuation: CheckedContinuation<(Bool, Data), Never>) in
Task.detached {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3")
process.arguments = [corruptedPath, ".recover"]
let outputPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = FileHandle.nullDevice
do {
try process.run()
process.waitUntilExit()
if process.terminationStatus == 0 {
let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
continuation.resume(returning: (true, data))
} else {
continuation.resume(returning: (false, Data()))
}
} catch {
continuation.resume(returning: (false, Data()))
}
}
}