A cohesive storage layer for SwiftUI apps with:
- GRDB-backed persistence (SQLite)
- Two-level caching (in-memory LRU + disk KV)
- Async/await with actors and Swift 6 concurrency checks
- A single, unified facade
StorageKitfor both easy and advanced starts
- iOS 18+
- Swift 6 (Complete Concurrency Checks)
- GRDB 7.6.1
- StorageCore — foundational pieces (Clock,
StorageConfigwith encoder/decoder factories,KeyBuilder,MemoryCache) - StorageGRDB — GRDB integration (
DatabaseActor,StorageContext,DiskCache,AppMigrations,ObservationBridge) - StorageRepo —
GenericRepository(read-through/write-through),QueryIndexStore - StorageKit — unified facade (this is what you import in your app)
Internally, GRDB-facing files use
@preconcurrency import GRDBandStorageContextis marked@unchecked Sendablebecause it stores GRDB types (DatabasePool) that are thread-safe but not annotatedSendablein public API. This keeps code sound under Swift 6 while preserving correctness.
import StorageKit
// 1) Define schema
var schema = AppMigrations()
schema.addKVCache() // creates "kv_cache" table + indexes if missing
schema.add(id: "2025-08-28_create_user_profiles", ifTableMissing: "user_profiles") { db in
try db.create(table: "user_profiles") { t in
t.column("id", .text).primaryKey()
t.column("name", .text).notNull()
t.column("email", .text).notNull()
t.column("updatedAt", .datetime).notNull()
}
try db.create(index: "idx_user_profiles_email", on: "user_profiles", columns: ["email"])
}
// 2) Start with defaults (Application Support; FK+WAL; sane TTL/disk quota)
let ctx = try StorageKit.start { $0 = schema }
// 3) Build a repository
typealias UserRepository = GenericRepository<UserProfileModel, UserProfileRecord>
let userRepo = ctx.makeRepository(UserProfileModel.self, record: UserProfileRecord.self)import StorageKit
let url = try StorageKit.defaultDatabaseURL(fileName: "custom.sqlite")
let ctx = try StorageKit.start(at: url, options: .init(
namespace: "myapp",
defaultTTL: 300,
diskQuotaBytes: 30 * 1024 * 1024,
pool: .init(
preset: .default, // ["PRAGMA foreign_keys = ON", "PRAGMA journal_mode = WAL"]
pragmasPlacement: .append, // or .prepend
configure: { cfg in cfg.maximumReaderCount = 4 }
)
)) { m in
m.addKVCache()
m.add(id: "2025-08-28_create_user_profiles", ifTableMissing: "user_profiles") { db in
try db.create(table: "user_profiles") { t in
t.column("id", .text).primaryKey()
t.column("name", .text).notNull()
t.column("email", .text).notNull()
t.column("updatedAt", .datetime).notNull()
}
}
}- No shared encoders/decoders:
StorageConfigprovidesmakeEncoder/makeDecoderfactories to avoid sharing non‑Sendableinstances across tasks. - GRDB annotation gap: all GRDB-facing files use
@preconcurrency import GRDBto interop cleanly with GRDB’s public types under Swift 6. - Context wrapper:
StorageContextis@unchecked Sendablewith a clear rationale (containsDatabasePoolwhich is thread-safe). - UI-safe observation: repository observation delivers values on MainActor via
DatabaseActor.streamOnMainActor(...)to avoid “call to main actor‑isolated instance” errors.
Task {
for await value in userRepo.observe(id: "u1") {
// Delivered on MainActor — safe for SwiftUI views
print("Name:", value?.name ?? "nil")
}
}If you need the raw, immediate stream (no main-actor hop), use DatabaseActor.streamImmediate directly from your custom code.
Get tries RAM → Disk → DB (and fills caches on the way).
Put writes to DB, then updates Disk and RAM (write-through).
// Get (local-first with TTL)
let profile = try await userRepo.get(id: "u1", policy: .localFirst(ttl: 300))
// Put (write-through)
if let p = profile {
try await userRepo.put(p)
}- Requires the
kv_cachetable. Add it once viaschema.addKVCache()before usingDiskCache. - TTL:
expiresAt = now + ttlifttl > 0(infinite otherwise). - Quota: every
settriggers pruning todiskQuotaBytes(expired/oldest first). - Manual cleanup:
await diskCache.pruneExpired()import StorageCore
let cache = MemoryCache<String, Data>(capacity: 500, defaultTTL: 300, clock: SystemClock())
await cache.set(Data([1,2,3]), for: "blob", ttl: nil)
let blob = await cache.get("blob")
await cache.removeAll()id— unique migration identifier; GRDB records it so each id runs exactly once. Do not rename/reuse a shipped id.ifTableMissing— guard for CREATE migrations. If the table already exists (prebuilt DBs, test fixtures, multi-module setups), the migration body is skipped instead of failing. Don’t use this forALTERmigrations that must always apply.
CREATE example (guarded):
m.add(id: "2025-09-01_create_events", ifTableMissing: "events") { db in
try db.create(table: "events") { t in
t.column("id", .text).primaryKey()
t.column("title", .text).notNull()
}
}ALTER example (unguarded):
m.add(id: "2025-09-15_add_isArchived_to_events") { db in
try db.alter(table: "events") { t in
t.add(column: "isArchived", .boolean).notNull().defaults(to: false)
}
}- “table already exists” on CREATE: add
ifTableMissing: "table_name"for that migration. - Indexes inside
create(table:): not supported; create withdb.create(index: ...)afterwards.
Q: Do I need to set PRAGMAs manually?
A: No. The facade provides pool presets (default = foreign_keys=ON, journal_mode=WAL). You can customize via PoolOptions.
Q: Can I use MemoryCache alone?
A: Yes. It’s an actor with get/set/remove/removeAll, LRU eviction, and TTL.
Q: Can I plug Swinject?
A: Yes; register StorageConfig, StorageContext/DatabaseActor, KeyBuilder, and your repositories. The facade does not impose a DI container.