diff --git a/Package.swift b/Package.swift index f14f07a3..579224fc 100644 --- a/Package.swift +++ b/Package.swift @@ -13,6 +13,7 @@ let package = Package( .library(name: "LogKit", targets: ["LogKit"]), .library(name: "LogViewerUI", targets: ["LogViewerUI"]), .library(name: "SwiftDataInspector", targets: ["SwiftDataInspector"]), + .library(name: "StorageKit", targets: ["StorageKit"]), .library(name: "WhereCore", targets: ["WhereCore"]), .library(name: "WhereUI", targets: ["WhereUI"]), .library(name: "WhereTesting", targets: ["WhereTesting"]), @@ -47,6 +48,10 @@ let package = Package( name: "SwiftDataInspector", path: "Shared/SwiftDataInspector/Sources", ), + .target( + name: "StorageKit", + path: "Shared/StorageKit/Sources", + ), .target( name: "WhereCore", dependencies: [ diff --git a/Project.swift b/Project.swift index 846a2c5d..0d72dc96 100644 --- a/Project.swift +++ b/Project.swift @@ -235,6 +235,12 @@ let project = Project( productDependency: "SwiftDataInspector", sources: ["Shared/SwiftDataInspector/Tests/**"], ), + unitTests( + name: "StorageKitTests", + bundleIdSuffix: "storagekit", + productDependency: "StorageKit", + sources: ["Shared/StorageKit/Tests/**"], + ), unitTests( name: "WhereCoreTests", bundleIdSuffix: "wherecore", @@ -269,6 +275,7 @@ let project = Project( testScheme(name: "LogKitTests"), testScheme(name: "LogViewerUITests"), testScheme(name: "SwiftDataInspectorTests"), + testScheme(name: "StorageKitTests"), testScheme(name: "WhereCoreTests"), testScheme(name: "WhereTests"), testScheme(name: "WhereUITests"), diff --git a/Shared/StorageKit/AGENTS.md b/Shared/StorageKit/AGENTS.md new file mode 100644 index 00000000..9351976d --- /dev/null +++ b/Shared/StorageKit/AGENTS.md @@ -0,0 +1,172 @@ +# StorageKit – Module Shape + +StorageKit is an app-agnostic library for **containerized, mode-aware storage**: a +`StorageSystem` vends a tree of `StorageContainer`s, each owning one directory and +vending raw files, a namespaced key-value store, and an isolated SwiftData +`ModelContainer`. One `mode` switch makes the whole tree persistent or in-memory, +and two registration-based, children-first teardown paths cleanly stop or destroy +any subtree. See [`README.md`](README.md) for the narrative and usage. + +This file complements the root [`AGENTS.md`](../../AGENTS.md), which owns build +system, formatting, and global conventions. Read that first. + +## Scope & dependencies + +- Pure **Foundation + SwiftData**. It must **not** import UIKit/SwiftUI, app code + (WhereCore/WhereUI), or `LifecycleKit` — a consumer that wants a teardown step + wraps a `deactivate()` / `deleteContainer()` call in its own `LifecycleStep`, + keeping the two decoupled. +- Library target only ([`Package.swift`](../../Package.swift), + `Shared/StorageKit/Sources`); the hosted test bundle `StorageKitTests` is wired + in [`Project.swift`](../../Project.swift) via the `unitTests` helper (host: + `StuffTestHost`). +- Not yet adopted by the Where app — this is a standalone module. Migrating + Where's ad-hoc storage (`LocationOutbox`, `WidgetSnapshotStore`, + `SwiftDataStore`, `WherePreferences`) onto it is a future pass. + +## Key types + +- [`StorageSystem`](Sources/StorageSystem.swift) – the **root**: an `actor` that + is pure configuration (`mode`, resolved namespace `url`, injected + `FileManager`) plus a vending point. It owns no storage logic of its own — it + creates its namespace directory and delegates `container` / `deactivate` / + `deleteAll` to a private root `StorageContainer`. +- [`StorageContainer`](Sources/StorageContainer.swift) – the **node**, and where + everything happens: an `actor` holding a child registry, cached vends, and + teardown registrations. Kept deliberately lean — lifecycle, children, teardown. + `key` / `url` / `mode` / `suiteName` are `nonisolated let`. Children are + `StorageContainer`s (recursive); the parent back-reference is `weak` so a + deleted subtree releases. +- **Storage namespaces** – each storage concern is a `public extension + StorageContainer` in its own file, exposing a `nonisolated` accessor that returns + a tiny `Sendable` facade: [`+Files`](Sources/StorageContainer+Files.swift) + (`container.files.url(_:)`), [`+KeyValue`](Sources/StorageContainer+KeyValue.swift) + (`container.keyValue.store()`), and + [`+SwiftData`](Sources/StorageContainer+SwiftData.swift) + (`container.swiftData.modelContainer(for:)`). Each facade holds only the + container and forwards into an actor-isolated `make…` method that lives in the + same file; the cache state it touches (`keyValueStoreCache` / + `modelContainerCache` / `CachedModelStore` / `activate` / + `requireLiveForKeyValueVend`) is `internal` on the actor for exactly this reason. +- [`StorageKey`](Sources/StorageKey.swift) – a sanitized single path component + (`Hashable`, `ExpressibleByStringLiteral`, `init(some RawRepresentable)`). +- [`BaseDirectory`](Sources/BaseDirectory.swift) – where a persistent system + roots (`applicationSupport` / `caches` / `documents` / `library` with optional + `subdirectory`, or `custom` for the exceptional cases — tests, relocations, an + App Group / security-scoped URL); `resolvedURL(using:)` is public so a `.custom` + base can be built from a standard one. +- [`StorageMode`](Sources/StorageMode.swift) / [`CloudKitOption`](Sources/CloudKitOption.swift) + / [`StorageError`](Sources/StorageError.swift) – the mode switch + (`.persistent(base:)` / `.inMemory`; the base rides inside `.persistent` so + on-disk storage can't be asked for without saying where, and is consumed only at + the system root), the CloudKit pass-through (`.none` / `.automatic`), and the + typed error (`containerDeleted` / `modelStoreSchemaMismatch`). +- [`KeyValueStore`](Sources/KeyValueStore.swift) – a minimal, `Sendable`-clean + protocol (typed `bool/int/double/string/data`, no `Any?`) with two conformers: + `UserDefaultsKeyValueStore` (`@unchecked Sendable` wrapper around a suite) and + the public `InMemoryKeyValueStore` (lock-guarded, typed slots). + +## Behaviors to preserve + +- **Root vs. node split.** Keep `StorageSystem` config-only; do not add storage + operations there. New per-node behavior goes on `StorageContainer`. +- **Vends are cached and idempotent.** `container(_:)`, `keyValue.store()`, and + `swiftData.modelContainer(for:)` return the same instance per node until the + cache is dropped by teardown. This is load-bearing for `ModelContainer` (never + two on one file) — don't make any vend construct a fresh instance each call. The + namespace accessors (`files` / `keyValue` / `swiftData`) themselves are cheap + `nonisolated` factories — a fresh facade struct each call is fine; only the + vended *store* / *container* is cached, on the actor. +- **Namespaces are facades over actor-held state, not a dynamic registry.** The + "opaque per-container storage keyed by type" idea fights actor isolation (a + separate extension object can't share the actor's isolation domain to touch its + caches synchronously), so each built-in namespace is a `Sendable` facade whose + `make…` backing is an actor-isolated method and whose caches live on the actor. + Add a new namespace the same way: a `nonisolated var` returning a facade that + composes the public primitives (child containers, `files`, teardown hooks); only + namespaces that need private cache slots (like the built-ins) live in-module. +- **In-memory does the right thing automatically.** `.inMemory` mode roots in a + temp directory, vends `InMemoryKeyValueStore`, and builds the SwiftData store + `isStoredInMemoryOnly` with **CloudKit forced off** and **no disk access** (it + skips vending the store child, so no directory is created). Callers use + identical code in both modes — keep that invariant when touching the vends. +- **Each SwiftData store gets its own child directory (persistent only).** + `swiftData.modelContainer(for:)` vends a dedicated child (`named`, default `"store"`) and + places the store at `/.store`, so the db + `-wal`/`-shm` + + external blobs are isolated and deletable as a unit. The store child shares the + `container(_:)` key namespace, so the default `"store"` collides with a + user-vended `container("store")` — documented; use distinct `named:` keys. A + store name pins one schema: re-vending the same name with different `types` + throws `StorageError.modelStoreSchemaMismatch` (cache keys on the type set). + Don't put a store directly in a shared directory. +- **Two teardown paths, never a flag.** `deactivate()` (reversible: run + `onDeactivate` + drop cached vends, keep files, go `inactive`, reactivate on + re-vend) and `deleteContainer()` (destructive). Don't collapse them into a + `tearDown(delete:)` boolean. +- **Deletion order and park-safety.** `deleteContainer()` runs children-first: + `prepareForDeletion` (resources live) → `deactivate` (`onDeactivate` + drop + vends) → remove directories **and** key-value suites + deregister → + `afterDeletion`. Everything before the remove must be reversible so a throw + parks with the subtree intact (retry-safe); only `afterDeletion` may run after + the point of no return, and a throw there retries just that step. Preserve this + ordering and the "nothing deleted on an early throw" guarantee. +- **State is one enum.** `State` is `active` / `inactive` / + `deleting(wasActive:)` / `deleted` (no loose flags); vending reactivates an + `inactive` node and throws/traps on a `deleting` or `deleted` one; + `deactivate()` is a no-op when already `inactive`. `deleting` is the in-flight + teardown state that makes the subtree reject vends so a race can't resurrect a + directory mid-delete. Its `wasActive` payload records the pre-freeze state so + `onDeactivate` fires exactly once (skipped when the node was already + `inactive`, since it ran during that `deactivate()`) and a pre-commit throw + reverts to the *exact* prior resting state (`active` or `inactive`); + committing advances it to `deleted`. Keep invalid states unrepresentable per + the root rules. +- **Errors surface.** Vending/teardown throw `StorageError` or rethrow the + underlying `FileManager`/SwiftData error — never swallow into an empty default. + The one deliberate exception is the **non-throwing `keyValue.store()`**, which + *traps* (not throws) when called on a `deleting`/`deleted` node (via the actor's + `requireLiveForKeyValueVend()`). This keeps reads terse; it's a genuine + programmer error (vending while/after a delete is a lifecycle bug), so don't make + callers `try`/`catch` it — preserve the non-throwing signature and the trap, and + route through the throwing `container(_:)` / `swiftData.modelContainer(for:)` + when a recoverable failure is wanted. +- **Key-value suites live outside the directory.** Because a `UserDefaults` suite + isn't under the container's directory, deletion must `removePersistentDomain` + per node (it does, in the purge phase). If you change suite naming, keep it + hierarchical, stable, and purged on delete. + +## Conventions + +- Follow the root rules: exhaustive `switch` over enums (no bare `default:`), + small named structs over tuples, typed keys not raw `String`s, `@_spi`/internal + for testing-only access, and "never silently swallow errors." +- `FileManager` is injected into `StorageSystem` (mirroring + `FileLocationOutbox.applicationSupport(fileManager:)`) for resolving the base + and creating the namespace directory; containers use `FileManager.default` for + their own I/O. Test determinism comes from `.persistent(base: .custom(tempDir))`, + not from threading a mock `FileManager` through every actor (which would fight + Sendable). +- Concurrency: both types are `actor`s; cross-actor reads use `nonisolated let`. + Teardown handlers are `@Sendable () async throws -> Void` (`TeardownHandler`). + +## Testing + +Tests live in [`Tests/`](Tests) (Swift Testing only, never XCTest), 1:1 with +sources, hosted in `StuffTestHost`. Shared fixtures (the `Note` `@Model`, a +`CallLog` actor, a `ThrowOnce` actor, `makeTemporaryDirectory()`) live in +[`StorageKitTestSupport.swift`](Tests/StorageKitTestSupport.swift). Patterns: + +- Always use `.persistent(base: .custom(makeTemporaryDirectory()))` (+ `defer` + cleanup) so a test never touches the host's real Application Support or Caches. +- Assert tree shape via the on-disk directories (`FileManager.fileExists`) and + identity via `===` on the vended actors / objects. +- Drive teardown ordering with a `CallLog` actor recording `phase:node` strings + and assert the exact children-first sequence. +- Drive park-safety with a `ThrowOnce` actor in a handler: assert the first call + throws and leaves data intact (or, for `afterDeletion`, deletes it), and the + retry completes. +- Read lifecycle transitions through `@testable import StorageKit` and the + internal `state` property; the `UserDefaultsKeyValueStore` wrapper is internal, + so its test also needs `@testable`. + +Consider these rules if they affect your changes. diff --git a/Shared/StorageKit/README.md b/Shared/StorageKit/README.md new file mode 100644 index 00000000..7d8ccd9c --- /dev/null +++ b/Shared/StorageKit/README.md @@ -0,0 +1,225 @@ +# StorageKit + +A small, app-agnostic library for **containerized, mode-aware storage**. You ask +a `StorageSystem` for a container ("give me a container for this user id"), ask +that container for sub-containers ("…and one for logs"), and each container vends +the things you actually store — raw files, a namespaced key-value store, and a +SwiftData `ModelContainer` — all rooted in one directory it owns. A single `mode` +switch makes the whole tree persistent or in-memory, and two registration-based +teardown paths let you cleanly stop or delete any subtree. + +StorageKit depends only on **Foundation + SwiftData** — no app code, no UI, and +not even `LifecycleKit` (wrap a teardown call in a `LifecycleStep` yourself if you +want one). + +## What you get + +- **A tree of containers.** A `StorageSystem` is the root (configuration + a + vending point); every `StorageContainer` below it owns one directory and can + vend child containers recursively. The on-disk layout mirrors the tree. +- **Storage lives on typed namespaces.** The container base type stays small; + what you store hangs off `container.files` (raw file URLs), `container.keyValue` + (a key-value store), and `container.swiftData` (isolated `ModelContainer`s), so + each concern is separable and you can add your own namespace by extension. +- **Mode-aware vends.** In `.persistent` mode you get real files, a + `UserDefaults` suite, and an on-disk SwiftData store; flip to `.inMemory` and + the same calls give you a temp directory, an in-memory key-value store, and an + `isStoredInMemoryOnly` SwiftData store with CloudKit forced off — **app and + model code never has to know**. +- **Isolated SwiftData stores.** `swiftData.modelContainer(for:)` puts each store + in its own child directory, so the `.store` file, its `-wal` / `-shm` sidecars, + and any external-storage blobs stay together and delete together. +- **Idempotent, cached vends.** Asking for the same child container, key-value + store, or model container twice returns the same instance (critical for + `ModelContainer`, which must be unique per file). +- **Two teardown paths, never a boolean.** `deactivate()` reversibly releases + handles but keeps data (account switch); `deleteContainer()` destroys a subtree + for good (delete account). Both are children-first and registration-based, and + deletion is **park-safe** (a failure before the point of no return leaves + everything intact for a retry). + +## Installation + +`StorageKit` is a local SPM library in this repo (`Shared/StorageKit`). Add it to +a target's dependencies in [`Package.swift`](../../Package.swift): + +```swift +.target(name: "YourCore", dependencies: [.target(name: "StorageKit")]) +``` + +## Quick start + +```swift +import StorageKit + +// One system per app (or per test). Persistent in production… +let storage = try StorageSystem("Where", mode: .persistent(base: .applicationSupport())) +// …or ephemeral in tests/previews — nothing below changes: +// let storage = try StorageSystem("Where", mode: .inMemory) + +let user = try await storage.container("user-1") // a directory per user +let logs = try await user.container("logs") // a subdirectory + +// Raw file +let url = logs.files.url("today.json") +try Data(...).write(to: url) + +// Key-value (a UserDefaults suite, or in-memory) +let prefs = await user.keyValue.store() +prefs.set(true, forKey: "onboarded") + +// SwiftData (its store isolated in its own child directory) +let container = try await user.swiftData.modelContainer(for: [Note.self]) +``` + +### Tearing down + +```swift +// Switching accounts: release handles, keep everything on disk. +try await user.deactivate() // re-vending later reactivates + +// Deleting an account: destroy this user's whole subtree. +try await user.deleteContainer() // siblings untouched + +// Wipe the whole system (and its namespace directory). +try await storage.deleteAll() +``` + +## Public API + +```swift +public enum StorageMode { case persistent(base: BaseDirectory), inMemory } + +public struct BaseDirectory { + public static func applicationSupport(subdirectory: String? = nil) -> BaseDirectory + public static func caches(subdirectory: String? = nil) -> BaseDirectory + public static func documents(subdirectory: String? = nil) -> BaseDirectory + public static func library(subdirectory: String? = nil) -> BaseDirectory + public static func custom(_ url: URL) -> BaseDirectory // tests, relocations, App Group / security-scoped URLs + public func resolvedURL(using: FileManager = .default) throws -> URL +} + +public struct StorageKey: Hashable, Sendable, ExpressibleByStringLiteral { + public init(_ raw: String) + public init(_ key: some RawRepresentable) + public let name: String +} + +public actor StorageSystem { + public init(_ name: StorageKey, mode: StorageMode, + fileManager: FileManager = .default) throws + public nonisolated let mode: StorageMode + public nonisolated let url: URL + public func container(_ key: StorageKey) async throws -> StorageContainer + public func deactivate() async throws + public func deleteAll() async throws +} + +public actor StorageContainer { + public nonisolated let key: StorageKey + public nonisolated let url: URL + public nonisolated let mode: StorageMode + + public func container(_ key: StorageKey) async throws -> StorageContainer + public func container(path keys: [StorageKey]) async throws -> StorageContainer + + // Storage namespaces (see below) + public nonisolated var files: FileStorage + public nonisolated var keyValue: KeyValueStorage + public nonisolated var swiftData: SwiftDataStorage + + @discardableResult public func onDeactivate(_ handler: @escaping TeardownHandler) async -> Token + @discardableResult public func prepareForDeletion(_ handler: @escaping TeardownHandler) async -> Token + @discardableResult public func afterDeletion(_ handler: @escaping TeardownHandler) async -> Token + public func deregister(_ token: Token) async + + public func deactivate() async throws + public func deleteContainer() async throws + public func deleteContents() async throws +} + +// Storage namespaces — tiny Sendable facades vended by the accessors above. +public struct FileStorage: Sendable { + public func url(_ name: String) -> URL +} +public struct KeyValueStorage: Sendable { + public func store() async -> any KeyValueStore +} +public struct SwiftDataStorage: Sendable { + public func modelContainer(for types: [any PersistentModel.Type], + named: StorageKey = "store", + cloudKit: CloudKitOption = .none) async throws -> ModelContainer +} + +public protocol KeyValueStore: AnyObject, Sendable { /* typed bool/int/double/string/data */ } +public final class InMemoryKeyValueStore: KeyValueStore { public init() } +``` + +## How it works + +- **Root vs. node.** `StorageSystem` is pure configuration (the `mode` — which + carries the base directory in `.persistent` — plus an injected `FileManager`) + and a vending point; it does no storage work beyond + creating its namespace directory and delegating to a hidden root container. All + operations live on `StorageContainer`. The split costs one extra directory level + on disk, which is invisible to users. +- **Where things live.** Persistent systems root at `/`; in-memory + systems root at a unique temporary directory removed by `deleteAll()`. A + container's `url` is `/`. A key-value store is a `UserDefaults` + suite named hierarchically (e.g. `Where.user-1.logs`) — those live in the app's + preferences, not the container directory, so deletion clears them explicitly. +- **Teardown order.** `deactivate()` runs `onDeactivate` handlers and drops cached + vends across the subtree, children-first, leaving files in place; + `deleteContainer()` runs, children-first: every `prepareForDeletion` handler + (resources still live) → every `onDeactivate` handler (+ drop vends) → delete + directories and key-value suites + deregister → every `afterDeletion` handler. + Everything before the delete is reversible, so a throw there parks the operation + with the subtree fully intact and a retry is safe; an `afterDeletion` throw is + post-commit (the data is already gone, so it retries only that step — keep those + handlers idempotent). + +## Contracts & limitations + +- **Vends are cached and idempotent**; the same key always returns the same + instance until the node is deactivated (drops the cache) or deleted. +- **`StorageKey` sanitizes, it doesn't validate.** Path separators, `:`, and + control characters become `_`, and empty / `.` / `..` get a leading `_`, so a + key can't escape its directory — but two different raw strings can collapse to + the same name, so keep keys distinct (prefer typed enums). +- **Errors surface.** Vending and teardown throw on real failures; nothing is + swallowed into a benign default. Using a deleted container throws + `StorageError.containerDeleted`. +- **`keyValue.store()` is intentionally non-throwing — don't `try`/`catch` it.** + So reads stay terse (`store.bool(forKey:)`), it traps (rather than throws) when + called on a container that's being deleted or already deleted. That's a + *programmer error*, not a runtime condition to defend against on every access: + hold the store through a live handle, and release it when you tear the container + down (e.g. in an `onDeactivate` handler). The only way to hit the trap is to + vend while a concurrent delete is in flight — a lifecycle bug to fix at the call + site. If you need a throwing failure mode, use `container(_:)` / + `swiftData.modelContainer(for:)` instead. +- **`files.url(_:)` is pure path construction.** It doesn't check the node's state + or create the directory: on an `inactive` node the directory is absent until the + next vend, and on a `deleted` one it's gone, so the URL points at a missing + directory. Use it only while the container is live. +- **A model store is a child container named `named` (default `"store"`).** In + `.persistent` mode it lives in the same key namespace as `container(_:)`, so + don't also vend a plain child under that key — `container("store")` and the + default model store would share one directory. Pass a distinct `named:` for + each store, and avoid colliding child keys. Re-vending a store under the same + name with a different `types` set throws `StorageError.modelStoreSchemaMismatch`. +- **CloudKit is a pass-through** (`.none` / `.automatic`), and is forced off in + `.inMemory` mode. Richer CloudKit configuration is out of scope. +- **`deleteAll()` spends the system** — its namespace directory is gone + afterwards; build a new `StorageSystem` to start over. + +## Testing + +Exercised with Swift Testing in a hosted bundle. Tests use +`.persistent(base: .custom(temp))` so they never touch the real Application +Support directory, and cover the tree, +both modes, cached/idempotent vends, key sanitization, key-value and SwiftData +vends, and — in detail — the two teardown paths: children-first ordering, the +`prepareForDeletion → onDeactivate → delete → afterDeletion` sequence, park-safe +retries on a thrown handler, and that deletion errors surface while leaving data +in place. diff --git a/Shared/StorageKit/Sources/BaseDirectory.swift b/Shared/StorageKit/Sources/BaseDirectory.swift new file mode 100644 index 00000000..93775d2f --- /dev/null +++ b/Shared/StorageKit/Sources/BaseDirectory.swift @@ -0,0 +1,95 @@ +import Foundation + +/// Where a `.persistent` `StorageSystem` is rooted on disk. +/// +/// Pick the standard search-path directory that fits the data: `applicationSupport` +/// (the default home for app-managed data), `caches` (purgeable), `documents` +/// (user-facing, backed up and visible in the Files app when the app opts in), or +/// `library`. `subdirectory` (optional) namespaces under the chosen directory so a +/// system isn't created at the very top of it. `custom(_:)` is the escape hatch for +/// the exceptional cases the standard directories don't cover — tests, relocations, +/// an App Group / security-scoped URL — and `resolvedURL(using:)` is public so a +/// caller can resolve a standard directory and build a `.custom` base from it. +/// +/// Ignored entirely in `.inMemory` mode — there the system roots itself in a +/// temporary directory it owns and removes on `deleteAll()`. +public struct BaseDirectory: Sendable { + private enum Root { + case applicationSupport + case caches + case documents + case library + case custom(URL) + } + + private let root: Root + private let subdirectory: String? + + /// The app's Application Support directory, optionally namespaced by + /// `subdirectory`. The default home for data your app creates and manages. + public static func applicationSupport(subdirectory: String? = nil) -> BaseDirectory { + BaseDirectory(root: .applicationSupport, subdirectory: subdirectory) + } + + /// The app's Caches directory, optionally namespaced by `subdirectory`. The OS + /// may purge it under storage pressure, so only put regenerable data here. + public static func caches(subdirectory: String? = nil) -> BaseDirectory { + BaseDirectory(root: .caches, subdirectory: subdirectory) + } + + /// The app's Documents directory, optionally namespaced by `subdirectory`. + /// User-facing: it's included in backups and shows up in the Files app when the + /// app opts in (`UISupportsDocumentBrowser` / `LSSupportsOpeningDocumentsInPlace`). + public static func documents(subdirectory: String? = nil) -> BaseDirectory { + BaseDirectory(root: .documents, subdirectory: subdirectory) + } + + /// The app's Library directory (the parent of Application Support and Caches), + /// optionally namespaced by `subdirectory`. Prefer `applicationSupport` / + /// `caches` unless you specifically need to root beside them. + public static func library(subdirectory: String? = nil) -> BaseDirectory { + BaseDirectory(root: .library, subdirectory: subdirectory) + } + + /// An explicit directory URL for the exceptional cases the standard directories + /// don't cover (tests, relocations, an App Group / security-scoped URL). Build + /// one from a resolved standard directory via `resolvedURL(using:)`. + public static func custom(_ url: URL) -> BaseDirectory { + BaseDirectory(root: .custom(url), subdirectory: nil) + } + + /// Resolve to a concrete directory URL, creating the standard search-path + /// directory if needed. + /// + /// The optional `subdirectory` is appended but not created here — the + /// `StorageSystem` creates its namespace directory (and any intermediates) + /// underneath it. + public func resolvedURL(using fileManager: FileManager = .default) throws -> URL { + let base: URL = switch root { + case .applicationSupport: + try standardURL(.applicationSupportDirectory, using: fileManager) + case .caches: + try standardURL(.cachesDirectory, using: fileManager) + case .documents: + try standardURL(.documentDirectory, using: fileManager) + case .library: + try standardURL(.libraryDirectory, using: fileManager) + case let .custom(url): + url + } + guard let subdirectory, !subdirectory.isEmpty else { return base } + return base.appending(path: subdirectory, directoryHint: .isDirectory) + } + + private func standardURL( + _ directory: FileManager.SearchPathDirectory, + using fileManager: FileManager, + ) throws -> URL { + try fileManager.url( + for: directory, + in: .userDomainMask, + appropriateFor: nil, + create: true, + ) + } +} diff --git a/Shared/StorageKit/Sources/CloudKitOption.swift b/Shared/StorageKit/Sources/CloudKitOption.swift new file mode 100644 index 00000000..ec2f23b6 --- /dev/null +++ b/Shared/StorageKit/Sources/CloudKitOption.swift @@ -0,0 +1,19 @@ +import SwiftData + +/// Whether a vended SwiftData store syncs through CloudKit. A thin pass-through +/// to `ModelConfiguration.CloudKitDatabase`; forced to `.none` in `.inMemory` +/// mode (an in-memory store can't sync). Richer CloudKit configuration is out of +/// scope for now. +public enum CloudKitOption: Sendable { + /// Local only — no CloudKit sync. + case none + /// Sync through the automatic (default) CloudKit container. + case automatic + + var cloudKitDatabase: ModelConfiguration.CloudKitDatabase { + switch self { + case .none: .none + case .automatic: .automatic + } + } +} diff --git a/Shared/StorageKit/Sources/KeyValueStore.swift b/Shared/StorageKit/Sources/KeyValueStore.swift new file mode 100644 index 00000000..b97b3cf1 --- /dev/null +++ b/Shared/StorageKit/Sources/KeyValueStore.swift @@ -0,0 +1,164 @@ +import Foundation + +/// A minimal, `Sendable`-clean key-value store: typed accessors only (no +/// `Any?`), so a value can't silently fail to round-trip the way an arbitrary +/// `set(_: Any?)` can. `StorageContainer.keyValue.store()` vends a namespaced one +/// per node — a real `UserDefaults` suite in `.persistent` mode, an in-memory +/// dictionary in `.inMemory` mode — behind this same protocol, so app code +/// doesn't know or care which it got. +/// +/// Reads of an absent key mirror `UserDefaults`: `bool` → `false`, +/// `integer`/`double` → `0`, `string`/`data` → `nil`. +public protocol KeyValueStore: AnyObject, Sendable { + func bool(forKey key: String) -> Bool + func integer(forKey key: String) -> Int + func double(forKey key: String) -> Double + func string(forKey key: String) -> String? + func data(forKey key: String) -> Data? + + func set(_ value: Bool, forKey key: String) + func set(_ value: Int, forKey key: String) + func set(_ value: Double, forKey key: String) + func set(_ value: String?, forKey key: String) + func set(_ value: Data?, forKey key: String) + + func removeObject(forKey key: String) +} + +/// `KeyValueStore` backed by a `UserDefaults` suite. `UserDefaults` is internally +/// thread-safe, so wrapping it as `@unchecked Sendable` is sound. Setting a `nil` +/// `String`/`Data` removes the key, matching the in-memory store. +final class UserDefaultsKeyValueStore: KeyValueStore, @unchecked Sendable { + private let defaults: UserDefaults + + init(_ defaults: UserDefaults) { + self.defaults = defaults + } + + func bool(forKey key: String) -> Bool { + defaults.bool(forKey: key) + } + + func integer(forKey key: String) -> Int { + defaults.integer(forKey: key) + } + + func double(forKey key: String) -> Double { + defaults.double(forKey: key) + } + + func string(forKey key: String) -> String? { + defaults.string(forKey: key) + } + + func data(forKey key: String) -> Data? { + defaults.data(forKey: key) + } + + func set(_ value: Bool, forKey key: String) { + defaults.set(value, forKey: key) + } + + func set(_ value: Int, forKey key: String) { + defaults.set(value, forKey: key) + } + + func set(_ value: Double, forKey key: String) { + defaults.set(value, forKey: key) + } + + func set(_ value: String?, forKey key: String) { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + + func set(_ value: Data?, forKey key: String) { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + + func removeObject(forKey key: String) { + defaults.removeObject(forKey: key) + } +} + +/// In-memory `KeyValueStore` used in `.inMemory` mode (and handy for tests). +/// Lock-guarded so it is safely `Sendable`, with one typed slot per key so a +/// value read back as the wrong type reads as absent rather than crashing. +public final class InMemoryKeyValueStore: KeyValueStore, @unchecked Sendable { + private enum Value { + case bool(Bool) + case integer(Int) + case double(Double) + case string(String) + case data(Data) + } + + private let lock = NSLock() + private var storage: [String: Value] = [:] + + public init() {} + + private func value(forKey key: String) -> Value? { + lock.withLock { storage[key] } + } + + private func store(_ value: Value?, forKey key: String) { + lock.withLock { storage[key] = value } + } + + public func bool(forKey key: String) -> Bool { + if case let .bool(value) = value(forKey: key) { return value } + return false + } + + public func integer(forKey key: String) -> Int { + if case let .integer(value) = value(forKey: key) { return value } + return 0 + } + + public func double(forKey key: String) -> Double { + if case let .double(value) = value(forKey: key) { return value } + return 0 + } + + public func string(forKey key: String) -> String? { + if case let .string(value) = value(forKey: key) { return value } + return nil + } + + public func data(forKey key: String) -> Data? { + if case let .data(value) = value(forKey: key) { return value } + return nil + } + + public func set(_ value: Bool, forKey key: String) { + store(.bool(value), forKey: key) + } + + public func set(_ value: Int, forKey key: String) { + store(.integer(value), forKey: key) + } + + public func set(_ value: Double, forKey key: String) { + store(.double(value), forKey: key) + } + + public func set(_ value: String?, forKey key: String) { + store(value.map(Value.string), forKey: key) + } + + public func set(_ value: Data?, forKey key: String) { + store(value.map(Value.data), forKey: key) + } + + public func removeObject(forKey key: String) { + store(nil, forKey: key) + } +} diff --git a/Shared/StorageKit/Sources/StorageContainer+Files.swift b/Shared/StorageKit/Sources/StorageContainer+Files.swift new file mode 100644 index 00000000..2c48edf2 --- /dev/null +++ b/Shared/StorageKit/Sources/StorageContainer+Files.swift @@ -0,0 +1,30 @@ +import Foundation + +extension StorageContainer { + /// The raw-file namespace for this container: URLs for files kept directly in + /// the container's own directory. See ``FileStorage``. + public nonisolated var files: FileStorage { + FileStorage(container: self) + } +} + +/// The `files` namespace of a ``StorageContainer`` — a tiny `Sendable` facade for +/// raw files stored directly in the container's directory. Vend it with +/// `container.files`. +public struct FileStorage: Sendable { + let container: StorageContainer + + /// A URL for a raw file named `name` directly inside the container's directory. + /// The directory already exists for a live container; writing the file is the + /// caller's job. + /// + /// - Warning: This is pure path construction — it can't read the node's state, + /// so it neither checks liveness nor ensures the directory exists. On an + /// `inactive` node the directory is absent until the next vend reactivates + /// it, and on a `deleted` one it's gone for good; in both cases the returned + /// URL points into a missing directory and writes will fail. Only use the URL + /// while the container is live. + public func url(_ name: String) -> URL { + container.url.appending(path: name, directoryHint: .notDirectory) + } +} diff --git a/Shared/StorageKit/Sources/StorageContainer+KeyValue.swift b/Shared/StorageKit/Sources/StorageContainer+KeyValue.swift new file mode 100644 index 00000000..3f371636 --- /dev/null +++ b/Shared/StorageKit/Sources/StorageContainer+KeyValue.swift @@ -0,0 +1,62 @@ +import Foundation + +extension StorageContainer { + /// The key-value namespace for this container: a namespaced `UserDefaults` + /// suite (`.persistent`) or an in-memory store (`.inMemory`). See + /// ``KeyValueStorage``. + public nonisolated var keyValue: KeyValueStorage { + KeyValueStorage(container: self) + } +} + +/// The `keyValue` namespace of a ``StorageContainer`` — a tiny `Sendable` facade +/// vending the node's namespaced key-value store. Vend it with +/// `container.keyValue`. +public struct KeyValueStorage: Sendable { + let container: StorageContainer + + /// Vend this node's namespaced key-value store: a `UserDefaults` suite in + /// `.persistent` mode, an in-memory store in `.inMemory` mode. Cached per node + /// (repeated calls return the same instance) and dropped by `deactivate()` / + /// `deleteContainer()`. + /// + /// - Important: This is deliberately **non-throwing** so the common case reads + /// like `store.bool(forKey:)` without a `try`. The price is that calling it + /// on a container that is being deleted or is already deleted is a + /// **programmer error and traps** — don't wrap every access in + /// `try`/`catch`. Reach for the store through a live handle and stop using it + /// once you've torn its container down (e.g. release it in an `onDeactivate` + /// handler); a vend racing a concurrent delete is the one case where the + /// trap can fire, and that's a lifecycle bug to fix at the call site, not to + /// defend against on each call. Use `container(_:)` / + /// `swiftData.modelContainer(for:)` instead if you need a throwing failure + /// mode. + public func store() async -> any KeyValueStore { + await container.makeKeyValueStore() + } +} + +extension StorageContainer { + /// Actor-isolated backing for `keyValue.store()`. Lives here beside its facade + /// so the key-value concern stays out of the core `StorageContainer` file. + func makeKeyValueStore() -> any KeyValueStore { + requireLiveForKeyValueVend() + if let cached = keyValueStoreCache { + return cached + } + let store: any KeyValueStore + switch mode { + case .persistent: + guard let suite = UserDefaults(suiteName: suiteName) else { + preconditionFailure( + "StorageKit: invalid UserDefaults suite name \"\(suiteName)\"", + ) + } + store = UserDefaultsKeyValueStore(suite) + case .inMemory: + store = InMemoryKeyValueStore() + } + keyValueStoreCache = store + return store + } +} diff --git a/Shared/StorageKit/Sources/StorageContainer+SwiftData.swift b/Shared/StorageKit/Sources/StorageContainer+SwiftData.swift new file mode 100644 index 00000000..2eabc529 --- /dev/null +++ b/Shared/StorageKit/Sources/StorageContainer+SwiftData.swift @@ -0,0 +1,87 @@ +import Foundation +import SwiftData + +extension StorageContainer { + /// The SwiftData namespace for this container: isolated `ModelContainer`s whose + /// stores live in dedicated child directories. See ``SwiftDataStorage``. + public nonisolated var swiftData: SwiftDataStorage { + SwiftDataStorage(container: self) + } +} + +/// The `swiftData` namespace of a ``StorageContainer`` — a tiny `Sendable` facade +/// vending isolated SwiftData `ModelContainer`s. Vend it with `container.swiftData`. +public struct SwiftDataStorage: Sendable { + let container: StorageContainer + + /// Vend a SwiftData `ModelContainer` whose store lives in a dedicated child + /// container, so the `.store` file and its `-wal` / `-shm` sidecars and any + /// external-storage blobs are isolated in one directory (deleting that child — + /// or this container — deletes exactly that store's files). Cached per `named` + /// key. In `.inMemory` mode the store is `isStoredInMemoryOnly` and CloudKit + /// is forced off. + /// + /// A store name identifies exactly one schema: calling this again under the + /// same `named` key with a different set of `types` throws + /// `StorageError.modelStoreSchemaMismatch` rather than handing back the first + /// container with a mismatched schema. Use distinct names for distinct stores. + /// + /// - Important: In `.persistent` mode the store occupies a child container + /// named `named` (default `"store"`), in the **same** key namespace as + /// `container(_:)`. Don't also vend a plain child under that key — e.g. + /// `container("store")` and the default model store would share one + /// directory and stomp each other. Pass a distinct `named:` (or avoid that + /// key for your own children) to keep them apart. + public func modelContainer( + for types: [any PersistentModel.Type], + named name: StorageKey = "store", + cloudKit: CloudKitOption = .none, + ) async throws -> ModelContainer { + try await container.makeModelContainer(for: types, named: name, cloudKit: cloudKit) + } +} + +extension StorageContainer { + /// Actor-isolated backing for `swiftData.modelContainer(for:)`. Lives here + /// beside its facade so the SwiftData concern stays out of the core + /// `StorageContainer` file. + func makeModelContainer( + for types: [any PersistentModel.Type], + named name: StorageKey, + cloudKit: CloudKitOption, + ) throws -> ModelContainer { + try activate() + let typeIDs = Set(types.map { ObjectIdentifier($0) }) + if let cached = modelContainerCache[name] { + guard cached.typeIDs == typeIDs else { + throw StorageError.modelStoreSchemaMismatch(name) + } + return cached.container + } + let schema = Schema(types) + let configuration: ModelConfiguration + switch mode { + case .inMemory: + // No backing files, so don't vend a store child / touch disk. + configuration = ModelConfiguration( + schema: schema, + isStoredInMemoryOnly: true, + cloudKitDatabase: .none, + ) + case .persistent: + let storeContainer = try container(name) + let storeURL = storeContainer.url.appending( + path: "\(name.name).store", + directoryHint: .notDirectory, + ) + configuration = ModelConfiguration( + schema: schema, + url: storeURL, + cloudKitDatabase: cloudKit.cloudKitDatabase, + ) + } + let modelContainer = try ModelContainer(for: schema, configurations: [configuration]) + modelContainerCache[name] = CachedModelStore(container: modelContainer, typeIDs: typeIDs) + return modelContainer + } +} diff --git a/Shared/StorageKit/Sources/StorageContainer.swift b/Shared/StorageKit/Sources/StorageContainer.swift new file mode 100644 index 00000000..29b77866 --- /dev/null +++ b/Shared/StorageKit/Sources/StorageContainer.swift @@ -0,0 +1,467 @@ +import Foundation +import SwiftData + +/// A handler registered for one of a container's teardown phases. Awaited during +/// `deactivate()` / `deleteContainer()`; a throw aborts the operation (see +/// `StorageContainer` for the park-safe guarantees). +public typealias TeardownHandler = @Sendable () async throws -> Void + +/// A node in a `StorageSystem`'s tree, and the place where storage actually +/// happens. Each container owns one directory and vends child containers +/// (subdirectories) directly; the things you store inside it hang off typed +/// namespaces — ``files`` (raw file URLs), ``keyValue`` (a namespaced key-value +/// store), and ``swiftData`` (an isolated SwiftData `ModelContainer`). Children +/// are `StorageContainer`s too, so the tree is recursive below the root. +/// +/// The base type stays deliberately small — lifecycle, the child registry, and +/// teardown — and each storage concern lives in its own namespace file +/// (`StorageContainer+Files`, `+KeyValue`, `+SwiftData`) that vends through a tiny +/// `Sendable` facade (`container.swiftData.modelContainer(for:)`). Add your own +/// namespace the same way: extend `StorageContainer` with a `nonisolated` accessor +/// returning a facade that composes the public primitives (child containers, +/// `files`, the teardown hooks). +/// +/// An `actor`: it guards a mutable child registry and teardown registrations, and +/// serializes the file I/O behind vending and deletion. `key` / `url` / `mode` +/// are `nonisolated let` for cheap synchronous reads. +/// +/// ## Deactivate vs. delete +/// +/// Two registration-based, children-first teardown paths, never a boolean flag: +/// +/// - ``deactivate()`` — reversible. Runs every `onDeactivate` handler and drops +/// the framework's own cached vends, but leaves files and registrations in +/// place; the node goes `inactive` and the next vend reactivates it. (account +/// switch / keep-data logout) +/// - ``deleteContainer()`` — irreversible. Runs, children-first across the +/// subtree: every `prepareForDeletion` handler (resources still live), then +/// every `onDeactivate` handler (+ drop cached vends), then deletes the +/// directories and key-value suites and deregisters, then every `afterDeletion` +/// handler. Everything before the delete is **park-safe** — a throw aborts with +/// nothing deleted, so a retry is safe; an `afterDeletion` throw is post-commit +/// (the data is already gone, so it retries only that step). The subtree is +/// marked `deleting` up front, so a vend racing the teardown is rejected rather +/// than resurrecting a directory mid-delete. (delete account / wipe-on-logout) +public actor StorageContainer { + /// Lifecycle of a node, modeled as one enum (not loose flags) per the repo's + /// "make invalid states unrepresentable" rule. + enum State: Equatable { + /// Live: the directory exists and vends work. + case active + /// `deactivate()`d: cached vends dropped and `onDeactivate` handlers run, + /// but the directory and registrations remain. The next vend reactivates. + case inactive + /// `deleteContainer()` / `deleteContents()` is mid-flight on this subtree. + /// Vends are rejected so a concurrent caller can't resurrect a directory + /// that is being torn down. `wasActive` remembers the pre-freeze state so + /// `onDeactivate` runs exactly once (skipped if the node was already + /// `inactive`) and a parked (pre-commit) throw reverts to the right resting + /// state (`active` or `inactive`); committing the delete advances it to + /// `deleted`. + case deleting(wasActive: Bool) + /// `deleteContainer()`d: the directory is gone and the node is detached. + /// Any further vend throws (or, for the non-throwing `keyValue.store()`, + /// traps as the programmer error it is). + case deleted + } + + /// An opaque handle to a registered teardown handler. Returned by + /// `onDeactivate` / `prepareForDeletion` / `afterDeletion` and passed back to + /// `deregister(_:)`. + public struct Token: Hashable, Sendable { + fileprivate enum Phase { + case onDeactivate + case prepareForDeletion + case afterDeletion + } + + /// Identity of the container that issued the token. Per-node token ids + /// start at 0, so without this a token from one container could match (and + /// deregister) an unrelated handler on another. `deregister(_:)` ignores a + /// token whose `node` isn't this container. + fileprivate let node: ObjectIdentifier + fileprivate let id: UInt64 + fileprivate let phase: Phase + } + + /// A cached `ModelContainer` plus the identity of the model types it was built + /// from, so a re-vend under the same store name with a different type set is + /// caught instead of silently returning a container with the wrong schema. + /// Internal (not `private`) so the `swiftData` namespace extension in a sibling + /// file can build and read it. + struct CachedModelStore { + let container: ModelContainer + let typeIDs: Set + } + + /// This node's key (a single, sanitized path component). + public nonisolated let key: StorageKey + /// This node's directory on disk. + public nonisolated let url: URL + /// Inherited from the owning `StorageSystem`. + public nonisolated let mode: StorageMode + + /// Hierarchical suite-name for this node's `.persistent` key-value store, e.g. + /// `"Where..user-1.logs"`. The root segment folds in a hash of the + /// system's resolved base directory (see `StorageSystem.rootSuiteName`) so + /// distinct systems don't share a global suite; children append their keys. + /// Unused in `.inMemory` mode. + nonisolated let suiteName: String + + private weak var parent: StorageContainer? + /// Internal (not `private`) so `@testable` tests can assert lifecycle + /// transitions; only this type mutates it. + private(set) var state: State = .active + private var children: [StorageKey: StorageContainer] = [:] + + /// Cached vends. Internal (not `private`) so the `keyValue` / `swiftData` + /// namespace extensions in sibling files can read and refresh them; only this + /// type touches them, always under the actor. Dropped by teardown via + /// `dropCachedVends()`. + var keyValueStoreCache: (any KeyValueStore)? + var modelContainerCache: [StorageKey: CachedModelStore] = [:] + + private var nextTokenID: UInt64 = 0 + private var onDeactivateHandlers: [UInt64: TeardownHandler] = [:] + private var prepareForDeletionHandlers: [UInt64: TeardownHandler] = [:] + private var afterDeletionHandlers: [UInt64: TeardownHandler] = [:] + + init( + key: StorageKey, + url: URL, + mode: StorageMode, + suiteName: String, + parent: StorageContainer?, + ) { + self.key = key + self.url = url + self.mode = mode + self.suiteName = suiteName + self.parent = parent + } + + // MARK: - Child containers + + /// Vend the child container named `childKey`, creating its directory the first + /// time. Cached and idempotent: the same `childKey` always returns the same + /// instance. + public func container(_ childKey: StorageKey) throws -> StorageContainer { + try activate() + if let existing = children[childKey] { + return existing + } + let childURL = url.appending(path: childKey.name, directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: childURL, withIntermediateDirectories: true) + let child = StorageContainer( + key: childKey, + url: childURL, + mode: mode, + suiteName: "\(suiteName).\(childKey.name)", + parent: self, + ) + children[childKey] = child + return child + } + + /// Vend (creating as needed) the container at a path of nested keys, e.g. + /// `container(path: ["user-1", "logs"])`. + public func container(path keys: [StorageKey]) async throws -> StorageContainer { + var node = self + for childKey in keys { + node = try await node.container(childKey) + } + return node + } + + // MARK: - Teardown registration + + /// Register a handler run on **both** teardown paths: "stop using your handle + /// and release it." Idempotent (it can run again after a parked retry). The + /// returned `Token` deregisters it. + @discardableResult + public func onDeactivate(_ handler: @escaping TeardownHandler) -> Token { + let id = takeTokenID() + onDeactivateHandlers[id] = handler + return Token(node: ObjectIdentifier(self), id: id, phase: .onDeactivate) + } + + /// Register a handler run **only** on deletion, first, while resources are + /// still live — reversible prep / final reads. A throw parks the deletion with + /// nothing deleted, so don't commit irreversible external work here. + @discardableResult + public func prepareForDeletion(_ handler: @escaping TeardownHandler) -> Token { + let id = takeTokenID() + prepareForDeletionHandlers[id] = handler + return Token(node: ObjectIdentifier(self), id: id, phase: .prepareForDeletion) + } + + /// Register a handler run **only** on deletion, after the files are gone — the + /// irreversible external commit ("it's gone now"). A throw leaves the deletion + /// done and retries only this step, so handlers must be idempotent and must + /// not assume the data still exists. + @discardableResult + public func afterDeletion(_ handler: @escaping TeardownHandler) -> Token { + let id = takeTokenID() + afterDeletionHandlers[id] = handler + return Token(node: ObjectIdentifier(self), id: id, phase: .afterDeletion) + } + + /// Remove a previously registered handler. A token issued by a different + /// container is ignored (it identifies no handler here). + public func deregister(_ token: Token) { + guard token.node == ObjectIdentifier(self) else { return } + switch token.phase { + case .onDeactivate: onDeactivateHandlers[token.id] = nil + case .prepareForDeletion: prepareForDeletionHandlers[token.id] = nil + case .afterDeletion: afterDeletionHandlers[token.id] = nil + } + } + + // MARK: - Teardown + + /// Reversible teardown: run `onDeactivate` handlers and drop cached vends + /// across the subtree, children-first, leaving files and registrations in + /// place. A no-op on an already-inactive node. Re-vending reactivates. + public func deactivate() async throws { + guard state == .active else { return } + for child in children.values { + try await child.deactivate() + } + try await fireOnDeactivate() + state = .inactive + } + + /// Irreversible teardown of this container and its whole subtree. See the type + /// doc for the phase order and the park-safe guarantee. + public func deleteContainer() async throws { + if state == .deleted { + // Retry after a post-commit (`afterDeletion`) throw: the data is gone + // and the node is already detached, so only the post-commit tail + // remains to re-run. + try await runAfterDeletion() + releaseChildren() + return + } + + // Mark the whole subtree `deleting` first, so a vend racing the teardown + // is rejected instead of recreating a directory we're about to remove. + await beginDeleting() + do { + try await runPrepareForDeletion() + try await runOnDeactivate() + try removeDirectoryTree() + } catch { + // Park-safe: nothing is committed yet, so undo the freeze and leave + // the subtree usable for a later retry. + await revertDeleting() + throw error + } + await purgeAndMarkDeleted() + // Deregister from the parent as part of the commit, *before* the + // post-commit step — so that even if an `afterDeletion` handler throws, + // re-vending this key builds a fresh node instead of handing back this + // deleted one. `runAfterDeletion` still recurses the (not-yet-released) + // children, which are freed once it succeeds. + await detachFromParent() + try await runAfterDeletion() + releaseChildren() + } + + /// Delete every child container and clear this container's own files, but keep + /// this (now empty) node live. The node's key-value suite is left intact. + public func deleteContents() async throws { + try activate() + let existingChildren = Array(children.values) + // Freeze this node while clearing it so a concurrent vend can't slip a new + // child past the snapshot and have `clearLooseFiles()` delete it underfoot. + // `activate()` above left it `active`, and the node itself survives, so the + // `wasActive` payload is moot here. + state = .deleting(wasActive: true) + do { + for child in existingChildren { + try await child.deleteContainer() + } + try clearLooseFiles() + } catch { + state = .active + throw error + } + state = .active + } + + // MARK: - Internals + + private func takeTokenID() -> UInt64 { + defer { nextTokenID += 1 } + return nextTokenID + } + + /// Ensure the node is live before a throwing vend, recreating the directory for + /// an `inactive` node. Internal so the `swiftData` namespace extension can + /// gate its vend on it. Throws `StorageError.containerDeleted` on a torn-down + /// node. + func activate() throws { + switch state { + case .active: + break + case .inactive: + try FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: true, + ) + state = .active + case .deleting, .deleted: + throw StorageError.containerDeleted(key) + } + } + + /// Ensure the node is live enough to vend its **non-throwing** key-value store, + /// reactivating an `inactive` node in place (the suite needs no directory, so + /// this doesn't recreate one). Traps on a `deleting`/`deleted` node — see + /// `KeyValueStorage.store()` for why that's a programmer error, not a throw. + /// Internal so the `keyValue` namespace extension can gate its vend on it while + /// the `state` setter stays private to this file. + func requireLiveForKeyValueVend() { + switch state { + case .active: + break + case .inactive: + state = .active + case .deleting, .deleted: + preconditionFailure( + "StorageKit: keyValue.store() on a deleted container \"\(key)\"", + ) + } + } + + private func dropCachedVends() { + keyValueStoreCache = nil + modelContainerCache.removeAll() + } + + /// Run this node's `onDeactivate` handlers and drop its cached vends. Shared by + /// the reversible `deactivate()` and the deletion path; neither touches state + /// here so each can set its own (`inactive` vs. keep `deleting`). + private func fireOnDeactivate() async throws { + for handler in onDeactivateHandlers.values { + try await handler() + } + dropCachedVends() + } + + /// Mark the subtree `deleting` so vends are rejected for the duration of a + /// teardown, recording each node's pre-freeze state in `wasActive`. Marks + /// `self` before recursing: once `self` is `deleting` no new child can be + /// vended onto it, so the child set it then snapshots is stable. + private func beginDeleting() async { + switch state { + case .active: + state = .deleting(wasActive: true) + case .inactive: + state = .deleting(wasActive: false) + case .deleting, .deleted: + return + } + for child in children.values { + await child.beginDeleting() + } + } + + /// Undo `beginDeleting()` after a parked (pre-commit) throw, returning each + /// still-intact node to the exact resting state it came from. + private func revertDeleting() async { + for child in children.values { + await child.revertDeleting() + } + if case let .deleting(wasActive) = state { + state = wasActive ? .active : .inactive + } + } + + /// The deletion-path counterpart to `deactivate()`: run `onDeactivate` + + /// drop cached vends across the subtree, children-first, but keep the + /// `deleting` state rather than going `inactive`. Skips a node that was + /// already `inactive` before the delete, so its `onDeactivate` handlers (which + /// ran during that earlier `deactivate()`) don't fire a second time. + private func runOnDeactivate() async throws { + for child in children.values { + try await child.runOnDeactivate() + } + if case .deleting(wasActive: true) = state { + try await fireOnDeactivate() + } + } + + private func runPrepareForDeletion() async throws { + for child in children.values { + try await child.runPrepareForDeletion() + } + for handler in prepareForDeletionHandlers.values { + try await handler() + } + } + + private func removeDirectoryTree() throws { + guard FileManager.default.fileExists(atPath: url.path) else { return } + try FileManager.default.removeItem(at: url) + } + + /// Children-first across the subtree: remove the (persistent) key-value suite, + /// drop cached vends, and mark the node `deleted`. Disk directories are + /// already gone via `removeDirectoryTree()`; suites live outside the directory + /// so they're cleared here. + private func purgeAndMarkDeleted() async { + for child in children.values { + await child.purgeAndMarkDeleted() + } + purgeKeyValueSuite() + dropCachedVends() + state = .deleted + } + + private func purgeKeyValueSuite() { + guard case .persistent = mode else { return } + UserDefaults().removePersistentDomain(forName: suiteName) + } + + private func runAfterDeletion() async throws { + for child in children.values { + try await child.runAfterDeletion() + } + for handler in afterDeletionHandlers.values { + try await handler() + } + } + + private func detachFromParent() async { + await parent?.removeChild(key) + parent = nil + } + + /// Release the subtree's child registry once `afterDeletion` has run. Kept + /// separate from `detachFromParent()` so the post-commit step can still + /// recurse the children before they're dropped. + private func releaseChildren() { + children.removeAll() + } + + private func removeChild(_ childKey: StorageKey) { + children[childKey] = nil + // A model store lives in the child keyed by `childKey`, so its cached + // `ModelContainer` is held here on the parent. Drop it when the child is + // detached (e.g. by `deleteContents()` deleting that child) so a re-vend + // rebuilds against fresh files instead of returning a container backed by + // a just-deleted store. + modelContainerCache[childKey] = nil + } + + private func clearLooseFiles() throws { + let contents = try FileManager.default.contentsOfDirectory( + at: url, + includingPropertiesForKeys: nil, + ) + for item in contents { + try FileManager.default.removeItem(at: item) + } + } +} diff --git a/Shared/StorageKit/Sources/StorageError.swift b/Shared/StorageKit/Sources/StorageError.swift new file mode 100644 index 00000000..cd6ed4ec --- /dev/null +++ b/Shared/StorageKit/Sources/StorageError.swift @@ -0,0 +1,14 @@ +/// Errors thrown by StorageKit operations. +public enum StorageError: Error, Sendable, Equatable { + /// A container method was used after the container (or one of its ancestors) + /// was destroyed via `deleteContainer()` / `deleteAll()`. The node is gone; + /// vend a fresh one from a live `StorageSystem`. + case containerDeleted(StorageKey) + + /// `swiftData.modelContainer(for:named:)` was called again under the same `named` store + /// (the associated key) but with a different set of model types than the + /// already-open store was built with. A store has exactly one schema — vend + /// distinct schemas under distinct names rather than silently reusing the + /// first one. + case modelStoreSchemaMismatch(StorageKey) +} diff --git a/Shared/StorageKit/Sources/StorageKey.swift b/Shared/StorageKit/Sources/StorageKey.swift new file mode 100644 index 00000000..7ba7af00 --- /dev/null +++ b/Shared/StorageKit/Sources/StorageKey.swift @@ -0,0 +1,58 @@ +import Foundation + +/// A filesystem-safe, single path component used to name a `StorageSystem` and +/// every container in its tree. +/// +/// Honors the repo's "typed keys, not raw `String`" rule: prefer building one +/// from a typed enum (`StorageKey(MyKeys.logs)`); the string-literal convenience +/// (`"logs"`) is for call-site brevity. The raw input is sanitized into a safe +/// single path component — path separators (`/`, `\`), `:`, and control +/// characters become `_`, and an empty / `.` / `..` input gets a leading `_` — +/// so a key can never traverse out of its parent directory. +/// +/// Sanitization is normalization, not validation: two different raw inputs can +/// sanitize to the same `name` (e.g. `"a/b"` and `"a_b"`), so keep your keys +/// distinct. +public struct StorageKey: Hashable, Sendable, ExpressibleByStringLiteral, CustomStringConvertible { + /// The sanitized path component, used both on disk and as this node's + /// key-value suite-name segment. + public let name: String + + public init(_ raw: String) { + name = Self.sanitized(raw) + } + + public init(_ key: some RawRepresentable) { + self.init(key.rawValue) + } + + public init(stringLiteral value: String) { + self.init(value) + } + + public var description: String { + name + } + + // Foundation has no built-in "make a safe single path component" API — the + // near-misses all fall short: `addingPercentEncoding` is URL-oriented (it + // leaves `.`/`..` intact and `%`-mangles otherwise-fine names), + // `URL(fileURLWithPath:).lastPathComponent` doesn't neutralize traversal, and + // `FileManager` offers no name sanitizer. So we sanitize scalar-by-scalar here. + private static func sanitized(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + var result = "" + result.unicodeScalars.reserveCapacity(trimmed.unicodeScalars.count) + for scalar in trimmed.unicodeScalars { + if scalar == "/" || scalar == "\\" || scalar == ":" || scalar.value < 0x20 { + result.unicodeScalars.append("_") + } else { + result.unicodeScalars.append(scalar) + } + } + if result.isEmpty || result == "." || result == ".." { + return "_" + result + } + return result + } +} diff --git a/Shared/StorageKit/Sources/StorageMode.swift b/Shared/StorageKit/Sources/StorageMode.swift new file mode 100644 index 00000000..d8a598a7 --- /dev/null +++ b/Shared/StorageKit/Sources/StorageMode.swift @@ -0,0 +1,23 @@ +/// How a `StorageSystem` — and the whole container tree below it — persists. +/// +/// The mode is set once on the `StorageSystem` and inherited by every container, +/// so test and preview code can flip a single switch and nothing below has to +/// know it is running against ephemeral storage. The vending namespaces +/// (`StorageContainer.keyValue.store()` / `.swiftData.modelContainer(for:)`) do +/// "the right thing" for each mode automatically. +/// +/// `.persistent` carries its `base` so the two are inseparable — you can't ask for +/// on-disk storage without saying *where*, and `.inMemory` can't carry a base it +/// would ignore. The base is consumed once, at `StorageSystem` init, to resolve +/// the root directory; containers below the root only ever branch on +/// persistent-vs-in-memory. +public enum StorageMode: Sendable { + /// Real files under `base`; UserDefaults suites and SwiftData stores persist + /// across launches. + case persistent(base: BaseDirectory) + + /// Ephemeral: files live in a temp directory removed by `deleteAll()`, + /// key-value stores are in-memory, and SwiftData stores are + /// `isStoredInMemoryOnly`. No app or model code needs to know. + case inMemory +} diff --git a/Shared/StorageKit/Sources/StorageSystem.swift b/Shared/StorageKit/Sources/StorageSystem.swift new file mode 100644 index 00000000..1570ecf6 --- /dev/null +++ b/Shared/StorageKit/Sources/StorageSystem.swift @@ -0,0 +1,102 @@ +import Foundation + +/// The root of a StorageKit tree: pure configuration plus a vending point. It +/// owns the `mode`, the resolved namespace directory, and whole-tree teardown, +/// and vends the first level of containers (e.g. one per user id). All actual +/// storage operations live on the `StorageContainer`s it vends — this type does +/// no file work of its own beyond creating its namespace directory. +/// +/// The split costs one extra directory level on disk (the layout is an invisible +/// implementation detail) in exchange for keeping "what do I configure" separate +/// from "what do I store". +public actor StorageSystem { + /// How this system and everything below it persists. + public nonisolated let mode: StorageMode + /// The resolved namespace directory (a temp directory in `.inMemory` mode). + public nonisolated let url: URL + + private let root: StorageContainer + + /// Create a storage system named `name`. + /// + /// - `.persistent(base:)`: rooted at `/`. + /// - `.inMemory`: rooted at a unique temporary directory removed by + /// `deleteAll()`. + /// + /// `fileManager` is injectable so tests can resolve a `.custom` base + /// deterministically. + public init( + _ name: StorageKey, + mode: StorageMode, + fileManager: FileManager = .default, + ) throws { + self.mode = mode + let rootURL: URL + switch mode { + case let .persistent(base): + let baseURL = try base.resolvedURL(using: fileManager) + rootURL = baseURL.appending(path: name.name, directoryHint: .isDirectory) + case .inMemory: + rootURL = fileManager.temporaryDirectory.appending( + path: "StorageKit-\(name.name)-\(UUID().uuidString)", + directoryHint: .isDirectory, + ) + } + try fileManager.createDirectory(at: rootURL, withIntermediateDirectories: true) + url = rootURL + root = StorageContainer( + key: name, + url: rootURL, + mode: mode, + suiteName: Self.rootSuiteName(name: name, url: rootURL), + parent: nil, + ) + } + + /// The root `UserDefaults` suite name for the tree. It folds a stable hash of + /// the resolved root URL into `name`, so two systems with the same `name` but + /// different base directories (parallel tests, or genuinely distinct on-disk + /// locations) get independent global suites instead of clobbering one shared + /// `""` domain. Child suites append their keys to this. Unused in + /// `.inMemory` mode, where key-value stores never touch `UserDefaults`. + /// + /// Why a *hash* of the URL rather than the URL string itself: a suite name + /// becomes a plist filename (`~/Library/Preferences/.plist`), so the + /// raw path can't be used — it contains `/`, is absolute, and would leak the + /// user's home directory into a global preferences filename. Sanitizing the + /// full path would sidestep the slashes but stays long and leaky; the hash is + /// a compact, stable disambiguator that keeps the readable `name` up front. + private static func rootSuiteName(name: StorageKey, url: URL) -> String { + "\(name.name).\(stableHash(url.path))" + } + + /// A deterministic FNV-1a hash, rendered base-36. `Hasher`/`hashValue` is + /// seeded per process, so it can't be used here — the suite name must stay + /// stable across launches for persistence to survive. + private static func stableHash(_ string: String) -> String { + var hash: UInt64 = 0xCBF2_9CE4_8422_2325 + for byte in string.utf8 { + hash ^= UInt64(byte) + hash = hash &* 0x0000_0100_0000_01B3 + } + return String(hash, radix: 36) + } + + /// Vend a top-level container (e.g. one per user id). Cached and idempotent. + public func container(_ key: StorageKey) async throws -> StorageContainer { + try await root.container(key) + } + + /// Reversibly release the whole tree's handles, keeping all data. The next + /// vend reactivates. + public func deactivate() async throws { + try await root.deactivate() + } + + /// Delete every container and the namespace directory itself (in `.inMemory` + /// mode, the temp directory). The system is spent afterwards — build a new one + /// to start over. + public func deleteAll() async throws { + try await root.deleteContainer() + } +} diff --git a/Shared/StorageKit/Tests/BaseDirectoryTests.swift b/Shared/StorageKit/Tests/BaseDirectoryTests.swift new file mode 100644 index 00000000..7d86cb83 --- /dev/null +++ b/Shared/StorageKit/Tests/BaseDirectoryTests.swift @@ -0,0 +1,54 @@ +import Foundation +import StorageKit +import Testing + +struct BaseDirectoryTests { + @Test + func customResolvesToTheGivenURL() throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + + #expect(try BaseDirectory.custom(temp).resolvedURL() == temp) + } + + @Test + func appendsSubdirectoryUnderAStandardDirectory() throws { + let resolved = try BaseDirectory + .applicationSupport(subdirectory: "StorageKitTests-namespace") + .resolvedURL() + #expect(resolved.lastPathComponent == "StorageKitTests-namespace") + } + + @Test + func standardDirectoriesResolveToDistinctLocations() throws { + let appSupport = try BaseDirectory.applicationSupport().resolvedURL() + let caches = try BaseDirectory.caches().resolvedURL() + let documents = try BaseDirectory.documents().resolvedURL() + let library = try BaseDirectory.library().resolvedURL() + + // Each standard directory maps to its own location — no two collapse. + #expect(Set([appSupport.path, caches.path, documents.path, library.path]).count == 4) + #expect(documents.lastPathComponent == "Documents") + #expect(library.lastPathComponent == "Library") + } + + @Test + func omittingSubdirectoryReturnsTheStandardDirectory() throws { + let without = try BaseDirectory.applicationSupport().resolvedURL() + let with = try BaseDirectory + .applicationSupport(subdirectory: "child") + .resolvedURL() + #expect(with.deletingLastPathComponent().path == without.path) + } + + @Test + func doesNotCreateTheSubdirectory() throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + + let resolved = try BaseDirectory.custom(temp).resolvedURL() + let child = resolved.appending(path: "not-created", directoryHint: .isDirectory) + // `resolvedURL` only resolves; the system creates the namespace dir. + #expect(!FileManager.default.fileExists(atPath: child.path)) + } +} diff --git a/Shared/StorageKit/Tests/KeyValueStoreTests.swift b/Shared/StorageKit/Tests/KeyValueStoreTests.swift new file mode 100644 index 00000000..7bd79a2f --- /dev/null +++ b/Shared/StorageKit/Tests/KeyValueStoreTests.swift @@ -0,0 +1,67 @@ +import Foundation +@testable import StorageKit +import Testing + +struct KeyValueStoreTests { + @Test + func inMemoryRoundTripsEachType() { + let store = InMemoryKeyValueStore() + store.set(true, forKey: "flag") + store.set(42, forKey: "count") + store.set(3.5, forKey: "ratio") + store.set("hi", forKey: "label") + store.set(Data([1, 2, 3]), forKey: "blob") + + #expect(store.bool(forKey: "flag")) + #expect(store.integer(forKey: "count") == 42) + #expect(store.double(forKey: "ratio") == 3.5) + #expect(store.string(forKey: "label") == "hi") + #expect(store.data(forKey: "blob") == Data([1, 2, 3])) + } + + @Test + func inMemoryReturnsDefaultsForAbsentKeys() { + let store = InMemoryKeyValueStore() + #expect(!store.bool(forKey: "missing")) + #expect(store.integer(forKey: "missing") == 0) + #expect(store.double(forKey: "missing") == 0) + #expect(store.string(forKey: "missing") == nil) + #expect(store.data(forKey: "missing") == nil) + } + + @Test + func inMemoryReadingTheWrongTypeReturnsTheDefault() { + let store = InMemoryKeyValueStore() + store.set(true, forKey: "flag") + #expect(store.integer(forKey: "flag") == 0) + #expect(store.string(forKey: "flag") == nil) + } + + @Test + func inMemorySettingNilOrRemovingClearsTheKey() { + let store = InMemoryKeyValueStore() + store.set("hi", forKey: "label") + store.set(nil as String?, forKey: "label") + #expect(store.string(forKey: "label") == nil) + + store.set(Data([9]), forKey: "blob") + store.removeObject(forKey: "blob") + #expect(store.data(forKey: "blob") == nil) + } + + @Test + func userDefaultsWrapperRoundTripsAndRemoves() throws { + let suiteName = "StorageKitTests-\(UUID().uuidString)" + let suite = try #require(UserDefaults(suiteName: suiteName)) + defer { suite.removePersistentDomain(forName: suiteName) } + let store = UserDefaultsKeyValueStore(suite) + + store.set(7, forKey: "count") + store.set("hi", forKey: "label") + #expect(store.integer(forKey: "count") == 7) + #expect(store.string(forKey: "label") == "hi") + + store.set(nil as String?, forKey: "label") + #expect(store.string(forKey: "label") == nil) + } +} diff --git a/Shared/StorageKit/Tests/StorageContainerTests.swift b/Shared/StorageKit/Tests/StorageContainerTests.swift new file mode 100644 index 00000000..41ca10a3 --- /dev/null +++ b/Shared/StorageKit/Tests/StorageContainerTests.swift @@ -0,0 +1,601 @@ +import Foundation +@testable import StorageKit +import SwiftData +import Testing + +struct StorageContainerTests { + // MARK: - Tree + + @Test + func vendsAndCachesChildContainers() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let user = try await system.container("user-1") + let logs = try await user.container("logs") + let logsAgain = try await user.container("logs") + + #expect(logs === logsAgain) + #expect(logs.url == user.url.appending(path: "logs", directoryHint: .isDirectory)) + #expect(FileManager.default.fileExists(atPath: logs.url.path)) + } + + @Test + func vendsANestedPath() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let user = try await system.container("user-1") + let leaf = try await user.container(path: ["logs", "today"]) + + #expect(leaf.key == "today") + #expect(leaf.url == user.url + .appending(path: "logs", directoryHint: .isDirectory) + .appending(path: "today", directoryHint: .isDirectory)) + #expect(FileManager.default.fileExists(atPath: leaf.url.path)) + } + + @Test + func fileURLPointsIntoTheContainerDirectory() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let user = try await system.container("user-1") + let fileURL = user.files.url("note.txt") + try Data("hello".utf8).write(to: fileURL) + + #expect(fileURL == user.url.appending(path: "note.txt", directoryHint: .notDirectory)) + #expect(try String(contentsOf: fileURL, encoding: .utf8) == "hello") + } + + // MARK: - Key-value store + + @Test + func inMemoryKeyValueStoresAreNamespacedPerContainerAndCached() async throws { + let system = try StorageSystem("Where", mode: .inMemory) + let a = try await system.container("a") + let b = try await system.container("b") + + let aStore = await a.keyValue.store() + let bStore = await b.keyValue.store() + aStore.set(true, forKey: "flag") + + #expect(aStore.bool(forKey: "flag")) + #expect(!bStore.bool(forKey: "flag")) + #expect(await a.keyValue.store() === aStore) + } + + @Test + func persistentKeyValueStoreRoundTripsAndIsPurgedOnDelete() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let user = try await system.container("user-1") + await user.keyValue.store().set(99, forKey: "count") + #expect(await user.keyValue.store().integer(forKey: "count") == 99) + + try await user.deleteContainer() + + let revived = try await system.container("user-1") + #expect(await revived.keyValue.store().integer(forKey: "count") == 0) + + try await system.deleteAll() + } + + // MARK: - SwiftData + + @Test + func persistentModelContainerIsolatesItsStoreAndCaches() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let user = try await system.container("user-1") + let container = try await user.swiftData.modelContainer(for: [Note.self]) + let again = try await user.swiftData.modelContainer(for: [Note.self]) + #expect(container === again) + + let context = ModelContext(container) + context.insert(Note(text: "hi")) + try context.save() + #expect(try context.fetchCount(FetchDescriptor()) == 1) + + let storeDir = user.url.appending(path: "store", directoryHint: .isDirectory) + let storeFile = storeDir.appending(path: "store.store", directoryHint: .notDirectory) + #expect(FileManager.default.fileExists(atPath: storeFile.path)) + } + + @Test + func inMemoryModelContainerWritesNoStoreFile() async throws { + let system = try StorageSystem("Where", mode: .inMemory) + let user = try await system.container("user-1") + let container = try await user.swiftData.modelContainer(for: [Note.self]) + + let context = ModelContext(container) + context.insert(Note(text: "hi")) + try context.save() + #expect(try context.fetchCount(FetchDescriptor()) == 1) + + let storeDir = user.url.appending(path: "store", directoryHint: .isDirectory) + let storeFile = storeDir.appending(path: "store.store", directoryHint: .notDirectory) + #expect(!FileManager.default.fileExists(atPath: storeFile.path)) + // In-memory mode must not touch disk at all — not even the store directory. + #expect(!FileManager.default.fileExists(atPath: storeDir.path)) + + try await system.deleteAll() + } + + @Test + func modelContainerIsRecreatedAfterDeleteContents() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let user = try await system.container("user-1") + let first = try await user.swiftData.modelContainer(for: [Note.self]) + let writeContext = ModelContext(first) + writeContext.insert(Note(text: "hi")) + try writeContext.save() + #expect(try writeContext.fetchCount(FetchDescriptor()) == 1) + + try await user.deleteContents() + + // The store's files were deleted; re-vending must rebuild a fresh container + // rather than hand back the stale cached one pointing at deleted files. + let second = try await user.swiftData.modelContainer(for: [Note.self]) + #expect(second !== first) + let readContext = ModelContext(second) + #expect(try readContext.fetchCount(FetchDescriptor()) == 0) + } + + @Test + func reVendingAStoreWithDifferentTypesThrows() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let user = try await system.container("user-1") + let first = try await user.swiftData.modelContainer(for: [Note.self]) + + // Same name, different schema → caught, not silently the wrong container. + await #expect(throws: StorageError.modelStoreSchemaMismatch("store")) { + _ = try await user.swiftData.modelContainer(for: [Note.self, Tag.self]) + } + + // The same type set still returns the cached container. + let again = try await user.swiftData.modelContainer(for: [Note.self]) + #expect(again === first) + } + + // MARK: - Deactivate + + @Test + func deactivateRunsHandlersChildrenFirstAndKeepsData() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let user = try await system.container("user-1") + let logs = try await user.container("logs") + let file = logs.files.url("a.txt") + try Data("x".utf8).write(to: file) + + let log = CallLog() + await user.onDeactivate { await log.record("user") } + await logs.onDeactivate { await log.record("logs") } + + try await user.deactivate() + + #expect(await log.entries == ["logs", "user"]) + #expect(await user.state == .inactive) + #expect(await logs.state == .inactive) + #expect(FileManager.default.fileExists(atPath: file.path)) + } + + @Test + func reVendingReactivatesAnInactiveContainer() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let user = try await system.container("user-1") + _ = try await user.container("logs") + try await user.deactivate() + #expect(await user.state == .inactive) + + let logs = try await user.container("logs") + #expect(await user.state == .active) + #expect(FileManager.default.fileExists(atPath: logs.url.path)) + } + + @Test + func deactivateDropsVendCachesAndReVendReopensPersistedData() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let user = try await system.container("user-1") + let firstModel = try await user.swiftData.modelContainer(for: [Note.self]) + let writeContext = ModelContext(firstModel) + writeContext.insert(Note(text: "hi")) + try writeContext.save() + await user.keyValue.store().set(7, forKey: "count") + + try await user.deactivate() + #expect(await user.state == .inactive) + + // Reactivation re-vends a *fresh* ModelContainer (cache was dropped) over + // the surviving on-disk store. + let secondModel = try await user.swiftData.modelContainer(for: [Note.self]) + #expect(secondModel !== firstModel) + let readContext = ModelContext(secondModel) + #expect(try readContext.fetchCount(FetchDescriptor()) == 1) + + // The KV cache was dropped too, but the persisted suite round-trips. + #expect(await user.keyValue.store().integer(forKey: "count") == 7) + + try await system.deleteAll() + } + + @Test + func deactivateIsANoOpWhenAlreadyInactive() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let user = try await system.container("user-1") + let log = CallLog() + await user.onDeactivate { await log.record("user") } + + try await user.deactivate() + try await user.deactivate() + + #expect(await log.entries == ["user"]) + } + + @Test + func deregisterStopsAHandlerFromFiring() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let user = try await system.container("user-1") + let log = CallLog() + let token = await user.onDeactivate { await log.record("user") } + await user.deregister(token) + + try await user.deactivate() + #expect(await log.entries.isEmpty) + } + + @Test + func deregisterIgnoresATokenFromAnotherContainer() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let a = try await system.container("a") + let b = try await system.container("b") + let log = CallLog() + // Both first tokens share id 0; only the issuing node may deregister them. + let tokenA = await a.onDeactivate { await log.record("a") } + await b.onDeactivate { await log.record("b") } + + await b.deregister(tokenA) + + try await a.deactivate() + try await b.deactivate() + #expect(await log.entries.sorted() == ["a", "b"]) + } + + // MARK: - Delete + + @Test + func deleteContainerRunsPhasesInOrderChildrenFirst() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let user = try await system.container("user-1") + let logs = try await user.container("logs") + let log = CallLog() + for (container, label) in [(user, "user"), (logs, "logs")] { + await container.prepareForDeletion { await log.record("prepare:\(label)") } + await container.onDeactivate { await log.record("deactivate:\(label)") } + await container.afterDeletion { await log.record("after:\(label)") } + } + + try await user.deleteContainer() + + #expect(await log.entries == [ + "prepare:logs", + "prepare:user", + "deactivate:logs", + "deactivate:user", + "after:logs", + "after:user", + ]) + #expect(!FileManager.default.fileExists(atPath: user.url.path)) + } + + @Test + func deleteContainerLeavesSiblingsAndDeregisters() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let user1 = try await system.container("user-1") + let user2 = try await system.container("user-2") + let logs1 = try await user1.container("logs") + + try await user1.deleteContainer() + + #expect(!FileManager.default.fileExists(atPath: user1.url.path)) + #expect(FileManager.default.fileExists(atPath: user2.url.path)) + #expect(await user1.state == .deleted) + #expect(await logs1.state == .deleted) + + await #expect(throws: StorageError.self) { _ = try await user1.container("x") } + await #expect(throws: StorageError.self) { _ = try await logs1.container("x") } + + let user1Revived = try await system.container("user-1") + #expect(user1Revived !== user1) + #expect(FileManager.default.fileExists(atPath: user1Revived.url.path)) + } + + @Test + func deletingAnAlreadyDeactivatedNodeRunsOnDeactivateOnce() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let user = try await system.container("user-1") + let logs = try await user.container("logs") + let log = CallLog() + await user.onDeactivate { await log.record("user") } + await logs.onDeactivate { await log.record("logs") } + + try await user.deactivate() + #expect(await log.entries == ["logs", "user"]) + + try await user.deleteContainer() + + // The delete must not re-fire onDeactivate — it already ran on deactivate. + #expect(await log.entries == ["logs", "user"]) + #expect(!FileManager.default.fileExists(atPath: user.url.path)) + } + + @Test + func vendingIsRejectedWhileTeardownIsInFlight() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let user = try await system.container("user-1") + let outcome = VendOutcome() + // A handler running mid-teardown tries to vend onto the node being deleted; + // it must be rejected rather than resurrecting the directory. + await user.prepareForDeletion { + do { + _ = try await user.container("sneaky") + await outcome.record(rejected: false) + } catch { + await outcome.record(rejected: true) + } + } + + try await user.deleteContainer() + + #expect(await outcome.wasRejected == true) + #expect(!FileManager.default.fileExists(atPath: user.url.path)) + let sneaky = user.url.appending(path: "sneaky", directoryHint: .isDirectory) + #expect(!FileManager.default.fileExists(atPath: sneaky.path)) + } + + // MARK: - Park-safe retries + + @Test + func throwingPrepareForDeletionParksWithNothingDeleted() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let user = try await system.container("user-1") + let throwOnce = ThrowOnce() + await user.prepareForDeletion { try await throwOnce.fireIfArmed() } + + await #expect(throws: StorageTestError.self) { try await user.deleteContainer() } + #expect(FileManager.default.fileExists(atPath: user.url.path)) + + try await user.deleteContainer() + #expect(!FileManager.default.fileExists(atPath: user.url.path)) + } + + @Test + func throwingOnDeactivateParksWithNothingDeleted() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let user = try await system.container("user-1") + let throwOnce = ThrowOnce() + await user.onDeactivate { try await throwOnce.fireIfArmed() } + + await #expect(throws: StorageTestError.self) { try await user.deleteContainer() } + #expect(FileManager.default.fileExists(atPath: user.url.path)) + + try await user.deleteContainer() + #expect(!FileManager.default.fileExists(atPath: user.url.path)) + } + + @Test + func parkedDeleteOfAnInactiveNodeRevertsToInactive() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let user = try await system.container("user-1") + try await user.deactivate() + #expect(await user.state == .inactive) + + let throwOnce = ThrowOnce() + await user.prepareForDeletion { try await throwOnce.fireIfArmed() } + + await #expect(throws: StorageTestError.self) { try await user.deleteContainer() } + + // A parked delete restores the exact prior resting state, not just `active`. + #expect(await user.state == .inactive) + #expect(FileManager.default.fileExists(atPath: user.url.path)) + } + + @Test + func throwingAfterDeletionIsPostCommitAndRetriesOnlyThatStep() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let user = try await system.container("user-1") + let throwOnce = ThrowOnce() + await user.afterDeletion { try await throwOnce.fireIfArmed() } + + await #expect(throws: StorageTestError.self) { try await user.deleteContainer() } + // Deletion already stands — the files are gone despite the throw. + #expect(!FileManager.default.fileExists(atPath: user.url.path)) + #expect(await user.state == .deleted) + + // Retry re-runs only the post-commit step; it no longer throws. + try await user.deleteContainer() + } + + @Test + func reVendingAfterAFailedAfterDeletionGivesAFreshContainer() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let user = try await system.container("user-1") + let throwOnce = ThrowOnce() + await user.afterDeletion { try await throwOnce.fireIfArmed() } + + await #expect(throws: StorageTestError.self) { try await user.deleteContainer() } + #expect(await user.state == .deleted) + + // Despite the afterDeletion throw, the node was deregistered from its + // parent, so re-vending the key builds a fresh, usable container rather + // than handing back the deleted one. + let fresh = try await system.container("user-1") + #expect(fresh !== user) + #expect(FileManager.default.fileExists(atPath: fresh.url.path)) + _ = try await fresh.container("logs") + } + + // MARK: - deleteContents + + @Test + func deleteContentsClearsDescendantsAndFilesButKeepsTheNode() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let user = try await system.container("user-1") + let logs = try await user.container("logs") + let loose = user.files.url("note.txt") + try Data("x".utf8).write(to: loose) + + try await user.deleteContents() + + #expect(!FileManager.default.fileExists(atPath: logs.url.path)) + #expect(!FileManager.default.fileExists(atPath: loose.path)) + #expect(FileManager.default.fileExists(atPath: user.url.path)) + #expect(await user.state == .active) + + let logsRevived = try await user.container("logs") + #expect(FileManager.default.fileExists(atPath: logsRevived.url.path)) + } + + @Test + func deleteContentsRunsChildTeardownHooks() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let user = try await system.container("user-1") + let logs = try await user.container("logs") + let log = CallLog() + await logs.prepareForDeletion { await log.record("prepare") } + await logs.onDeactivate { await log.record("deactivate") } + await logs.afterDeletion { await log.record("after") } + + try await user.deleteContents() + + // Each child is fully deleted (all three hooks fire) while the node lives. + #expect(await log.entries == ["prepare", "deactivate", "after"]) + #expect(await user.state == .active) + #expect(await logs.state == .deleted) + #expect(!FileManager.default.fileExists(atPath: logs.url.path)) + #expect(FileManager.default.fileExists(atPath: user.url.path)) + } + + // MARK: - Error surfacing + + @Test + func deletionErrorsSurfaceAndLeaveDataInPlace() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let user = try await system.container("user-1") + let parent = system.url + // A read-only parent directory makes removing `user` fail. + try FileManager.default.setAttributes( + [.posixPermissions: 0o500], + ofItemAtPath: parent.path, + ) + defer { + try? FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: parent.path, + ) + } + + await #expect(throws: (any Error).self) { try await user.deleteContainer() } + + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: parent.path, + ) + #expect(FileManager.default.fileExists(atPath: user.url.path)) + } + + // MARK: - Concurrency + + @Test + func concurrentVendsDoNotResurrectADeletedContainer() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + + let user = try await system.container("user-1") + + // Hammer the node with vends while it's torn down. Each vend either lands + // before the freeze (and is deleted with the tree) or after it (and + // throws) — never recreating the directory mid-delete. + await withTaskGroup(of: Void.self) { group in + for index in 0 ..< 50 { + group.addTask { + _ = try? await user.container(StorageKey("child-\(index)")) + } + } + group.addTask { + try? await user.deleteContainer() + } + } + + #expect(await user.state == .deleted) + #expect(!FileManager.default.fileExists(atPath: user.url.path)) + } +} diff --git a/Shared/StorageKit/Tests/StorageKeyTests.swift b/Shared/StorageKit/Tests/StorageKeyTests.swift new file mode 100644 index 00000000..2beb6dcd --- /dev/null +++ b/Shared/StorageKit/Tests/StorageKeyTests.swift @@ -0,0 +1,46 @@ +import StorageKit +import Testing + +struct StorageKeyTests { + @Test + func passesThroughASafeComponent() { + #expect(StorageKey("logs").name == "logs") + } + + @Test + func trimsSurroundingWhitespace() { + #expect(StorageKey(" logs \n").name == "logs") + } + + @Test + func replacesPathSeparatorsAndColons() { + #expect(StorageKey("a/b").name == "a_b") + #expect(StorageKey("a\\b").name == "a_b") + #expect(StorageKey("a:b").name == "a_b") + } + + @Test + func neutralizesTraversalAndEmpty() { + #expect(StorageKey("").name == "_") + #expect(StorageKey(".").name == "_.") + #expect(StorageKey("..").name == "_..") + } + + @Test + func buildsFromARawRepresentableEnum() { + enum Keys: String { case logs } + #expect(StorageKey(Keys.logs).name == "logs") + } + + @Test + func acceptsAStringLiteral() { + let key: StorageKey = "logs" + #expect(key.name == "logs") + } + + @Test + func isValueEquatableByName() { + #expect(StorageKey("a/b") == StorageKey("a_b")) + #expect(StorageKey("logs") != StorageKey("notes")) + } +} diff --git a/Shared/StorageKit/Tests/StorageKitTestSupport.swift b/Shared/StorageKit/Tests/StorageKitTestSupport.swift new file mode 100644 index 00000000..0c05d12a --- /dev/null +++ b/Shared/StorageKit/Tests/StorageKitTestSupport.swift @@ -0,0 +1,71 @@ +import Foundation +import SwiftData + +/// Error a test injects into a teardown handler to exercise the park-safe paths. +enum StorageTestError: Error, Equatable { + case injected +} + +/// A `@Model` fixture for `swiftData.modelContainer(for:)` tests. +@Model +final class Note { + var text: String + + init(text: String) { + self.text = text + } +} + +/// A second `@Model` fixture, used to vary a store's schema across calls. +@Model +final class Tag { + var label: String + + init(label: String) { + self.label = label + } +} + +/// Records the order teardown handlers fire in, so a test can assert phase +/// ordering and children-first traversal. +actor CallLog { + private(set) var entries: [String] = [] + + func record(_ entry: String) { + entries.append(entry) + } +} + +/// Throws `StorageTestError.injected` the first time it's fired and succeeds +/// afterwards — used to verify a parked teardown completes on retry. +actor ThrowOnce { + private var armed = true + + func fireIfArmed() throws { + if armed { + armed = false + throw StorageTestError.injected + } + } +} + +/// Records whether a vend attempted from inside a teardown handler was rejected, +/// so a test can prove the subtree refuses vends while it is being deleted. +actor VendOutcome { + private(set) var wasRejected: Bool? + + func record(rejected: Bool) { + wasRejected = rejected + } +} + +/// A fresh, unique temporary directory for a `.custom` base, isolating each test +/// from the others and from the host's real Application Support. +func makeTemporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory.appending( + path: "StorageKitTests-\(UUID().uuidString)", + directoryHint: .isDirectory, + ) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +} diff --git a/Shared/StorageKit/Tests/StorageSystemTests.swift b/Shared/StorageKit/Tests/StorageSystemTests.swift new file mode 100644 index 00000000..bb6dc182 --- /dev/null +++ b/Shared/StorageKit/Tests/StorageSystemTests.swift @@ -0,0 +1,90 @@ +import Foundation +import StorageKit +import Testing + +struct StorageSystemTests { + @Test + func persistentSystemCreatesItsNamespaceDirectoryUnderTheBase() throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + #expect(system.url == temp.appending(path: "Where", directoryHint: .isDirectory)) + #expect(FileManager.default.fileExists(atPath: system.url.path)) + } + + @Test + func vendsTopLevelContainersIdempotently() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + let first = try await system.container("user-1") + let second = try await system.container("user-1") + #expect(first === second) + #expect(FileManager.default.fileExists(atPath: first.url.path)) + } + + @Test + func inMemorySystemRootsInATempDirectoryAndDeleteAllRemovesIt() async throws { + let system = try StorageSystem("Where", mode: .inMemory) + let root = system.url + #expect(FileManager.default.fileExists(atPath: root.path)) + #expect(root.path.hasPrefix(FileManager.default.temporaryDirectory.path)) + + try await system.deleteAll() + #expect(!FileManager.default.fileExists(atPath: root.path)) + } + + @Test + func deleteAllRemovesThePersistentNamespaceDirectory() async throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + + let system = try StorageSystem("Where", mode: .persistent(base: .custom(temp))) + _ = try await system.container("user-1") + #expect(FileManager.default.fileExists(atPath: system.url.path)) + + try await system.deleteAll() + #expect(!FileManager.default.fileExists(atPath: system.url.path)) + } + + @Test + func sameNamedSystemsWithDifferentBasesHaveIndependentKeyValueStores() async throws { + let tempA = try makeTemporaryDirectory() + let tempB = try makeTemporaryDirectory() + defer { + try? FileManager.default.removeItem(at: tempA) + try? FileManager.default.removeItem(at: tempB) + } + + let systemA = try StorageSystem("Where", mode: .persistent(base: .custom(tempA))) + let systemB = try StorageSystem("Where", mode: .persistent(base: .custom(tempB))) + let userA = try await systemA.container("user-1") + let userB = try await systemB.container("user-1") + + await userA.keyValue.store().set(99, forKey: "count") + + // Same logical key path, different base: the suites must not collide. + #expect(await userA.keyValue.store().integer(forKey: "count") == 99) + #expect(await userB.keyValue.store().integer(forKey: "count") == 0) + + try await systemA.deleteAll() + try await systemB.deleteAll() + } + + @Test + func subdirectoryNamespacesTheSystemUnderTheBase() throws { + let temp = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + + let system = try StorageSystem( + "Where", + mode: .persistent( + base: .custom(temp.appending(path: "ns", directoryHint: .isDirectory)), + ), + ) + #expect(system.url.deletingLastPathComponent().lastPathComponent == "ns") + #expect(FileManager.default.fileExists(atPath: system.url.path)) + } +}