strata-storage 2.6.1 → 2.7.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/AI-INTEGRATION-GUIDE.md +88 -10
- package/README.md +42 -64
- package/android/AGENTS.md +18 -3
- package/android/CLAUDE.md +36 -3
- package/android/build.gradle +5 -1
- package/android/src/main/java/com/strata/storage/EncryptedStorage.java +31 -18
- package/android/src/main/java/com/strata/storage/FilesystemStorage.java +62 -10
- package/android/src/main/java/com/strata/storage/SQLiteStorage.java +65 -13
- package/android/src/main/java/com/strata/storage/SharedPreferencesStorage.java +63 -51
- package/android/src/main/java/com/stratastorage/StrataStoragePlugin.java +80 -11
- package/dist/README.md +42 -64
- package/dist/adapters/capacitor/FilesystemAdapter.d.ts +2 -2
- package/dist/adapters/capacitor/FilesystemAdapter.d.ts.map +1 -1
- package/dist/adapters/capacitor/FilesystemAdapter.js +6 -2
- package/dist/adapters/capacitor/PreferencesAdapter.d.ts +2 -2
- package/dist/adapters/capacitor/PreferencesAdapter.d.ts.map +1 -1
- package/dist/adapters/capacitor/PreferencesAdapter.js +6 -2
- package/dist/adapters/capacitor/SecureAdapter.d.ts +3 -3
- package/dist/adapters/capacitor/SecureAdapter.d.ts.map +1 -1
- package/dist/adapters/capacitor/SecureAdapter.js +6 -2
- package/dist/adapters/capacitor/SqliteAdapter.d.ts +24 -3
- package/dist/adapters/capacitor/SqliteAdapter.d.ts.map +1 -1
- package/dist/adapters/capacitor/SqliteAdapter.js +105 -23
- package/dist/adapters/capacitor/index.d.ts +4 -4
- package/dist/adapters/web/CacheAdapter.d.ts +2 -2
- package/dist/adapters/web/CookieAdapter.d.ts +2 -2
- package/dist/adapters/web/IndexedDBAdapter.d.ts +10 -3
- package/dist/adapters/web/IndexedDBAdapter.d.ts.map +1 -1
- package/dist/adapters/web/IndexedDBAdapter.js +14 -3
- package/dist/adapters/web/LocalStorageAdapter.d.ts +2 -2
- package/dist/adapters/web/MemoryAdapter.d.ts +2 -2
- package/dist/adapters/web/SessionStorageAdapter.d.ts +10 -10
- package/dist/adapters/web/SessionStorageAdapter.d.ts.map +1 -1
- package/dist/adapters/web/SessionStorageAdapter.js +3 -2
- package/dist/adapters/web/URLAdapter.d.ts +2 -2
- package/dist/adapters/web/index.d.ts +7 -7
- package/dist/capacitor.d.ts +6 -6
- package/dist/capacitor.d.ts.map +1 -1
- package/dist/capacitor.js +5 -4
- package/dist/core/AdapterRegistry.d.ts +1 -1
- package/dist/core/BaseAdapter.d.ts +21 -5
- package/dist/core/BaseAdapter.d.ts.map +1 -1
- package/dist/core/BaseAdapter.js +29 -1
- package/dist/core/Strata.d.ts +41 -7
- package/dist/core/Strata.d.ts.map +1 -1
- package/dist/core/Strata.js +476 -118
- package/dist/features/compression/index.d.ts +1 -1
- package/dist/features/compression.d.ts +5 -1
- package/dist/features/compression.d.ts.map +1 -1
- package/dist/features/encryption/index.d.ts +1 -1
- package/dist/features/encryption.d.ts +55 -2
- package/dist/features/encryption.d.ts.map +1 -1
- package/dist/features/encryption.js +139 -26
- package/dist/features/migration.d.ts +1 -1
- package/dist/features/observer/index.d.ts +1 -1
- package/dist/features/observer.d.ts +1 -1
- package/dist/features/query/index.d.ts +1 -1
- package/dist/features/query.d.ts +1 -1
- package/dist/features/query.d.ts.map +1 -1
- package/dist/features/query.js +3 -1
- package/dist/features/sync/index.d.ts +1 -1
- package/dist/features/sync.d.ts +19 -3
- package/dist/features/sync.d.ts.map +1 -1
- package/dist/features/sync.js +57 -9
- package/dist/features/ttl/index.d.ts +1 -1
- package/dist/features/ttl.d.ts +2 -2
- package/dist/firebase.d.ts +1 -1
- package/dist/firebase.d.ts.map +1 -1
- package/dist/firebase.js +34 -9
- package/dist/index.d.ts +24 -22
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/integrations/angular/index.d.ts +2 -2
- package/dist/integrations/angular/index.d.ts.map +1 -1
- package/dist/integrations/angular/index.js +7 -8
- package/dist/integrations/react/index.d.ts +3 -3
- package/dist/integrations/react/index.d.ts.map +1 -1
- package/dist/integrations/react/index.js +16 -4
- package/dist/integrations/vue/index.d.ts +2 -2
- package/dist/integrations/vue/index.d.ts.map +1 -1
- package/dist/integrations/vue/index.js +5 -2
- package/dist/package.json +11 -2
- package/dist/plugin/definitions.d.ts +29 -3
- package/dist/plugin/definitions.d.ts.map +1 -1
- package/dist/plugin/index.d.ts +2 -2
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +3 -0
- package/dist/plugin/web.d.ts +11 -2
- package/dist/plugin/web.d.ts.map +1 -1
- package/dist/plugin/web.js +23 -4
- package/dist/types/index.d.ts +50 -20
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -10
- package/dist/utils/index.d.ts +5 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +7 -0
- package/ios/AGENTS.md +14 -3
- package/ios/CLAUDE.md +25 -3
- package/ios/Plugin/FilesystemStorage.swift +19 -0
- package/ios/Plugin/SQLiteStorage.swift +59 -9
- package/ios/Plugin/StrataStoragePlugin.m +1 -0
- package/ios/Plugin/StrataStoragePlugin.swift +87 -34
- package/ios/Plugin/UserDefaultsStorage.swift +47 -46
- package/package.json +27 -24
- package/scripts/build.js +16 -14
- package/scripts/cli.js +1 -1
- package/scripts/configure.js +2 -2
- package/scripts/postinstall.js +3 -2
- package/dist/android/AGENTS.md +0 -51
- package/dist/android/CLAUDE.md +0 -89
- package/dist/android/build.gradle +0 -58
- package/dist/android/proguard-rules.pro +0 -11
- package/dist/android/settings.gradle +0 -2
- package/dist/android/src/main/AndroidManifest.xml +0 -2
- package/dist/android/src/main/java/com/strata/storage/EncryptedStorage.java +0 -218
- package/dist/android/src/main/java/com/strata/storage/FilesystemStorage.java +0 -287
- package/dist/android/src/main/java/com/strata/storage/SQLiteStorage.java +0 -371
- package/dist/android/src/main/java/com/strata/storage/SharedPreferencesStorage.java +0 -209
- package/dist/android/src/main/java/com/stratastorage/StrataStoragePlugin.java +0 -616
- package/dist/core/StorageStrategy.d.ts +0 -55
- package/dist/core/StorageStrategy.d.ts.map +0 -1
- package/dist/core/StorageStrategy.js +0 -200
- package/dist/ios/AGENTS.md +0 -48
- package/dist/ios/CLAUDE.md +0 -84
- package/dist/ios/Plugin/FilesystemStorage.swift +0 -218
- package/dist/ios/Plugin/KeychainStorage.swift +0 -233
- package/dist/ios/Plugin/SQLiteStorage.swift +0 -426
- package/dist/ios/Plugin/StrataStoragePlugin.m +0 -23
- package/dist/ios/Plugin/StrataStoragePlugin.swift +0 -411
- package/dist/ios/Plugin/UserDefaultsStorage.swift +0 -87
|
@@ -1,233 +0,0 @@
|
|
|
1
|
-
import Foundation
|
|
2
|
-
import Security
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Keychain-backed secure storage.
|
|
6
|
-
*
|
|
7
|
-
* Security notes:
|
|
8
|
-
* - Items default to `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`,
|
|
9
|
-
* which keeps data available to background tasks after the first unlock
|
|
10
|
-
* while preventing it from leaving the device via encrypted backups.
|
|
11
|
-
* `kSecAttrAccessibleAlways` / `kSecAttrAccessibleAlwaysThisDeviceOnly`
|
|
12
|
-
* are deprecated and insecure and are intentionally NOT used.
|
|
13
|
-
* - Callers may pass a stricter accessibility class from JS via the
|
|
14
|
-
* `accessible` option (see KeychainAccessible in definitions.ts).
|
|
15
|
-
* - Secret values are never logged.
|
|
16
|
-
*/
|
|
17
|
-
@objc public class KeychainStorage: NSObject {
|
|
18
|
-
private let service: String
|
|
19
|
-
private let accessGroup: String?
|
|
20
|
-
private let defaultAccessible: CFString
|
|
21
|
-
|
|
22
|
-
@objc public init(service: String? = nil, accessGroup: String? = nil) {
|
|
23
|
-
self.service = service ?? Bundle.main.bundleIdentifier ?? "StrataStorage"
|
|
24
|
-
self.accessGroup = accessGroup
|
|
25
|
-
// After-first-unlock + this-device-only is a safe, widely-recommended
|
|
26
|
-
// default for app secrets. It is more permissive than whenUnlocked
|
|
27
|
-
// (so background access works) but still excluded from backups.
|
|
28
|
-
self.defaultAccessible = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
29
|
-
super.init()
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/// Maps a JS `KeychainAccessible` string to the matching Security framework
|
|
33
|
-
/// constant. Unknown / nil values fall back to the secure default.
|
|
34
|
-
private func accessibleConstant(for raw: String?) -> CFString {
|
|
35
|
-
switch raw {
|
|
36
|
-
case "whenUnlocked":
|
|
37
|
-
return kSecAttrAccessibleWhenUnlocked
|
|
38
|
-
case "afterFirstUnlock":
|
|
39
|
-
return kSecAttrAccessibleAfterFirstUnlock
|
|
40
|
-
case "whenUnlockedThisDeviceOnly":
|
|
41
|
-
return kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
|
42
|
-
case "afterFirstUnlockThisDeviceOnly":
|
|
43
|
-
return kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
44
|
-
case "whenPasscodeSetThisDeviceOnly":
|
|
45
|
-
return kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
|
|
46
|
-
default:
|
|
47
|
-
return defaultAccessible
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/// Builds an NSError that surfaces the OSStatus to the JS bridge without
|
|
52
|
-
/// leaking any stored secret value.
|
|
53
|
-
private func keychainError(_ status: OSStatus, operation: String) -> NSError {
|
|
54
|
-
let message: String
|
|
55
|
-
if #available(iOS 11.3, *), let str = SecCopyErrorMessageString(status, nil) as String? {
|
|
56
|
-
message = str
|
|
57
|
-
} else {
|
|
58
|
-
message = "OSStatus \(status)"
|
|
59
|
-
}
|
|
60
|
-
return NSError(
|
|
61
|
-
domain: "StrataStorage.KeychainStorage",
|
|
62
|
-
code: Int(status),
|
|
63
|
-
userInfo: [NSLocalizedDescriptionKey: "Keychain \(operation) failed: \(message)"]
|
|
64
|
-
)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
@objc public func set(key: String, value: Any, accessible: String? = nil) throws -> Bool {
|
|
68
|
-
let data: Data
|
|
69
|
-
|
|
70
|
-
if let dataValue = value as? Data {
|
|
71
|
-
data = dataValue
|
|
72
|
-
} else if let stringValue = value as? String {
|
|
73
|
-
data = stringValue.data(using: .utf8) ?? Data()
|
|
74
|
-
} else {
|
|
75
|
-
// Convert to JSON for complex objects
|
|
76
|
-
let jsonData = try JSONSerialization.data(withJSONObject: value, options: [])
|
|
77
|
-
data = jsonData
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Remove any existing item first so the accessibility class is applied
|
|
81
|
-
// cleanly (SecItemUpdate cannot change accessibility reliably).
|
|
82
|
-
let deleteQuery = createQuery(key: key)
|
|
83
|
-
SecItemDelete(deleteQuery as CFDictionary)
|
|
84
|
-
|
|
85
|
-
var newItem = createQuery(key: key, accessible: accessibleConstant(for: accessible))
|
|
86
|
-
newItem[kSecValueData as String] = data
|
|
87
|
-
|
|
88
|
-
let status = SecItemAdd(newItem as CFDictionary, nil)
|
|
89
|
-
guard status == errSecSuccess else {
|
|
90
|
-
throw keychainError(status, operation: "set")
|
|
91
|
-
}
|
|
92
|
-
return true
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Convenience overload kept for existing Objective-C callers.
|
|
96
|
-
@objc public func set(key: String, value: Any) throws -> Bool {
|
|
97
|
-
return try set(key: key, value: value, accessible: nil)
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
@objc public func get(key: String) throws -> Any? {
|
|
101
|
-
var query = createQuery(key: key)
|
|
102
|
-
query[kSecReturnData as String] = true
|
|
103
|
-
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
104
|
-
|
|
105
|
-
var result: AnyObject?
|
|
106
|
-
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
107
|
-
|
|
108
|
-
if status == errSecItemNotFound {
|
|
109
|
-
return nil
|
|
110
|
-
}
|
|
111
|
-
guard status == errSecSuccess else {
|
|
112
|
-
throw keychainError(status, operation: "get")
|
|
113
|
-
}
|
|
114
|
-
guard let data = result as? Data else { return nil }
|
|
115
|
-
|
|
116
|
-
// Try to parse as JSON first, fallback to string
|
|
117
|
-
if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) {
|
|
118
|
-
return jsonObject
|
|
119
|
-
} else {
|
|
120
|
-
return String(data: data, encoding: .utf8)
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
@objc public func remove(key: String) throws -> Bool {
|
|
125
|
-
let query = createQuery(key: key)
|
|
126
|
-
let status = SecItemDelete(query as CFDictionary)
|
|
127
|
-
guard status == errSecSuccess || status == errSecItemNotFound else {
|
|
128
|
-
throw keychainError(status, operation: "remove")
|
|
129
|
-
}
|
|
130
|
-
return true
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
@objc public func clear(prefix: String? = nil) throws -> Bool {
|
|
134
|
-
if let prefix = prefix {
|
|
135
|
-
// Clear only keys with the given prefix
|
|
136
|
-
let keysToRemove = try keys(pattern: prefix)
|
|
137
|
-
for key in keysToRemove {
|
|
138
|
-
_ = try remove(key: key)
|
|
139
|
-
}
|
|
140
|
-
return true
|
|
141
|
-
} else {
|
|
142
|
-
// Clear all keys for this service (and access group, if any).
|
|
143
|
-
var query: [String: Any] = [
|
|
144
|
-
kSecClass as String: kSecClassGenericPassword,
|
|
145
|
-
kSecAttrService as String: service
|
|
146
|
-
]
|
|
147
|
-
if let accessGroup = accessGroup {
|
|
148
|
-
query[kSecAttrAccessGroup as String] = accessGroup
|
|
149
|
-
}
|
|
150
|
-
let status = SecItemDelete(query as CFDictionary)
|
|
151
|
-
guard status == errSecSuccess || status == errSecItemNotFound else {
|
|
152
|
-
throw keychainError(status, operation: "clear")
|
|
153
|
-
}
|
|
154
|
-
return true
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
@objc public func keys(pattern: String? = nil) throws -> [String] {
|
|
159
|
-
var query: [String: Any] = [
|
|
160
|
-
kSecClass as String: kSecClassGenericPassword,
|
|
161
|
-
kSecAttrService as String: service,
|
|
162
|
-
kSecReturnAttributes as String: true,
|
|
163
|
-
kSecMatchLimit as String: kSecMatchLimitAll
|
|
164
|
-
]
|
|
165
|
-
|
|
166
|
-
if let accessGroup = accessGroup {
|
|
167
|
-
query[kSecAttrAccessGroup as String] = accessGroup
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
var result: AnyObject?
|
|
171
|
-
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
172
|
-
|
|
173
|
-
if status == errSecItemNotFound {
|
|
174
|
-
return []
|
|
175
|
-
}
|
|
176
|
-
guard status == errSecSuccess,
|
|
177
|
-
let items = result as? [[String: Any]] else {
|
|
178
|
-
if status == errSecSuccess { return [] }
|
|
179
|
-
throw keychainError(status, operation: "keys")
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
let allKeys = items.compactMap { $0[kSecAttrAccount as String] as? String }
|
|
183
|
-
|
|
184
|
-
guard let pattern = pattern else {
|
|
185
|
-
return allKeys
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Filter keys by pattern (simple prefix / contains matching)
|
|
189
|
-
return allKeys.filter { key in
|
|
190
|
-
key.hasPrefix(pattern) || key.contains(pattern)
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
private func createQuery(key: String, accessible: CFString? = nil) -> [String: Any] {
|
|
195
|
-
var query: [String: Any] = [
|
|
196
|
-
kSecClass as String: kSecClassGenericPassword,
|
|
197
|
-
kSecAttrService as String: service,
|
|
198
|
-
kSecAttrAccount as String: key
|
|
199
|
-
]
|
|
200
|
-
|
|
201
|
-
// Accessibility only needs to be set when adding/writing an item.
|
|
202
|
-
// Including it on read/delete queries can over-constrain matching.
|
|
203
|
-
if let accessible = accessible {
|
|
204
|
-
query[kSecAttrAccessible as String] = accessible
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
if let accessGroup = accessGroup {
|
|
208
|
-
query[kSecAttrAccessGroup as String] = accessGroup
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
return query
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
@objc public func size() throws -> (total: Int, count: Int) {
|
|
215
|
-
let allKeys = try keys()
|
|
216
|
-
var totalSize = 0
|
|
217
|
-
|
|
218
|
-
for key in allKeys {
|
|
219
|
-
if let value = try get(key: key) {
|
|
220
|
-
if let data = value as? Data {
|
|
221
|
-
totalSize += data.count
|
|
222
|
-
} else if let string = value as? String {
|
|
223
|
-
totalSize += string.data(using: .utf8)?.count ?? 0
|
|
224
|
-
} else if let jsonData = try? JSONSerialization.data(withJSONObject: value, options: []) {
|
|
225
|
-
totalSize += jsonData.count
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
totalSize += key.data(using: .utf8)?.count ?? 0
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
return (total: totalSize, count: allKeys.count)
|
|
232
|
-
}
|
|
233
|
-
}
|
|
@@ -1,426 +0,0 @@
|
|
|
1
|
-
import Foundation
|
|
2
|
-
import SQLite3
|
|
3
|
-
import os.log
|
|
4
|
-
|
|
5
|
-
/// SQLITE_TRANSIENT tells SQLite to copy bound bytes immediately, which is
|
|
6
|
-
/// required when binding Swift `String`/`Data` whose buffers may be freed
|
|
7
|
-
/// before `sqlite3_step` runs. Passing `nil` (SQLITE_STATIC) here is a
|
|
8
|
-
/// use-after-free hazard for transient Swift buffers.
|
|
9
|
-
private let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* SQLite-backed storage for one `(database, table)` pair.
|
|
13
|
-
*
|
|
14
|
-
* Multi-store design:
|
|
15
|
-
* - One `SQLiteStorage` instance owns one open DB handle for one database file.
|
|
16
|
-
* - A single instance can serve many tables in that file; tables are created
|
|
17
|
-
* on first use (see `ensureTable`).
|
|
18
|
-
* - The plugin caches one instance per database filename (see
|
|
19
|
-
* `SQLiteStorageManager`) so the DB is opened once, not per call.
|
|
20
|
-
*
|
|
21
|
-
* Value-shape contract (matches src/plugin/definitions.ts):
|
|
22
|
-
* - `set` receives the FULL `StorageValue` wrapper object
|
|
23
|
-
* `{ value, created, updated, expires?, tags?, metadata?, ... }`. The whole
|
|
24
|
-
* wrapper is JSON-serialized into the `value` column for a perfect
|
|
25
|
-
* round-trip. `created`/`updated`/`expires`/`tags`/`metadata` are ALSO
|
|
26
|
-
* mirrored into dedicated columns so native TTL cleanup + `query` can use
|
|
27
|
-
* them without re-parsing the blob.
|
|
28
|
-
* - `get` parses the `value` column bytes back into the wrapper object and the
|
|
29
|
-
* plugin resolves `{ value: wrapper }`. Corrupt/legacy bytes that do not
|
|
30
|
-
* parse as a JSON object are treated as a miss (never a crash).
|
|
31
|
-
*
|
|
32
|
-
* SQL-safety: only the table identifier is interpolated into SQL, and it is
|
|
33
|
-
* pre-sanitized to `^[A-Za-z0-9_]+$` by the plugin. All user values are bound
|
|
34
|
-
* parameters.
|
|
35
|
-
*/
|
|
36
|
-
@objc public class SQLiteStorage: NSObject {
|
|
37
|
-
private var db: OpaquePointer?
|
|
38
|
-
private let dbName: String
|
|
39
|
-
/// Tables already verified/created on this handle, to skip redundant DDL.
|
|
40
|
-
private var ensuredTables: Set<String> = []
|
|
41
|
-
private let logger = OSLog(subsystem: "com.strata.storage", category: "SQLiteStorage")
|
|
42
|
-
|
|
43
|
-
/// Opens (or creates) the database file `<dbName>` under Documents.
|
|
44
|
-
/// `dbName` MUST already be a sanitized filename (e.g. `storage.db`).
|
|
45
|
-
@objc public init(dbName: String = "strata_storage.db") {
|
|
46
|
-
self.dbName = dbName
|
|
47
|
-
super.init()
|
|
48
|
-
do {
|
|
49
|
-
try openDatabase()
|
|
50
|
-
} catch {
|
|
51
|
-
os_log("Failed to initialize SQLite storage: %{public}@", log: logger, type: .error, error.localizedDescription)
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
deinit {
|
|
56
|
-
closeDatabase()
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
private func openDatabase() throws {
|
|
60
|
-
guard let fileURL = try? FileManager.default
|
|
61
|
-
.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
|
|
62
|
-
.appendingPathComponent(dbName) else {
|
|
63
|
-
throw NSError(
|
|
64
|
-
domain: "StrataStorage.SQLiteStorage",
|
|
65
|
-
code: 1001,
|
|
66
|
-
userInfo: [NSLocalizedDescriptionKey: "Unable to access document directory"]
|
|
67
|
-
)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
guard sqlite3_open(fileURL.path, &db) == SQLITE_OK else {
|
|
71
|
-
let errorMessage = String(cString: sqlite3_errmsg(db))
|
|
72
|
-
sqlite3_close(db)
|
|
73
|
-
db = nil
|
|
74
|
-
throw NSError(
|
|
75
|
-
domain: "StrataStorage.SQLiteStorage",
|
|
76
|
-
code: 1002,
|
|
77
|
-
userInfo: [NSLocalizedDescriptionKey: "Unable to open database: \(errorMessage)"]
|
|
78
|
-
)
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
private func closeDatabase() {
|
|
83
|
-
if db != nil {
|
|
84
|
-
sqlite3_close(db)
|
|
85
|
-
db = nil
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/// Creates the table for `table` if it does not exist yet. Cached per
|
|
90
|
-
/// instance so repeated calls are cheap. `table` MUST be pre-sanitized.
|
|
91
|
-
private func ensureTable(_ table: String) throws {
|
|
92
|
-
guard let db = db else {
|
|
93
|
-
throw notInitializedError()
|
|
94
|
-
}
|
|
95
|
-
if ensuredTables.contains(table) { return }
|
|
96
|
-
|
|
97
|
-
let createTableString = """
|
|
98
|
-
CREATE TABLE IF NOT EXISTS \(table) (
|
|
99
|
-
key TEXT PRIMARY KEY NOT NULL,
|
|
100
|
-
value BLOB NOT NULL,
|
|
101
|
-
created INTEGER NOT NULL,
|
|
102
|
-
updated INTEGER NOT NULL,
|
|
103
|
-
expires INTEGER,
|
|
104
|
-
tags TEXT,
|
|
105
|
-
metadata TEXT
|
|
106
|
-
);
|
|
107
|
-
"""
|
|
108
|
-
|
|
109
|
-
var createTableStatement: OpaquePointer?
|
|
110
|
-
defer { sqlite3_finalize(createTableStatement) }
|
|
111
|
-
|
|
112
|
-
guard sqlite3_prepare_v2(db, createTableString, -1, &createTableStatement, nil) == SQLITE_OK else {
|
|
113
|
-
let errorMessage = String(cString: sqlite3_errmsg(db))
|
|
114
|
-
throw NSError(
|
|
115
|
-
domain: "StrataStorage.SQLiteStorage",
|
|
116
|
-
code: 1003,
|
|
117
|
-
userInfo: [NSLocalizedDescriptionKey: "Failed to prepare CREATE TABLE: \(errorMessage)"]
|
|
118
|
-
)
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
guard sqlite3_step(createTableStatement) == SQLITE_DONE else {
|
|
122
|
-
let errorMessage = String(cString: sqlite3_errmsg(db))
|
|
123
|
-
throw NSError(
|
|
124
|
-
domain: "StrataStorage.SQLiteStorage",
|
|
125
|
-
code: 1004,
|
|
126
|
-
userInfo: [NSLocalizedDescriptionKey: "Failed to create table: \(errorMessage)"]
|
|
127
|
-
)
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
ensuredTables.insert(table)
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
private func notInitializedError() -> NSError {
|
|
134
|
-
return NSError(
|
|
135
|
-
domain: "StrataStorage.SQLiteStorage",
|
|
136
|
-
code: 1000,
|
|
137
|
-
userInfo: [NSLocalizedDescriptionKey: "Database not initialized"]
|
|
138
|
-
)
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Persist a full `StorageValue` wrapper for `key` in `table`.
|
|
143
|
-
*
|
|
144
|
-
* The entire `wrapper` dictionary is JSON-encoded into the `value` column
|
|
145
|
-
* (perfect round-trip). `created`/`updated`/`expires` and the JSON of
|
|
146
|
-
* `tags`/`metadata` are extracted into their own columns for TTL + query.
|
|
147
|
-
* Values are bound parameters; only `table` is interpolated (pre-sanitized).
|
|
148
|
-
*/
|
|
149
|
-
@objc public func set(table: String, key: String, wrapper: [String: Any]) throws -> Bool {
|
|
150
|
-
guard let db = db else { throw notInitializedError() }
|
|
151
|
-
try ensureTable(table)
|
|
152
|
-
|
|
153
|
-
// Serialize the whole wrapper for round-trip storage.
|
|
154
|
-
let blob = try JSONSerialization.data(withJSONObject: wrapper, options: [])
|
|
155
|
-
|
|
156
|
-
// Extract mirror columns from the wrapper. created/updated are required
|
|
157
|
-
// by the wrapper contract; fall back to "now" defensively.
|
|
158
|
-
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
|
159
|
-
let created = (wrapper["created"] as? NSNumber)?.int64Value ?? now
|
|
160
|
-
let updated = (wrapper["updated"] as? NSNumber)?.int64Value ?? now
|
|
161
|
-
let expires = (wrapper["expires"] as? NSNumber)?.int64Value
|
|
162
|
-
|
|
163
|
-
let tagsData: Data?
|
|
164
|
-
if let tags = wrapper["tags"] {
|
|
165
|
-
tagsData = try? JSONSerialization.data(withJSONObject: tags, options: [])
|
|
166
|
-
} else {
|
|
167
|
-
tagsData = nil
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
let metadataData: Data?
|
|
171
|
-
if let metadata = wrapper["metadata"] {
|
|
172
|
-
metadataData = try? JSONSerialization.data(withJSONObject: metadata, options: [])
|
|
173
|
-
} else {
|
|
174
|
-
metadataData = nil
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
let insertSQL = """
|
|
178
|
-
INSERT OR REPLACE INTO \(table)
|
|
179
|
-
(key, value, created, updated, expires, tags, metadata)
|
|
180
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
181
|
-
"""
|
|
182
|
-
|
|
183
|
-
var statement: OpaquePointer?
|
|
184
|
-
defer { sqlite3_finalize(statement) }
|
|
185
|
-
|
|
186
|
-
guard sqlite3_prepare_v2(db, insertSQL, -1, &statement, nil) == SQLITE_OK else {
|
|
187
|
-
return false
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
let ok =
|
|
191
|
-
sqlite3_bind_text(statement, 1, key, -1, SQLITE_TRANSIENT) == SQLITE_OK &&
|
|
192
|
-
blob.withUnsafeBytes { sqlite3_bind_blob(statement, 2, $0.baseAddress, Int32(blob.count), SQLITE_TRANSIENT) } == SQLITE_OK &&
|
|
193
|
-
sqlite3_bind_int64(statement, 3, created) == SQLITE_OK &&
|
|
194
|
-
sqlite3_bind_int64(statement, 4, updated) == SQLITE_OK &&
|
|
195
|
-
(expires != nil ? sqlite3_bind_int64(statement, 5, expires!) : sqlite3_bind_null(statement, 5)) == SQLITE_OK &&
|
|
196
|
-
bindOptionalBlob(statement, 6, tagsData) == SQLITE_OK &&
|
|
197
|
-
bindOptionalBlob(statement, 7, metadataData) == SQLITE_OK
|
|
198
|
-
|
|
199
|
-
guard ok else { return false }
|
|
200
|
-
return sqlite3_step(statement) == SQLITE_DONE
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/// Binds `data` as a blob to `index`, or NULL when `data` is nil.
|
|
204
|
-
private func bindOptionalBlob(_ statement: OpaquePointer?, _ index: Int32, _ data: Data?) -> Int32 {
|
|
205
|
-
guard let data = data else {
|
|
206
|
-
return sqlite3_bind_null(statement, index)
|
|
207
|
-
}
|
|
208
|
-
return data.withUnsafeBytes {
|
|
209
|
-
sqlite3_bind_blob(statement, index, $0.baseAddress, Int32(data.count), SQLITE_TRANSIENT)
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Read the wrapper object for `key` from `table`.
|
|
215
|
-
*
|
|
216
|
-
* Returns the decoded `StorageValue` wrapper dictionary, or `nil` for a
|
|
217
|
-
* missing row OR when stored bytes do not parse as a JSON object
|
|
218
|
-
* (legacy/corrupt data is treated as a miss, never a crash).
|
|
219
|
-
*/
|
|
220
|
-
@objc public func get(table: String, key: String) -> [String: Any]? {
|
|
221
|
-
guard let db = db else {
|
|
222
|
-
os_log("Database not initialized", log: logger, type: .error)
|
|
223
|
-
return nil
|
|
224
|
-
}
|
|
225
|
-
// Reading a table that was never created is a miss, not an error.
|
|
226
|
-
try? ensureTable(table)
|
|
227
|
-
|
|
228
|
-
let querySQL = "SELECT value FROM \(table) WHERE key = ? LIMIT 1"
|
|
229
|
-
var statement: OpaquePointer?
|
|
230
|
-
defer { sqlite3_finalize(statement) }
|
|
231
|
-
|
|
232
|
-
guard sqlite3_prepare_v2(db, querySQL, -1, &statement, nil) == SQLITE_OK,
|
|
233
|
-
sqlite3_bind_text(statement, 1, key, -1, SQLITE_TRANSIENT) == SQLITE_OK else {
|
|
234
|
-
return nil
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
guard sqlite3_step(statement) == SQLITE_ROW,
|
|
238
|
-
let blob = sqlite3_column_blob(statement, 0) else {
|
|
239
|
-
return nil
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
let valueData = Data(bytes: blob, count: Int(sqlite3_column_bytes(statement, 0)))
|
|
243
|
-
// The wrapper is always a JSON object. Anything else = legacy/corrupt → miss.
|
|
244
|
-
guard let wrapper = try? JSONSerialization.jsonObject(with: valueData, options: []) as? [String: Any] else {
|
|
245
|
-
return nil
|
|
246
|
-
}
|
|
247
|
-
return wrapper
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
@objc public func remove(table: String, key: String) -> Bool {
|
|
251
|
-
guard let db = db else {
|
|
252
|
-
os_log("Database not initialized", log: logger, type: .error)
|
|
253
|
-
return false
|
|
254
|
-
}
|
|
255
|
-
try? ensureTable(table)
|
|
256
|
-
|
|
257
|
-
let deleteSQL = "DELETE FROM \(table) WHERE key = ?"
|
|
258
|
-
var statement: OpaquePointer?
|
|
259
|
-
defer { sqlite3_finalize(statement) }
|
|
260
|
-
|
|
261
|
-
guard sqlite3_prepare_v2(db, deleteSQL, -1, &statement, nil) == SQLITE_OK,
|
|
262
|
-
sqlite3_bind_text(statement, 1, key, -1, SQLITE_TRANSIENT) == SQLITE_OK else {
|
|
263
|
-
return false
|
|
264
|
-
}
|
|
265
|
-
return sqlite3_step(statement) == SQLITE_DONE
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
@objc public func clear(table: String, prefix: String? = nil) -> Bool {
|
|
269
|
-
guard let db = db else {
|
|
270
|
-
os_log("Database not initialized", log: logger, type: .error)
|
|
271
|
-
return false
|
|
272
|
-
}
|
|
273
|
-
try? ensureTable(table)
|
|
274
|
-
|
|
275
|
-
let deleteSQL: String
|
|
276
|
-
if prefix != nil {
|
|
277
|
-
deleteSQL = "DELETE FROM \(table) WHERE key LIKE ?"
|
|
278
|
-
} else {
|
|
279
|
-
deleteSQL = "DELETE FROM \(table)"
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
var statement: OpaquePointer?
|
|
283
|
-
defer { sqlite3_finalize(statement) }
|
|
284
|
-
|
|
285
|
-
guard sqlite3_prepare_v2(db, deleteSQL, -1, &statement, nil) == SQLITE_OK else {
|
|
286
|
-
return false
|
|
287
|
-
}
|
|
288
|
-
if let prefix = prefix {
|
|
289
|
-
guard sqlite3_bind_text(statement, 1, "\(prefix)%", -1, SQLITE_TRANSIENT) == SQLITE_OK else {
|
|
290
|
-
return false
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
return sqlite3_step(statement) == SQLITE_DONE
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
@objc public func keys(table: String, pattern: String? = nil) -> [String] {
|
|
297
|
-
guard let db = db else {
|
|
298
|
-
os_log("Database not initialized", log: logger, type: .error)
|
|
299
|
-
return []
|
|
300
|
-
}
|
|
301
|
-
try? ensureTable(table)
|
|
302
|
-
|
|
303
|
-
let querySQL: String
|
|
304
|
-
if pattern != nil {
|
|
305
|
-
querySQL = "SELECT key FROM \(table) WHERE key LIKE ?"
|
|
306
|
-
} else {
|
|
307
|
-
querySQL = "SELECT key FROM \(table)"
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
var statement: OpaquePointer?
|
|
311
|
-
defer { sqlite3_finalize(statement) }
|
|
312
|
-
var keys: [String] = []
|
|
313
|
-
|
|
314
|
-
if sqlite3_prepare_v2(db, querySQL, -1, &statement, nil) == SQLITE_OK {
|
|
315
|
-
if let pattern = pattern {
|
|
316
|
-
// `%pattern%` mirrors the contains-matching used by other backends.
|
|
317
|
-
sqlite3_bind_text(statement, 1, "%\(pattern)%", -1, SQLITE_TRANSIENT)
|
|
318
|
-
}
|
|
319
|
-
while sqlite3_step(statement) == SQLITE_ROW {
|
|
320
|
-
if let key = sqlite3_column_text(statement, 0) {
|
|
321
|
-
keys.append(String(cString: key))
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
return keys
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Aggregate sizes for `table`.
|
|
330
|
-
*
|
|
331
|
-
* Returns `count`, `total` (= `keys` + `values`), and per-segment byte
|
|
332
|
-
* sums: `keys` = SUM(LENGTH(key)), `values` = SUM(LENGTH(value)),
|
|
333
|
-
* `metadata` = SUM(LENGTH(metadata)) treating NULL as 0. The plugin decides
|
|
334
|
-
* whether to surface the `detailed` segment based on the `detailed` flag.
|
|
335
|
-
*/
|
|
336
|
-
@objc public func size(table: String) throws -> [String: Int] {
|
|
337
|
-
guard let db = db else { throw notInitializedError() }
|
|
338
|
-
try ensureTable(table)
|
|
339
|
-
|
|
340
|
-
let querySQL = """
|
|
341
|
-
SELECT COUNT(*),
|
|
342
|
-
COALESCE(SUM(LENGTH(key)), 0),
|
|
343
|
-
COALESCE(SUM(LENGTH(value)), 0),
|
|
344
|
-
COALESCE(SUM(LENGTH(metadata)), 0)
|
|
345
|
-
FROM \(table)
|
|
346
|
-
"""
|
|
347
|
-
var statement: OpaquePointer?
|
|
348
|
-
defer { sqlite3_finalize(statement) }
|
|
349
|
-
|
|
350
|
-
var count = 0
|
|
351
|
-
var keysSize = 0
|
|
352
|
-
var valuesSize = 0
|
|
353
|
-
var metadataSize = 0
|
|
354
|
-
|
|
355
|
-
if sqlite3_prepare_v2(db, querySQL, -1, &statement, nil) == SQLITE_OK,
|
|
356
|
-
sqlite3_step(statement) == SQLITE_ROW {
|
|
357
|
-
count = Int(sqlite3_column_int(statement, 0))
|
|
358
|
-
keysSize = Int(sqlite3_column_int64(statement, 1))
|
|
359
|
-
valuesSize = Int(sqlite3_column_int64(statement, 2))
|
|
360
|
-
metadataSize = Int(sqlite3_column_int64(statement, 3))
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
return [
|
|
364
|
-
"count": count,
|
|
365
|
-
"keys": keysSize,
|
|
366
|
-
"values": valuesSize,
|
|
367
|
-
"metadata": metadataSize,
|
|
368
|
-
"total": keysSize + valuesSize
|
|
369
|
-
]
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/**
|
|
373
|
-
* Returns every row as `{ key }`. The JS SqliteAdapter applies the real
|
|
374
|
-
* query `condition` by re-fetching and filtering each key, so this native
|
|
375
|
-
* side only needs to enumerate candidate keys. The `condition` argument is
|
|
376
|
-
* accepted for forward-compatibility but is not yet pushed down into SQL.
|
|
377
|
-
*/
|
|
378
|
-
@objc public func query(table: String, condition: [String: Any]) -> [[String: Any]] {
|
|
379
|
-
guard let db = db else {
|
|
380
|
-
os_log("Database not initialized", log: logger, type: .error)
|
|
381
|
-
return []
|
|
382
|
-
}
|
|
383
|
-
try? ensureTable(table)
|
|
384
|
-
|
|
385
|
-
let querySQL = "SELECT key FROM \(table)"
|
|
386
|
-
var statement: OpaquePointer?
|
|
387
|
-
defer { sqlite3_finalize(statement) }
|
|
388
|
-
var rows: [[String: Any]] = []
|
|
389
|
-
|
|
390
|
-
if sqlite3_prepare_v2(db, querySQL, -1, &statement, nil) == SQLITE_OK {
|
|
391
|
-
while sqlite3_step(statement) == SQLITE_ROW {
|
|
392
|
-
guard let keyCString = sqlite3_column_text(statement, 0) else { continue }
|
|
393
|
-
rows.append(["key": String(cString: keyCString)])
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
return rows
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
/**
|
|
401
|
-
* Caches one open `SQLiteStorage` (and thus one DB handle) per database
|
|
402
|
-
* filename, so the database is opened once per file instead of on every
|
|
403
|
-
* bridge call. Access is serialized with a lock because Capacitor may dispatch
|
|
404
|
-
* plugin calls on a background queue.
|
|
405
|
-
*/
|
|
406
|
-
final class SQLiteStorageManager {
|
|
407
|
-
static let shared = SQLiteStorageManager()
|
|
408
|
-
|
|
409
|
-
private var stores: [String: SQLiteStorage] = [:]
|
|
410
|
-
private let lock = NSLock()
|
|
411
|
-
|
|
412
|
-
private init() {}
|
|
413
|
-
|
|
414
|
-
/// Returns the cached store for `fileName` (e.g. `storage.db`), creating it
|
|
415
|
-
/// on first use. `fileName` MUST already be sanitized.
|
|
416
|
-
func store(forFile fileName: String) -> SQLiteStorage {
|
|
417
|
-
lock.lock()
|
|
418
|
-
defer { lock.unlock() }
|
|
419
|
-
if let existing = stores[fileName] {
|
|
420
|
-
return existing
|
|
421
|
-
}
|
|
422
|
-
let created = SQLiteStorage(dbName: fileName)
|
|
423
|
-
stores[fileName] = created
|
|
424
|
-
return created
|
|
425
|
-
}
|
|
426
|
-
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
#import <Foundation/Foundation.h>
|
|
2
|
-
#import <Capacitor/Capacitor.h>
|
|
3
|
-
|
|
4
|
-
// Registers the StrataStorage plugin and its methods with the Capacitor
|
|
5
|
-
// bridge. Without this macro block the @objc Swift methods are NOT exposed
|
|
6
|
-
// to the JavaScript layer and every call would fail with "method not
|
|
7
|
-
// implemented". The method names and the parameter list below MUST match
|
|
8
|
-
// the @objc func names in StrataStoragePlugin.swift and the JS plugin
|
|
9
|
-
// contract in src/plugin/definitions.ts.
|
|
10
|
-
CAP_PLUGIN(StrataStoragePlugin, "StrataStorage",
|
|
11
|
-
CAP_PLUGIN_METHOD(isAvailable, CAPPluginReturnPromise);
|
|
12
|
-
CAP_PLUGIN_METHOD(get, CAPPluginReturnPromise);
|
|
13
|
-
CAP_PLUGIN_METHOD(set, CAPPluginReturnPromise);
|
|
14
|
-
CAP_PLUGIN_METHOD(remove, CAPPluginReturnPromise);
|
|
15
|
-
CAP_PLUGIN_METHOD(clear, CAPPluginReturnPromise);
|
|
16
|
-
CAP_PLUGIN_METHOD(keys, CAPPluginReturnPromise);
|
|
17
|
-
CAP_PLUGIN_METHOD(size, CAPPluginReturnPromise);
|
|
18
|
-
CAP_PLUGIN_METHOD(query, CAPPluginReturnPromise);
|
|
19
|
-
CAP_PLUGIN_METHOD(getUserDefaults, CAPPluginReturnPromise);
|
|
20
|
-
CAP_PLUGIN_METHOD(setUserDefaults, CAPPluginReturnPromise);
|
|
21
|
-
CAP_PLUGIN_METHOD(getKeychain, CAPPluginReturnPromise);
|
|
22
|
-
CAP_PLUGIN_METHOD(setKeychain, CAPPluginReturnPromise);
|
|
23
|
-
)
|