August 15, 2026 · 11 min read
iOS App Group SQLite: One Database for App and Widget
The app, the widget, and the share extension each opened their own store. Moving them onto one App Group database, and what WAL mode and schema drift require once they share a file.
A user whose login session had expired opened the app. All of their records appeared to be gone.
The files were intact on disk. The app had opened a different file.
The widget could not see the app’s data
The widget and the shared extension were different processes from the app. Their sandboxes were different too.
The widget could not open the SQLite file in the app’s Application Support directory.
There were two options: the app could copy the data for them, or they could share the same directory.
I used an App Group. The app and its extensions viewed the same container.
// DatabaseLocation.swift:10-19
static public let appGroupId = "group.dev.example.justsend"
public static var containerRootURL: URL? {
FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: appGroupId
)
}
Both the store and attachments were located underneath it. The widget read the database directly.
The problem was with files that were already on users’ devices. They had to be moved.
The migration was a launch routine, not a migrator
Database migrations usually changed the schema. Changing a file’s location was different.
// DatabaseLocation.swift:5-8
/// GRDB 파일의 App Group 이전 — 위젯·Share Extension이 같은 DB를
/// 직독하기 위한 선행 조건. 마이그레이터가 아니라 기동 시 1회 파일 이동
/// 루틴이며, 어떤 실패도 legacy 경로 폴백으로 흡수한다.
The last sentence described the entire routine: if the move failed, I continued using the old path.
It was more important for the app to open than for the widget to work. The app still had to work normally even when the move failed.
// DatabaseLocation.swift:75-81
guard let containerURL else { return legacyURL }
let target = containerURL.appendingPathComponent(legacyURL.lastPathComponent)
guard target != legacyURL else { return target }
guard migrateDatabase(from: legacyURL, to: target, fileManager: fileManager) else {
return legacyURL // 실패하면 옛 경로
}
return target
If the entitlement was missing or the container could not be created, I used the old path. Only the widget failed to see the data.
Catalyst lied
I encountered one trap on Mac Catalyst.
// DatabaseLocation.swift:57-62
// Catalyst ad-hoc/debug launches can report a non-nil App Group URL before
// the system has provisioned the directory. Treat that as unavailable so
// the existing legacy Application Support store remains usable.
let usableContainer = container.flatMap { containerURL in
FileManager.default.fileExists(atPath: containerURL.path) ? containerURL : nil
}
When I requested the container URL, the system returned a value. The directory did not yet exist at that path.
The system provided the address without provisioning it. Moving the files to that address failed.
Checking only for nil was not enough. I also had to verify that the path actually existed.
The WAL files had to move with the database
When SQLite used WAL mode, there were three files.
| File | What it was |
|---|---|
db.sqlite |
Main database |
db.sqlite-wal |
Writes not yet applied to the main database |
db.sqlite-shm |
Shared-memory index |
Moving only the main file would lose recent writes that were still in the WAL.
// DatabaseLocation.swift:131-142
for suffix in ["-wal", "-shm"] {
let side = URL(fileURLWithPath: source.path + suffix)
let targetSide = URL(fileURLWithPath: destination.path + suffix)
guard fileManager.fileExists(atPath: side.path),
!fileManager.fileExists(atPath: targetSide.path) else { continue }
do {
try fileManager.moveItem(at: side, to: targetSide)
} catch {
// The main file is still usable and a later launch retries the
// missing sidecar; never fall back to an unscoped database.
}
}
The next launch completed the move even after an interruption
The app could terminate after moving the main file but before moving the WAL.
The function could be called again in that state. If the main file was already at the destination, it was left there. If only the WAL remained, only the WAL was moved.
I documented that it was idempotent.
The operation is idempotent so a process interrupted between the main file and sidecars can finish the migration on its next launch.
A sidecar move failure did not trigger fallback
The main file had already been moved. Falling back to the old path at that point would point to a location without the main file.
The comment in the catch block stated this explicitly.
never fall back to an unscoped database
If the main file was at the destination, I used the destination. The next launch retried the WAL move.
The fallback behavior differed by failure point. Within the same function, some failures returned to the old path and others did not.
| Where it failed | Fallback? | Why |
|---|---|---|
| Container URL was nil | Old path | The device could not use the App Group |
| Container directory did not exist | Old path | Catalyst had returned a false URL |
| Main file move failed | Old path | Nothing had been moved yet |
| Sidecar move failed | No | The main file was already at the destination |
| Data existed on both sides | No, set a flag | I could not tell which side was correct |
The distinction between the first three and the last two was simple: if the main file had not been moved, I backed out; after it had been moved, I did not. The safe fallback window was bounded.
When both sides contained data, I did nothing
If both the old and new paths contained data, the code selected neither one.
// DatabaseLocation.swift:96-101
if destinationExists && sourceExists {
let sourceHasRows = databaseHasRows(at: source, fileManager: fileManager)
let destinationHasRows = databaseHasRows(at: destination, fileManager: fileManager)
if sourceHasRows && destinationHasRows {
return false // 아무것도 하지 않습니다
}
The code could not know which side was correct. Overwriting one would permanently lose the other.
I therefore abandoned the move and used the old path. I also set a flag.
// DatabaseLocation.swift:12-13
/// Set during launch path resolution when both unscoped and account stores contain data.
public private(set) static var activePathConflict = false
When one side was empty, I could decide.
// DatabaseLocation.swift:102-115
if sourceHasRows && !destinationHasRows {
// 목적지가 빈 파일이면 지우고 옮깁니다
try fileManager.removeItem(at: destination)
for suffix in ["-wal", "-shm"] { /* 사이드카도 */ }
An empty database at the destination was common. The app might have opened once and created only the schema.
The criterion was whether the database contained rows, not whether the file existed.
The incident was caused by account scoping
There were separate database files for each account. The app had to decide which one to open.
At first, I checked the session in the keychain. I opened the file for the logged-in account. That seemed natural.
It broke during forced logout.
When a token expired or the server invalidated a session, the app cleared the session. On the next launch, the key used to open the account file was gone.
The app opened the unscoped file. It was empty.
The file was still intact on disk. To the user, all of their records appeared to have disappeared.
A user pointed this out on August 12, 2026.
Forced logout and user-initiated logout were different
The fix was to distinguish the two kinds of logout.
// DatabaseLocation.swift:48-51
public static func launchUserID(sessionUserID: String?) -> String? {
if let sessionUserID, !sessionUserID.isEmpty { return sessionUserID }
return sharedActiveUserID
}
When a session existed, I used it. Otherwise, I continued with the previous scope.
The previous scope was stored in the App Group’s UserDefaults. It lived independently of the session.
| Logout type | Session | Scope | Result |
|---|---|---|---|
| Forced (token expired) | Cleared | Kept | Continue seeing the same records |
| User initiated | Cleared | Cleared | Start clean |
During forced logout, I deliberately kept the scope. That contract was documented in the logout-handling code.
강제 로그아웃에서는 세션만 내린다… 사용자는 같은 기록을 계속 보고
("A forced logout drops the session only… the user keeps seeing the same records.")
The launch path honored that contract. Only a user-initiated logout cleared the scope.
Both places had to honor the same contract
The structure of the incident stood out to me.
The logout handling preserved the rule: “clear only the session and keep the records.” It was even documented in a comment.
The launch path did not know about that contract. It looked only at the session.
If a contract existed in only one place, another place could violate it. Both places had to read the same value.
The address was lost on another device
The attachments were stored in the user’s iCloud. The server stored only where they were.
A problem appeared when I synchronized on a new device.
I needed three values to retrieve the bytes.
| Value | What it was |
|---|---|
| record name | Which record in iCloud |
| nonce | Required for decryption |
| wrapped CK | Wrapped content key |
All three existed only in the server’s manifest. After synchronization completed, they were not left in the app.
The address was lost. Documents and recordings could not be opened on a new device.
I grouped the three values into one value type
I added one column through a migration. It held one value instead of three.
item_attachment.cloudBlobRef ← record name + nonce + wrapped CKThere was a reason. The three values arrived and were used together. If one was missing, none of them worked.
With three separate columns, it would have been possible to represent a state where only two existed. I would then have had to decide what to do in that state. There was no useful decision.
The first implementation stored only cloudRecordName. I discovered before committing that the other two values were also required for decryption and corrected it.
I determined that the wrapped key was safe to keep on the device
wrapped CK was the content key in wrapped form. I had to decide whether it was safe to store it on the device.
The server already held the same value. Keeping it on the device was equivalent in security terms to keeping it on the server.
The key used to unwrap it was elsewhere. It existed only in the keychain; neither the server nor the device database had it.
Recovery received the address first and the bytes later
Downloading all attachment bytes during synchronization on a new device took too long.
I received only the addresses. I downloaded the bytes when they were opened.
restoreAll → 주소만 남긴다 (바이트를 건드리지 않음)
materializeBytes → 열 때 그 하나만 받는다Photos were an exception
The list cards rendered photos as thumbnails. Without the bytes, they appeared blank.
When a user scrolled through a list containing blank areas, it was impossible to tell whether they were loading or had failed.
I therefore also downloaded photos during recovery.
I distinguished “not received” from “cannot be received”
When the address was missing, I returned a failure. The UI had to distinguish the two states.
| State | UI |
|---|---|
| Not received yet | Spinner (“Downloading”) |
| Cannot be received | Reason + retry |
There had been a regression because this wiring was missing. The viewer’s retry button only incremented the retry counter.
It reopened a file that did not exist. The path that downloaded the file was not connected.
The wording was already correct. It said “This has not been downloaded to this device yet” and “Retry.” The button did nothing.
Adding a schema column broke historical schema tests
Adding the new column caused an old migration test to fail.
The test created a table from the old version, inserted data, and checked whether the migration preserved it.
When inserting the data, it used today’s model. Today’s model contained the new column. The old table did not.
Another part of the same test already contained that lesson in a comment. It recorded that this had broken once before.
Only the attachment portion fell outside the rule. I had not applied a lesson learned once across the entire file.