strata-storage 2.5.0 → 2.6.0
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.
- package/android/AGENTS.md +24 -7
- package/android/CLAUDE.md +42 -4
- package/android/build.gradle +1 -1
- package/android/src/main/java/com/strata/storage/FilesystemStorage.java +287 -0
- package/android/src/main/java/com/strata/storage/SQLiteStorage.java +243 -221
- package/android/src/main/java/com/stratastorage/StrataStoragePlugin.java +202 -78
- package/dist/android/AGENTS.md +24 -7
- package/dist/android/CLAUDE.md +42 -4
- package/dist/android/build.gradle +1 -1
- package/dist/android/src/main/java/com/strata/storage/FilesystemStorage.java +287 -0
- package/dist/android/src/main/java/com/strata/storage/SQLiteStorage.java +243 -221
- package/dist/android/src/main/java/com/stratastorage/StrataStoragePlugin.java +202 -78
- package/dist/ios/AGENTS.md +19 -4
- package/dist/ios/CLAUDE.md +39 -4
- package/dist/ios/Plugin/FilesystemStorage.swift +218 -0
- package/dist/ios/Plugin/SQLiteStorage.swift +265 -173
- package/dist/ios/Plugin/StrataStoragePlugin.swift +100 -42
- package/dist/package.json +1 -1
- package/ios/AGENTS.md +19 -4
- package/ios/CLAUDE.md +39 -4
- package/ios/Plugin/FilesystemStorage.swift +218 -0
- package/ios/Plugin/SQLiteStorage.swift +265 -173
- package/ios/Plugin/StrataStoragePlugin.swift +100 -42
- package/package.json +1 -1
|
@@ -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
|
|
16
|
+
private let filesystemStorage = FilesystemStorage()
|
|
17
17
|
|
|
18
18
|
/// Storage types that have a real native backend on iOS.
|
|
19
|
-
|
|
20
|
-
|
|
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 =
|
|
84
|
+
value = sqliteStore(for: call).get(table: sqliteTableName(from: call.getString("table")), key: key)
|
|
60
85
|
case "filesystem":
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
_ =
|
|
175
|
+
_ = sqliteStore(for: call).remove(table: sqliteTableName(from: call.getString("table")), key: key)
|
|
131
176
|
case "filesystem":
|
|
132
|
-
|
|
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
|
-
_ =
|
|
205
|
+
_ = sqliteStore(for: call).clear(table: sqliteTableName(from: call.getString("table")), prefix: prefix)
|
|
162
206
|
case "filesystem":
|
|
163
|
-
|
|
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 =
|
|
234
|
+
keys = sqliteStore(for: call).keys(table: sqliteTableName(from: call.getString("table")), pattern: pattern)
|
|
192
235
|
case "filesystem":
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
+
segments = try sqliteStore(for: call).size(table: sqliteTableName(from: call.getString("table")))
|
|
223
270
|
case "filesystem":
|
|
224
|
-
|
|
225
|
-
return
|
|
271
|
+
segments = filesystemStorage.size()
|
|
226
272
|
case "preferences":
|
|
227
273
|
fallthrough
|
|
228
274
|
default:
|
|
229
|
-
|
|
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
|
-
|
|
233
|
-
"total":
|
|
234
|
-
"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
|
|
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 =
|
|
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.
|
|
3
|
+
"version": "2.6.0",
|
|
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-
|
|
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` |
|
|
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-
|
|
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 (
|
|
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`
|
|
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
|
+
}
|