strata-storage 2.5.0 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,11 +13,42 @@ import Capacitor
13
13
  public class StrataStoragePlugin: CAPPlugin {
14
14
  private let userDefaultsStorage = UserDefaultsStorage()
15
15
  private let keychainStorage = KeychainStorage()
16
- private let sqliteStorage = SQLiteStorage()
16
+ private let filesystemStorage = FilesystemStorage()
17
17
 
18
18
  /// Storage types that have a real native backend on iOS.
19
- /// `filesystem` is intentionally absent see isAvailable / resolveUnsupported.
20
- private let supportedStorageTypes: Set<String> = ["preferences", "secure", "sqlite"]
19
+ private let supportedStorageTypes: Set<String> = ["preferences", "secure", "sqlite", "filesystem"]
20
+
21
+ // MARK: - SQLite multi-store helpers
22
+
23
+ /// Default database name (→ file `strata_storage.db`) and table.
24
+ private static let defaultDatabase = "strata_storage"
25
+ private static let defaultTable = "storage"
26
+
27
+ /// Sanitizes a database name into a safe `.db` filename. Strips everything
28
+ /// outside `[A-Za-z0-9_]` (which also blocks path separators / traversal),
29
+ /// then appends `.db`. Empty input falls back to the default. The filename
30
+ /// CANNOT be a bound parameter, so it must be sanitized before use.
31
+ private func sqliteFileName(from database: String?) -> String {
32
+ let raw = database ?? StrataStoragePlugin.defaultDatabase
33
+ let cleaned = String(raw.unicodeScalars.filter { CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_").contains($0) })
34
+ let safe = cleaned.isEmpty ? StrataStoragePlugin.defaultDatabase : cleaned
35
+ return "\(safe).db"
36
+ }
37
+
38
+ /// Sanitizes a table name to a safe SQL identifier matching `^[A-Za-z0-9_]+$`.
39
+ /// Disallowed characters are removed; empty input falls back to the default.
40
+ /// The table name is interpolated into SQL (it cannot be bound), so this is
41
+ /// the single point that guarantees injection-safety for the identifier.
42
+ private func sqliteTableName(from table: String?) -> String {
43
+ let raw = table ?? StrataStoragePlugin.defaultTable
44
+ let cleaned = String(raw.unicodeScalars.filter { CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_").contains($0) })
45
+ return cleaned.isEmpty ? StrataStoragePlugin.defaultTable : cleaned
46
+ }
47
+
48
+ /// Returns the cached SQLite store for the call's `database` option.
49
+ private func sqliteStore(for call: CAPPluginCall) -> SQLiteStorage {
50
+ return SQLiteStorageManager.shared.store(forFile: sqliteFileName(from: call.getString("database")))
51
+ }
21
52
 
22
53
  /**
23
54
  * Check if a specific storage type is available.
@@ -30,16 +61,10 @@ public class StrataStoragePlugin: CAPPlugin {
30
61
  ])
31
62
  }
32
63
 
33
- private func rejectUnsupportedFilesystem(_ call: CAPPluginCall) {
34
- call.reject(
35
- "Filesystem storage is not implemented in the native iOS plugin. " +
36
- "Use the 'preferences', 'secure', or 'sqlite' storage types, or a " +
37
- "web/Capacitor Filesystem-backed adapter."
38
- )
39
- }
40
-
41
64
  /**
42
- * Get value from storage
65
+ * Get value from storage.
66
+ * For sqlite/filesystem the resolved `value` is the full StorageValue
67
+ * wrapper object (or NSNull on a miss).
43
68
  */
44
69
  @objc func get(_ call: CAPPluginCall) {
45
70
  guard let key = call.getString("key") else {
@@ -56,10 +81,9 @@ public class StrataStoragePlugin: CAPPlugin {
56
81
  case "secure":
57
82
  value = try keychainStorage.get(key: key)
58
83
  case "sqlite":
59
- value = sqliteStorage.get(key: key)
84
+ value = sqliteStore(for: call).get(table: sqliteTableName(from: call.getString("table")), key: key)
60
85
  case "filesystem":
61
- rejectUnsupportedFilesystem(call)
62
- return
86
+ value = filesystemStorage.get(key: key)
63
87
  case "preferences":
64
88
  fallthrough
65
89
  default:
@@ -75,7 +99,9 @@ public class StrataStoragePlugin: CAPPlugin {
75
99
  }
76
100
 
77
101
  /**
78
- * Set value in storage
102
+ * Set value in storage.
103
+ * For sqlite/filesystem the `value` option is the full StorageValue
104
+ * wrapper object and is stored verbatim (JSON) for a perfect round-trip.
79
105
  */
80
106
  @objc func set(_ call: CAPPluginCall) {
81
107
  guard let key = call.getString("key") else {
@@ -83,25 +109,44 @@ public class StrataStoragePlugin: CAPPlugin {
83
109
  return
84
110
  }
85
111
 
86
- let value = call.getValue("value") ?? NSNull()
87
112
  let storage = call.getString("storage") ?? "preferences"
88
113
 
89
114
  do {
90
115
  switch storage {
91
116
  case "secure":
117
+ let value = call.getValue("value") ?? NSNull()
92
118
  _ = try keychainStorage.set(key: key, value: value)
93
119
  case "sqlite":
94
- let ok = try sqliteStorage.set(key: key, value: value)
120
+ // getObject returns JSObject ([String: JSValue]); upcast each
121
+ // value to Any (the canonical Capacitor pattern) so the bridged
122
+ // Foundation values are JSONSerialization-compatible.
123
+ guard let jsObject = call.getObject("value") else {
124
+ call.reject("SQLite value must be a StorageValue object")
125
+ return
126
+ }
127
+ let ok = try sqliteStore(for: call).set(
128
+ table: sqliteTableName(from: call.getString("table")),
129
+ key: key,
130
+ wrapper: jsObject as [String: Any]
131
+ )
95
132
  if !ok {
96
133
  call.reject("Failed to write value to SQLite")
97
134
  return
98
135
  }
99
136
  case "filesystem":
100
- rejectUnsupportedFilesystem(call)
101
- return
137
+ guard let jsObject = call.getObject("value") else {
138
+ call.reject("Filesystem value must be a StorageValue object")
139
+ return
140
+ }
141
+ let ok = try filesystemStorage.set(key: key, wrapper: jsObject as [String: Any])
142
+ if !ok {
143
+ call.reject("Failed to write value to filesystem")
144
+ return
145
+ }
102
146
  case "preferences":
103
147
  fallthrough
104
148
  default:
149
+ let value = call.getValue("value") ?? NSNull()
105
150
  userDefaultsStorage.set(key: key, value: value)
106
151
  }
107
152
 
@@ -127,10 +172,9 @@ public class StrataStoragePlugin: CAPPlugin {
127
172
  case "secure":
128
173
  _ = try keychainStorage.remove(key: key)
129
174
  case "sqlite":
130
- _ = sqliteStorage.remove(key: key)
175
+ _ = sqliteStore(for: call).remove(table: sqliteTableName(from: call.getString("table")), key: key)
131
176
  case "filesystem":
132
- rejectUnsupportedFilesystem(call)
133
- return
177
+ _ = filesystemStorage.remove(key: key)
134
178
  case "preferences":
135
179
  fallthrough
136
180
  default:
@@ -158,10 +202,9 @@ public class StrataStoragePlugin: CAPPlugin {
158
202
  case "secure":
159
203
  _ = try keychainStorage.clear(prefix: prefix)
160
204
  case "sqlite":
161
- _ = sqliteStorage.clear(prefix: prefix)
205
+ _ = sqliteStore(for: call).clear(table: sqliteTableName(from: call.getString("table")), prefix: prefix)
162
206
  case "filesystem":
163
- rejectUnsupportedFilesystem(call)
164
- return
207
+ _ = filesystemStorage.clear(prefix: prefix)
165
208
  case "preferences":
166
209
  fallthrough
167
210
  default:
@@ -188,10 +231,9 @@ public class StrataStoragePlugin: CAPPlugin {
188
231
  case "secure":
189
232
  keys = try keychainStorage.keys(pattern: pattern)
190
233
  case "sqlite":
191
- keys = sqliteStorage.keys(pattern: pattern)
234
+ keys = sqliteStore(for: call).keys(table: sqliteTableName(from: call.getString("table")), pattern: pattern)
192
235
  case "filesystem":
193
- rejectUnsupportedFilesystem(call)
194
- return
236
+ keys = filesystemStorage.keys(pattern: pattern)
195
237
  case "preferences":
196
238
  fallthrough
197
239
  default:
@@ -207,32 +249,45 @@ public class StrataStoragePlugin: CAPPlugin {
207
249
  }
208
250
 
209
251
  /**
210
- * Get storage size information
252
+ * Get storage size information.
253
+ * When `detailed` is true, resolves `{ total, count, detailed: { keys,
254
+ * values, metadata } }`; otherwise `{ total, count }`.
211
255
  */
212
256
  @objc func size(_ call: CAPPluginCall) {
213
257
  let storage = call.getString("storage") ?? "preferences"
258
+ let detailed = call.getBool("detailed") ?? false
214
259
 
215
260
  do {
216
- let sizeInfo: (total: Int, count: Int)
261
+ // segments carries total/count/keys/values/metadata in bytes.
262
+ let segments: [String: Int]
217
263
 
218
264
  switch storage {
219
265
  case "secure":
220
- sizeInfo = try keychainStorage.size()
266
+ let info = try keychainStorage.size()
267
+ segments = ["total": info.total, "count": info.count, "keys": 0, "values": info.total, "metadata": 0]
221
268
  case "sqlite":
222
- sizeInfo = try sqliteStorage.size()
269
+ segments = try sqliteStore(for: call).size(table: sqliteTableName(from: call.getString("table")))
223
270
  case "filesystem":
224
- rejectUnsupportedFilesystem(call)
225
- return
271
+ segments = filesystemStorage.size()
226
272
  case "preferences":
227
273
  fallthrough
228
274
  default:
229
- sizeInfo = userDefaultsStorage.size()
275
+ let info = userDefaultsStorage.size()
276
+ segments = ["total": info.total, "count": info.count, "keys": 0, "values": info.total, "metadata": 0]
230
277
  }
231
278
 
232
- call.resolve([
233
- "total": sizeInfo.total,
234
- "count": sizeInfo.count
235
- ])
279
+ var result: [String: Any] = [
280
+ "total": segments["total"] ?? 0,
281
+ "count": segments["count"] ?? 0
282
+ ]
283
+ if detailed {
284
+ result["detailed"] = [
285
+ "keys": segments["keys"] ?? 0,
286
+ "values": segments["values"] ?? 0,
287
+ "metadata": segments["metadata"] ?? 0
288
+ ]
289
+ }
290
+ call.resolve(result)
236
291
  } catch {
237
292
  call.reject("Failed to get size", nil, error)
238
293
  }
@@ -241,7 +296,7 @@ public class StrataStoragePlugin: CAPPlugin {
241
296
  /**
242
297
  * Query SQLite-backed storage.
243
298
  * Matches the optional `query` method in the JS contract:
244
- * resolves `{ results: [{ key, value }] }`.
299
+ * resolves `{ results: [{ key }] }` (JS re-fetches each value via get).
245
300
  */
246
301
  @objc func query(_ call: CAPPluginCall) {
247
302
  let storage = call.getString("storage") ?? "sqlite"
@@ -252,7 +307,10 @@ public class StrataStoragePlugin: CAPPlugin {
252
307
  }
253
308
 
254
309
  let condition = call.getObject("condition") ?? [:]
255
- let results = sqliteStorage.query(condition: condition)
310
+ let results = sqliteStore(for: call).query(
311
+ table: sqliteTableName(from: call.getString("table")),
312
+ condition: condition
313
+ )
256
314
  call.resolve([
257
315
  "results": results
258
316
  ])
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strata-storage",
3
- "version": "2.5.0",
3
+ "version": "2.6.1",
4
4
  "description": "Zero-dependency universal storage plugin providing a unified API for all storage operations across web, Android, and iOS platforms",
5
5
  "type": "module",
6
6
  "main": "./index.js",
package/ios/AGENTS.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # AGENTS.md — ios/
2
2
 
3
- Last Updated: 2026-04-03
3
+ Last Updated: 2026-05-27
4
4
 
5
5
  > Agent instructions for iOS native development.
6
6
 
@@ -11,7 +11,16 @@ Last Updated: 2026-04-03
11
11
  | `Plugin/StrataStoragePlugin.swift` | Capacitor plugin entry |
12
12
  | `Plugin/UserDefaultsStorage.swift` | General key-value storage |
13
13
  | `Plugin/KeychainStorage.swift` | Secure storage |
14
- | `Plugin/SQLiteStorage.swift` | Database storage (~11KB) |
14
+ | `Plugin/SQLiteStorage.swift` | SQLite database storage — multi-store (v2.6.0), `database`/`table` options honoured, full `StorageValue` wrapper round-trip, `size(detailed)` supported |
15
+ | `Plugin/FilesystemStorage.swift` | File-per-key native storage under `NSDocumentsDirectory/strata_storage/` (new in v2.6.0); atomic writes via staging rename; `isAvailable()` returns `true` |
16
+
17
+ ## v2.6.0 Notes
18
+
19
+ - **SQLite multi-store:** `database` option → distinct `.db` file. `table` option → sanitised table identifier `[A-Za-z0-9_]`. Previously both were ignored and all adapters shared one table.
20
+ - **SQLite value shape:** `get` now returns the full `StorageValue` wrapper (`value`, `created`, `updated`, `expires`, `tags`, `metadata`). Corrupt rows are treated as a miss.
21
+ - **SQLite bind safety:** text/blob binds use `SQLITE_TRANSIENT` — removes a latent use-after-free for transient Swift buffers.
22
+ - **FilesystemStorage.swift:** new file. Writes are atomic (staging temp → rename). `keys()` excludes `.staging/` artifacts. `size(detailed: true)` returns `{ keys, values, metadata }`.
23
+ - **Pending on-device verification** — see `docs/guides/platforms/device-verification.md`.
15
24
 
16
25
  ## Agent Rules
17
26
 
@@ -20,14 +29,20 @@ Last Updated: 2026-04-03
20
29
  - NEVER store secrets in `UserDefaultsStorage`
21
30
 
22
31
  ### SQL Safety
23
- - ALWAYS use parameterized queries in SQLiteStorage
32
+ - ALWAYS use parameterized queries in `SQLiteStorage`
24
33
  - NEVER string-interpolate SQL values
34
+ - Use `SQLITE_TRANSIENT` for text/blob binds
35
+
36
+ ### Filesystem Safety
37
+ - NEVER read from `strata_storage/.staging/` — staging files are transient
38
+ - Key sanitisation must be consistent between `set`, `get`, `remove`, and `keys`
25
39
 
26
40
  ### Plugin Architecture
27
41
  - Methods exposed through `StrataStoragePlugin.swift`
28
42
  - Each storage backend is a separate Swift file
29
43
  - Bridges Capacitor JS calls to native Swift
44
+ - The `CAP_PLUGIN` macro must include every callable method name
30
45
 
31
46
  ### Before Modifying
32
47
  - Understand Capacitor plugin protocol
33
- - Test on iOS simulator after changes
48
+ - Test on iOS simulator after changes; follow device-verification guide
package/ios/CLAUDE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # CLAUDE.md — ios/
2
2
 
3
- Last Updated: 2026-04-03
3
+ Last Updated: 2026-05-27
4
4
 
5
5
  ## iOS Native Plugin
6
6
 
@@ -13,7 +13,8 @@ Swift implementation of native storage backends for Capacitor.
13
13
  | `Plugin/StrataStoragePlugin.swift` | Main Capacitor plugin entry point |
14
14
  | `Plugin/UserDefaultsStorage.swift` | UserDefaults-based general storage |
15
15
  | `Plugin/KeychainStorage.swift` | Keychain-based secure storage |
16
- | `Plugin/SQLiteStorage.swift` | SQLite database storage (~11KB) |
16
+ | `Plugin/SQLiteStorage.swift` | SQLite database storage (multi-store, v2.6.0) |
17
+ | `Plugin/FilesystemStorage.swift` | File-per-key storage under `NSDocumentsDirectory/strata_storage/` (new in v2.6.0) |
17
18
 
18
19
  ### Configuration
19
20
 
@@ -21,6 +22,30 @@ Swift implementation of native storage backends for Capacitor.
21
22
  - Minimum iOS version: defined in podspec
22
23
  - Language: Swift
23
24
 
25
+ ## v2.6.0 Changes
26
+
27
+ ### SQLite multi-store
28
+ `SQLiteStorage.swift` now accepts `database` and `table` parameters from the
29
+ Capacitor call options. Each unique `(database, table)` pair opens a separate
30
+ `.db` file in the app's documents directory. Table identifiers are sanitised to
31
+ `[A-Za-z0-9_]` before use in SQL. The full `StorageValue` wrapper is serialised
32
+ to JSON and stored in a single `value` column; native `get` deserialises and
33
+ returns the full wrapper so TTL, tags, and metadata survive the round-trip.
34
+ Text/blob binds use `SQLITE_TRANSIENT` (removes a latent use-after-free for
35
+ transient Swift buffers). `size(detailed: true)` returns per-column byte
36
+ breakdown.
37
+
38
+ ### FilesystemStorage.swift (new)
39
+ Stores each key as `NSDocumentsDirectory/strata_storage/<sanitised-key>.json`.
40
+ Writes are atomic: the value is first written to
41
+ `strata_storage/.staging/<key>.tmp`, then renamed into place. Temp files live in
42
+ the staging subdirectory, so they are never exposed by `keys()` and are not
43
+ deleted by a targeted `remove()`. `isAvailable()` returns `true` on device.
44
+ `size(detailed: true)` supported.
45
+
46
+ > **Pending on-device verification** — native code is complete and reviewed; see
47
+ > `docs/guides/platforms/device-verification.md` for the test matrix.
48
+
24
49
  ## Rules
25
50
 
26
51
  ### Security (IRON-SOLID)
@@ -32,15 +57,25 @@ Swift implementation of native storage backends for Capacitor.
32
57
  - All native methods are exposed through `StrataStoragePlugin.swift`
33
58
  - Plugin bridges Capacitor JS calls to native Swift implementations
34
59
  - Each storage backend is a separate Swift file
60
+ - The `CAP_PLUGIN` macro must list every callable method; missing entries cause
61
+ silent `undefined` returns on the JS side
35
62
 
36
63
  ### SQLite
37
- - `SQLiteStorage.swift` is the largest file (~11KB)
64
+ - `SQLiteStorage.swift` opens or creates the `.db` file identified by the
65
+ `database` call option (default `strata_storage.db`)
38
66
  - Handles table creation, migrations, and CRUD operations
39
67
  - Use parameterized queries — NEVER string-interpolate SQL
68
+ - Use `SQLITE_TRANSIENT` for text/blob binds
69
+
70
+ ### Filesystem
71
+ - Key strings are sanitised before use as filenames (`/` → `_`, etc.)
72
+ - Staging renames ensure atomic writes; never read from `.staging/`
73
+ - `keys()` lists `.json` files only, ignoring staging artifacts
40
74
 
41
75
  ### Testing
42
76
  - Native code is tested through the Capacitor bridge
43
- - Test via the demo app on iOS simulator
77
+ - Test via the demo app on iOS simulator following
78
+ `docs/guides/platforms/device-verification.md`
44
79
  - Verify all adapter methods work end-to-end
45
80
 
46
81
  ### Before Modifying
@@ -0,0 +1,218 @@
1
+ import Foundation
2
+ import os.log
3
+
4
+ /**
5
+ * Filesystem-backed storage: one file per key under
6
+ * `<Documents>/strata_storage/`.
7
+ *
8
+ * Value-shape contract (matches src/plugin/definitions.ts, same as SQLite):
9
+ * - File contents are the JSON-serialized FULL `StorageValue` wrapper
10
+ * `{ value, created, updated, expires?, tags?, metadata?, ... }`.
11
+ * - `get` parses the bytes back into the wrapper object; the plugin resolves
12
+ * `{ value: wrapper }`. Missing file OR non-object bytes → miss (no crash).
13
+ *
14
+ * File naming: the storage key is percent-encoded with an allowed set that
15
+ * EXCLUDES `/` (and `%`), so the encoding is reversible and never escapes the
16
+ * storage directory. `decodeFileName` reverses it for `keys()`.
17
+ *
18
+ * Durability: writes go to a temp file in the same directory and are then
19
+ * atomically replaced into place, so a crash mid-write cannot leave a torn
20
+ * file at the real path.
21
+ */
22
+ @objc public class FilesystemStorage: NSObject {
23
+ private let directory: URL
24
+ private let stagingDirectory: URL
25
+ private let logger = OSLog(subsystem: "com.strata.storage", category: "FilesystemStorage")
26
+
27
+ /// Reserved staging subdirectory name (inside the storage dir) holding
28
+ /// in-flight temp files for atomic writes. Enumeration skips it by name, so
29
+ /// a temp file can never collide with an encoded key file (e.g. a real key
30
+ /// literally named ".tmp-x").
31
+ private static let stagingDirName = ".strata-staging"
32
+
33
+ /// Characters allowed verbatim in an on-disk file name. Anything else
34
+ /// (notably `/` and `%`) is percent-escaped, keeping the name reversible
35
+ /// and confined to a single path component.
36
+ private static let fileNameAllowed: CharacterSet = {
37
+ var set = CharacterSet.alphanumerics
38
+ set.insert(charactersIn: "-_. ")
39
+ return set
40
+ }()
41
+
42
+ @objc public override init() {
43
+ let base = (try? FileManager.default.url(
44
+ for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true
45
+ )) ?? URL(fileURLWithPath: NSTemporaryDirectory())
46
+ let storageDir = base.appendingPathComponent("strata_storage", isDirectory: true)
47
+ self.directory = storageDir
48
+ self.stagingDirectory = storageDir.appendingPathComponent(FilesystemStorage.stagingDirName, isDirectory: true)
49
+ super.init()
50
+ ensureDirectory()
51
+ }
52
+
53
+ private func ensureDirectory() {
54
+ for dir in [directory, stagingDirectory] {
55
+ if !FileManager.default.fileExists(atPath: dir.path) {
56
+ do {
57
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
58
+ } catch {
59
+ os_log("Failed to create filesystem storage dir: %{public}@", log: logger, type: .error, error.localizedDescription)
60
+ }
61
+ }
62
+ }
63
+ }
64
+
65
+ /// Reversible encoding of a storage key → safe single-component file name.
66
+ private func encodeFileName(_ key: String) -> String {
67
+ // addingPercentEncoding only returns nil for invalid unichar sequences,
68
+ // which a Swift String key cannot contain; fall back defensively.
69
+ return key.addingPercentEncoding(withAllowedCharacters: FilesystemStorage.fileNameAllowed) ?? key
70
+ }
71
+
72
+ /// Reverses `encodeFileName`. Returns nil if the name cannot be decoded.
73
+ private func decodeFileName(_ name: String) -> String? {
74
+ return name.removingPercentEncoding
75
+ }
76
+
77
+ private func fileURL(forKey key: String) -> URL {
78
+ return directory.appendingPathComponent(encodeFileName(key), isDirectory: false)
79
+ }
80
+
81
+ /**
82
+ * Persist the full `StorageValue` wrapper for `key`. The wrapper is
83
+ * JSON-encoded and written atomically (temp file + replace).
84
+ */
85
+ @objc public func set(key: String, wrapper: [String: Any]) throws -> Bool {
86
+ ensureDirectory()
87
+ let data = try JSONSerialization.data(withJSONObject: wrapper, options: [])
88
+ let target = fileURL(forKey: key)
89
+
90
+ // Atomic write: stage to a unique temp file in the reserved staging
91
+ // subdir (same volume → atomic rename), then replace. The staging subdir
92
+ // keeps temp names out of the key namespace entirely.
93
+ let temp = stagingDirectory.appendingPathComponent(UUID().uuidString, isDirectory: false)
94
+ do {
95
+ try data.write(to: temp, options: .atomic)
96
+ if FileManager.default.fileExists(atPath: target.path) {
97
+ _ = try FileManager.default.replaceItemAt(target, withItemAt: temp)
98
+ } else {
99
+ try FileManager.default.moveItem(at: temp, to: target)
100
+ }
101
+ } catch {
102
+ // Clean up the temp file on any failure so we don't leak partials.
103
+ try? FileManager.default.removeItem(at: temp)
104
+ throw error
105
+ }
106
+ return true
107
+ }
108
+
109
+ /**
110
+ * Read the wrapper object for `key`. Missing file OR bytes that are not a
111
+ * JSON object → nil (treated as a miss; never throws on corrupt data).
112
+ */
113
+ @objc public func get(key: String) -> [String: Any]? {
114
+ let url = fileURL(forKey: key)
115
+ guard let data = try? Data(contentsOf: url) else {
116
+ return nil
117
+ }
118
+ guard let wrapper = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
119
+ return nil
120
+ }
121
+ return wrapper
122
+ }
123
+
124
+ @objc public func remove(key: String) -> Bool {
125
+ let url = fileURL(forKey: key)
126
+ if !FileManager.default.fileExists(atPath: url.path) {
127
+ return true // already absent — treat as success/idempotent
128
+ }
129
+ do {
130
+ try FileManager.default.removeItem(at: url)
131
+ return true
132
+ } catch {
133
+ os_log("Failed to remove filesystem key: %{public}@", log: logger, type: .error, error.localizedDescription)
134
+ return false
135
+ }
136
+ }
137
+
138
+ /// Clears stored entries. When `prefix` is set, only keys starting with it
139
+ /// are removed; otherwise the whole storage directory contents are removed.
140
+ @objc public func clear(prefix: String? = nil) -> Bool {
141
+ let names = (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? []
142
+ var ok = true
143
+ for name in names {
144
+ // Skip the staging subdirectory (never a key).
145
+ if name == FilesystemStorage.stagingDirName { continue }
146
+ guard let key = decodeFileName(name) else { continue }
147
+ if let prefix = prefix, !key.hasPrefix(prefix) { continue }
148
+ do {
149
+ try FileManager.default.removeItem(at: directory.appendingPathComponent(name, isDirectory: false))
150
+ } catch {
151
+ ok = false
152
+ }
153
+ }
154
+ // On a full clear, also drop any orphaned in-flight temp files.
155
+ if prefix == nil, let temps = try? FileManager.default.contentsOfDirectory(atPath: stagingDirectory.path) {
156
+ for temp in temps {
157
+ try? FileManager.default.removeItem(at: stagingDirectory.appendingPathComponent(temp, isDirectory: false))
158
+ }
159
+ }
160
+ return ok
161
+ }
162
+
163
+ /// Lists stored keys, decoding file names back to their original keys.
164
+ /// When `pattern` is set, applies the same prefix/contains matching the
165
+ /// other backends use.
166
+ @objc public func keys(pattern: String? = nil) -> [String] {
167
+ let names = (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? []
168
+ var keys: [String] = []
169
+ for name in names {
170
+ if name == FilesystemStorage.stagingDirName { continue }
171
+ guard let key = decodeFileName(name) else { continue }
172
+ keys.append(key)
173
+ }
174
+ guard let pattern = pattern else { return keys }
175
+ return keys.filter { $0.hasPrefix(pattern) || $0.contains(pattern) }
176
+ }
177
+
178
+ /**
179
+ * Aggregate sizes over the storage directory.
180
+ *
181
+ * `count` = number of entry files; `values` = sum of file byte sizes;
182
+ * `keys` = sum of decoded-key UTF-8 byte lengths; `metadata` = sum of the
183
+ * serialized `metadata` segment per file (0 when absent or unparseable);
184
+ * `total` = `keys` + `values`.
185
+ */
186
+ @objc public func size() -> [String: Int] {
187
+ let names = (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? []
188
+ var count = 0
189
+ var keysSize = 0
190
+ var valuesSize = 0
191
+ var metadataSize = 0
192
+
193
+ for name in names {
194
+ if name == FilesystemStorage.stagingDirName { continue }
195
+ guard let key = decodeFileName(name) else { continue }
196
+ let url = directory.appendingPathComponent(name, isDirectory: false)
197
+ count += 1
198
+ keysSize += key.data(using: .utf8)?.count ?? 0
199
+
200
+ if let data = try? Data(contentsOf: url) {
201
+ valuesSize += data.count
202
+ if let wrapper = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
203
+ let metadata = wrapper["metadata"],
204
+ let metadataData = try? JSONSerialization.data(withJSONObject: metadata, options: []) {
205
+ metadataSize += metadataData.count
206
+ }
207
+ }
208
+ }
209
+
210
+ return [
211
+ "count": count,
212
+ "keys": keysSize,
213
+ "values": valuesSize,
214
+ "metadata": metadataSize,
215
+ "total": keysSize + valuesSize
216
+ ]
217
+ }
218
+ }