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.
Files changed (130) hide show
  1. package/AI-INTEGRATION-GUIDE.md +88 -10
  2. package/README.md +42 -64
  3. package/android/AGENTS.md +18 -3
  4. package/android/CLAUDE.md +36 -3
  5. package/android/build.gradle +5 -1
  6. package/android/src/main/java/com/strata/storage/EncryptedStorage.java +31 -18
  7. package/android/src/main/java/com/strata/storage/FilesystemStorage.java +62 -10
  8. package/android/src/main/java/com/strata/storage/SQLiteStorage.java +65 -13
  9. package/android/src/main/java/com/strata/storage/SharedPreferencesStorage.java +63 -51
  10. package/android/src/main/java/com/stratastorage/StrataStoragePlugin.java +80 -11
  11. package/dist/README.md +42 -64
  12. package/dist/adapters/capacitor/FilesystemAdapter.d.ts +2 -2
  13. package/dist/adapters/capacitor/FilesystemAdapter.d.ts.map +1 -1
  14. package/dist/adapters/capacitor/FilesystemAdapter.js +6 -2
  15. package/dist/adapters/capacitor/PreferencesAdapter.d.ts +2 -2
  16. package/dist/adapters/capacitor/PreferencesAdapter.d.ts.map +1 -1
  17. package/dist/adapters/capacitor/PreferencesAdapter.js +6 -2
  18. package/dist/adapters/capacitor/SecureAdapter.d.ts +3 -3
  19. package/dist/adapters/capacitor/SecureAdapter.d.ts.map +1 -1
  20. package/dist/adapters/capacitor/SecureAdapter.js +6 -2
  21. package/dist/adapters/capacitor/SqliteAdapter.d.ts +24 -3
  22. package/dist/adapters/capacitor/SqliteAdapter.d.ts.map +1 -1
  23. package/dist/adapters/capacitor/SqliteAdapter.js +105 -23
  24. package/dist/adapters/capacitor/index.d.ts +4 -4
  25. package/dist/adapters/web/CacheAdapter.d.ts +2 -2
  26. package/dist/adapters/web/CookieAdapter.d.ts +2 -2
  27. package/dist/adapters/web/IndexedDBAdapter.d.ts +10 -3
  28. package/dist/adapters/web/IndexedDBAdapter.d.ts.map +1 -1
  29. package/dist/adapters/web/IndexedDBAdapter.js +14 -3
  30. package/dist/adapters/web/LocalStorageAdapter.d.ts +2 -2
  31. package/dist/adapters/web/MemoryAdapter.d.ts +2 -2
  32. package/dist/adapters/web/SessionStorageAdapter.d.ts +10 -10
  33. package/dist/adapters/web/SessionStorageAdapter.d.ts.map +1 -1
  34. package/dist/adapters/web/SessionStorageAdapter.js +3 -2
  35. package/dist/adapters/web/URLAdapter.d.ts +2 -2
  36. package/dist/adapters/web/index.d.ts +7 -7
  37. package/dist/capacitor.d.ts +6 -6
  38. package/dist/capacitor.d.ts.map +1 -1
  39. package/dist/capacitor.js +5 -4
  40. package/dist/core/AdapterRegistry.d.ts +1 -1
  41. package/dist/core/BaseAdapter.d.ts +21 -5
  42. package/dist/core/BaseAdapter.d.ts.map +1 -1
  43. package/dist/core/BaseAdapter.js +29 -1
  44. package/dist/core/Strata.d.ts +41 -7
  45. package/dist/core/Strata.d.ts.map +1 -1
  46. package/dist/core/Strata.js +476 -118
  47. package/dist/features/compression/index.d.ts +1 -1
  48. package/dist/features/compression.d.ts +5 -1
  49. package/dist/features/compression.d.ts.map +1 -1
  50. package/dist/features/encryption/index.d.ts +1 -1
  51. package/dist/features/encryption.d.ts +55 -2
  52. package/dist/features/encryption.d.ts.map +1 -1
  53. package/dist/features/encryption.js +139 -26
  54. package/dist/features/migration.d.ts +1 -1
  55. package/dist/features/observer/index.d.ts +1 -1
  56. package/dist/features/observer.d.ts +1 -1
  57. package/dist/features/query/index.d.ts +1 -1
  58. package/dist/features/query.d.ts +1 -1
  59. package/dist/features/query.d.ts.map +1 -1
  60. package/dist/features/query.js +3 -1
  61. package/dist/features/sync/index.d.ts +1 -1
  62. package/dist/features/sync.d.ts +19 -3
  63. package/dist/features/sync.d.ts.map +1 -1
  64. package/dist/features/sync.js +57 -9
  65. package/dist/features/ttl/index.d.ts +1 -1
  66. package/dist/features/ttl.d.ts +2 -2
  67. package/dist/firebase.d.ts +1 -1
  68. package/dist/firebase.d.ts.map +1 -1
  69. package/dist/firebase.js +34 -9
  70. package/dist/index.d.ts +24 -22
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +6 -1
  73. package/dist/integrations/angular/index.d.ts +2 -2
  74. package/dist/integrations/angular/index.d.ts.map +1 -1
  75. package/dist/integrations/angular/index.js +7 -8
  76. package/dist/integrations/react/index.d.ts +3 -3
  77. package/dist/integrations/react/index.d.ts.map +1 -1
  78. package/dist/integrations/react/index.js +16 -4
  79. package/dist/integrations/vue/index.d.ts +2 -2
  80. package/dist/integrations/vue/index.d.ts.map +1 -1
  81. package/dist/integrations/vue/index.js +5 -2
  82. package/dist/package.json +11 -2
  83. package/dist/plugin/definitions.d.ts +29 -3
  84. package/dist/plugin/definitions.d.ts.map +1 -1
  85. package/dist/plugin/index.d.ts +2 -2
  86. package/dist/plugin/index.d.ts.map +1 -1
  87. package/dist/plugin/index.js +3 -0
  88. package/dist/plugin/web.d.ts +11 -2
  89. package/dist/plugin/web.d.ts.map +1 -1
  90. package/dist/plugin/web.js +23 -4
  91. package/dist/types/index.d.ts +50 -20
  92. package/dist/types/index.d.ts.map +1 -1
  93. package/dist/types/index.js +1 -10
  94. package/dist/utils/index.d.ts +5 -0
  95. package/dist/utils/index.d.ts.map +1 -1
  96. package/dist/utils/index.js +7 -0
  97. package/ios/AGENTS.md +14 -3
  98. package/ios/CLAUDE.md +25 -3
  99. package/ios/Plugin/FilesystemStorage.swift +19 -0
  100. package/ios/Plugin/SQLiteStorage.swift +59 -9
  101. package/ios/Plugin/StrataStoragePlugin.m +1 -0
  102. package/ios/Plugin/StrataStoragePlugin.swift +87 -34
  103. package/ios/Plugin/UserDefaultsStorage.swift +47 -46
  104. package/package.json +27 -24
  105. package/scripts/build.js +16 -14
  106. package/scripts/cli.js +1 -1
  107. package/scripts/configure.js +2 -2
  108. package/scripts/postinstall.js +3 -2
  109. package/dist/android/AGENTS.md +0 -51
  110. package/dist/android/CLAUDE.md +0 -89
  111. package/dist/android/build.gradle +0 -58
  112. package/dist/android/proguard-rules.pro +0 -11
  113. package/dist/android/settings.gradle +0 -2
  114. package/dist/android/src/main/AndroidManifest.xml +0 -2
  115. package/dist/android/src/main/java/com/strata/storage/EncryptedStorage.java +0 -218
  116. package/dist/android/src/main/java/com/strata/storage/FilesystemStorage.java +0 -287
  117. package/dist/android/src/main/java/com/strata/storage/SQLiteStorage.java +0 -371
  118. package/dist/android/src/main/java/com/strata/storage/SharedPreferencesStorage.java +0 -209
  119. package/dist/android/src/main/java/com/stratastorage/StrataStoragePlugin.java +0 -616
  120. package/dist/core/StorageStrategy.d.ts +0 -55
  121. package/dist/core/StorageStrategy.d.ts.map +0 -1
  122. package/dist/core/StorageStrategy.js +0 -200
  123. package/dist/ios/AGENTS.md +0 -48
  124. package/dist/ios/CLAUDE.md +0 -84
  125. package/dist/ios/Plugin/FilesystemStorage.swift +0 -218
  126. package/dist/ios/Plugin/KeychainStorage.swift +0 -233
  127. package/dist/ios/Plugin/SQLiteStorage.swift +0 -426
  128. package/dist/ios/Plugin/StrataStoragePlugin.m +0 -23
  129. package/dist/ios/Plugin/StrataStoragePlugin.swift +0 -411
  130. 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
- )