strata-storage 2.5.0 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,27 +2,60 @@ import Foundation
2
2
  import SQLite3
3
3
  import os.log
4
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
+ */
5
36
  @objc public class SQLiteStorage: NSObject {
6
37
  private var db: OpaquePointer?
7
38
  private let dbName: String
8
- private let tableName = "strata_storage"
39
+ /// Tables already verified/created on this handle, to skip redundant DDL.
40
+ private var ensuredTables: Set<String> = []
9
41
  private let logger = OSLog(subsystem: "com.strata.storage", category: "SQLiteStorage")
10
-
11
- @objc public init(dbName: String = "strata.db") {
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") {
12
46
  self.dbName = dbName
13
47
  super.init()
14
48
  do {
15
49
  try openDatabase()
16
- try createTable()
17
50
  } catch {
18
51
  os_log("Failed to initialize SQLite storage: %{public}@", log: logger, type: .error, error.localizedDescription)
19
52
  }
20
53
  }
21
-
54
+
22
55
  deinit {
23
56
  closeDatabase()
24
57
  }
25
-
58
+
26
59
  private func openDatabase() throws {
27
60
  guard let fileURL = try? FileManager.default
28
61
  .url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
@@ -37,6 +70,7 @@ import os.log
37
70
  guard sqlite3_open(fileURL.path, &db) == SQLITE_OK else {
38
71
  let errorMessage = String(cString: sqlite3_errmsg(db))
39
72
  sqlite3_close(db)
73
+ db = nil
40
74
  throw NSError(
41
75
  domain: "StrataStorage.SQLiteStorage",
42
76
  code: 1002,
@@ -44,14 +78,24 @@ import os.log
44
78
  )
45
79
  }
46
80
  }
47
-
81
+
48
82
  private func closeDatabase() {
49
- sqlite3_close(db)
83
+ if db != nil {
84
+ sqlite3_close(db)
85
+ db = nil
86
+ }
50
87
  }
51
-
52
- private func createTable() throws {
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
+
53
97
  let createTableString = """
54
- CREATE TABLE IF NOT EXISTS \(tableName) (
98
+ CREATE TABLE IF NOT EXISTS \(table) (
55
99
  key TEXT PRIMARY KEY NOT NULL,
56
100
  value BLOB NOT NULL,
57
101
  created INTEGER NOT NULL,
@@ -63,6 +107,7 @@ import os.log
63
107
  """
64
108
 
65
109
  var createTableStatement: OpaquePointer?
110
+ defer { sqlite3_finalize(createTableStatement) }
66
111
 
67
112
  guard sqlite3_prepare_v2(db, createTableString, -1, &createTableStatement, nil) == SQLITE_OK else {
68
113
  let errorMessage = String(cString: sqlite3_errmsg(db))
@@ -73,10 +118,6 @@ import os.log
73
118
  )
74
119
  }
75
120
 
76
- defer {
77
- sqlite3_finalize(createTableStatement)
78
- }
79
-
80
121
  guard sqlite3_step(createTableStatement) == SQLITE_DONE else {
81
122
  let errorMessage = String(cString: sqlite3_errmsg(db))
82
123
  throw NSError(
@@ -85,250 +126,301 @@ import os.log
85
126
  userInfo: [NSLocalizedDescriptionKey: "Failed to create table: \(errorMessage)"]
86
127
  )
87
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
+ )
88
139
  }
89
-
90
- @objc public func set(key: String, value: Any, expires: Int64? = nil, tags: [String]? = nil, metadata: [String: Any]? = nil) throws -> Bool {
91
- guard let db = db else {
92
- throw NSError(
93
- domain: "StrataStorage.SQLiteStorage",
94
- code: 1000,
95
- userInfo: [NSLocalizedDescriptionKey: "Database not initialized"]
96
- )
97
- }
98
140
 
99
- let data: Data
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)
100
152
 
101
- if let dataValue = value as? Data {
102
- data = dataValue
103
- } else if let stringValue = value as? String {
104
- data = stringValue.data(using: .utf8) ?? Data()
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: [])
105
166
  } else {
106
- // Convert to JSON for complex objects
107
- data = try JSONSerialization.data(withJSONObject: value, options: [])
167
+ tagsData = nil
108
168
  }
109
-
110
- let now = Int64(Date().timeIntervalSince1970 * 1000)
111
- let tagsJson = tags != nil ? try? JSONSerialization.data(withJSONObject: tags!, options: []) : nil
112
- let metadataJson = metadata != nil ? try? JSONSerialization.data(withJSONObject: metadata!, options: []) : nil
113
-
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
+
114
177
  let insertSQL = """
115
- INSERT OR REPLACE INTO \(tableName)
116
- (key, value, created, updated, expires, tags, metadata)
178
+ INSERT OR REPLACE INTO \(table)
179
+ (key, value, created, updated, expires, tags, metadata)
117
180
  VALUES (?, ?, ?, ?, ?, ?, ?)
118
181
  """
119
-
182
+
120
183
  var statement: OpaquePointer?
121
- let result = sqlite3_prepare_v2(db, insertSQL, -1, &statement, nil) == SQLITE_OK &&
122
- sqlite3_bind_text(statement, 1, key, -1, nil) == SQLITE_OK &&
123
- sqlite3_bind_blob(statement, 2, (data as NSData).bytes, Int32(data.count), nil) == SQLITE_OK &&
124
- sqlite3_bind_int64(statement, 3, now) == SQLITE_OK &&
125
- sqlite3_bind_int64(statement, 4, now) == SQLITE_OK &&
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 &&
126
195
  (expires != nil ? sqlite3_bind_int64(statement, 5, expires!) : sqlite3_bind_null(statement, 5)) == SQLITE_OK &&
127
- (tagsJson != nil ? sqlite3_bind_blob(statement, 6, (tagsJson! as NSData).bytes, Int32(tagsJson!.count), nil) : sqlite3_bind_null(statement, 6)) == SQLITE_OK &&
128
- (metadataJson != nil ? sqlite3_bind_blob(statement, 7, (metadataJson! as NSData).bytes, Int32(metadataJson!.count), nil) : sqlite3_bind_null(statement, 7)) == SQLITE_OK &&
129
- sqlite3_step(statement) == SQLITE_DONE
130
-
131
- sqlite3_finalize(statement)
132
- return result
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
133
201
  }
134
-
135
- // Convenience method for simple values
136
- @objc public func set(key: String, value: Any) throws -> Bool {
137
- return try set(key: key, value: value, expires: nil, tags: nil, metadata: nil)
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
+ }
138
211
  }
139
-
140
- @objc public func get(key: String) -> [String: Any]? {
141
- guard db != nil else {
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 {
142
222
  os_log("Database not initialized", log: logger, type: .error)
143
223
  return nil
144
224
  }
225
+ // Reading a table that was never created is a miss, not an error.
226
+ try? ensureTable(table)
145
227
 
146
- let querySQL = "SELECT * FROM \(tableName) WHERE key = ? LIMIT 1"
228
+ let querySQL = "SELECT value FROM \(table) WHERE key = ? LIMIT 1"
147
229
  var statement: OpaquePointer?
230
+ defer { sqlite3_finalize(statement) }
148
231
 
149
232
  guard sqlite3_prepare_v2(db, querySQL, -1, &statement, nil) == SQLITE_OK,
150
- sqlite3_bind_text(statement, 1, key, -1, nil) == SQLITE_OK else {
151
- sqlite3_finalize(statement)
233
+ sqlite3_bind_text(statement, 1, key, -1, SQLITE_TRANSIENT) == SQLITE_OK else {
152
234
  return nil
153
235
  }
154
-
155
- var result: [String: Any]?
156
- if sqlite3_step(statement) == SQLITE_ROW {
157
- let valueData = Data(bytes: sqlite3_column_blob(statement, 1), count: Int(sqlite3_column_bytes(statement, 1)))
158
- let created = sqlite3_column_int64(statement, 2)
159
- let updated = sqlite3_column_int64(statement, 3)
160
-
161
- result = [
162
- "key": key,
163
- "value": valueData,
164
- "created": created,
165
- "updated": updated
166
- ]
167
-
168
- if sqlite3_column_type(statement, 4) != SQLITE_NULL {
169
- result!["expires"] = sqlite3_column_int64(statement, 4)
170
- }
171
-
172
- if sqlite3_column_type(statement, 5) != SQLITE_NULL {
173
- let tagsData = Data(bytes: sqlite3_column_blob(statement, 5), count: Int(sqlite3_column_bytes(statement, 5)))
174
- if let tags = try? JSONSerialization.jsonObject(with: tagsData, options: []) {
175
- result!["tags"] = tags
176
- }
177
- }
178
-
179
- if sqlite3_column_type(statement, 6) != SQLITE_NULL {
180
- let metadataData = Data(bytes: sqlite3_column_blob(statement, 6), count: Int(sqlite3_column_bytes(statement, 6)))
181
- if let metadata = try? JSONSerialization.jsonObject(with: metadataData, options: []) {
182
- result!["metadata"] = metadata
183
- }
184
- }
236
+
237
+ guard sqlite3_step(statement) == SQLITE_ROW,
238
+ let blob = sqlite3_column_blob(statement, 0) else {
239
+ return nil
185
240
  }
186
-
187
- sqlite3_finalize(statement)
188
- return result
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
189
248
  }
190
-
191
- @objc public func remove(key: String) -> Bool {
192
- guard db != nil else {
249
+
250
+ @objc public func remove(table: String, key: String) -> Bool {
251
+ guard let db = db else {
193
252
  os_log("Database not initialized", log: logger, type: .error)
194
253
  return false
195
254
  }
255
+ try? ensureTable(table)
196
256
 
197
- let deleteSQL = "DELETE FROM \(tableName) WHERE key = ?"
257
+ let deleteSQL = "DELETE FROM \(table) WHERE key = ?"
198
258
  var statement: OpaquePointer?
259
+ defer { sqlite3_finalize(statement) }
199
260
 
200
- let result = sqlite3_prepare_v2(db, deleteSQL, -1, &statement, nil) == SQLITE_OK &&
201
- sqlite3_bind_text(statement, 1, key, -1, nil) == SQLITE_OK &&
202
- sqlite3_step(statement) == SQLITE_DONE
203
-
204
- sqlite3_finalize(statement)
205
- return result
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
206
266
  }
207
-
208
- @objc public func clear(prefix: String? = nil) -> Bool {
209
- guard db != nil else {
267
+
268
+ @objc public func clear(table: String, prefix: String? = nil) -> Bool {
269
+ guard let db = db else {
210
270
  os_log("Database not initialized", log: logger, type: .error)
211
271
  return false
212
272
  }
273
+ try? ensureTable(table)
213
274
 
214
275
  let deleteSQL: String
215
- if let prefix = prefix {
216
- deleteSQL = "DELETE FROM \(tableName) WHERE key LIKE ?"
276
+ if prefix != nil {
277
+ deleteSQL = "DELETE FROM \(table) WHERE key LIKE ?"
217
278
  } else {
218
- deleteSQL = "DELETE FROM \(tableName)"
279
+ deleteSQL = "DELETE FROM \(table)"
219
280
  }
220
281
 
221
282
  var statement: OpaquePointer?
222
- var result = sqlite3_prepare_v2(db, deleteSQL, -1, &statement, nil) == SQLITE_OK
283
+ defer { sqlite3_finalize(statement) }
223
284
 
224
- if result && prefix != nil {
225
- result = sqlite3_bind_text(statement, 1, "\(prefix!)%", -1, nil) == SQLITE_OK
285
+ guard sqlite3_prepare_v2(db, deleteSQL, -1, &statement, nil) == SQLITE_OK else {
286
+ return false
226
287
  }
227
-
228
- if result {
229
- result = sqlite3_step(statement) == SQLITE_DONE
288
+ if let prefix = prefix {
289
+ guard sqlite3_bind_text(statement, 1, "\(prefix)%", -1, SQLITE_TRANSIENT) == SQLITE_OK else {
290
+ return false
291
+ }
230
292
  }
231
-
232
- sqlite3_finalize(statement)
233
- return result
293
+ return sqlite3_step(statement) == SQLITE_DONE
234
294
  }
235
-
236
- @objc public func keys(pattern: String? = nil) -> [String] {
237
- guard db != nil else {
295
+
296
+ @objc public func keys(table: String, pattern: String? = nil) -> [String] {
297
+ guard let db = db else {
238
298
  os_log("Database not initialized", log: logger, type: .error)
239
299
  return []
240
300
  }
301
+ try? ensureTable(table)
241
302
 
242
303
  let querySQL: String
243
- if let pattern = pattern {
244
- querySQL = "SELECT key FROM \(tableName) WHERE key LIKE ?"
304
+ if pattern != nil {
305
+ querySQL = "SELECT key FROM \(table) WHERE key LIKE ?"
245
306
  } else {
246
- querySQL = "SELECT key FROM \(tableName)"
307
+ querySQL = "SELECT key FROM \(table)"
247
308
  }
248
309
 
249
310
  var statement: OpaquePointer?
311
+ defer { sqlite3_finalize(statement) }
250
312
  var keys: [String] = []
251
313
 
252
314
  if sqlite3_prepare_v2(db, querySQL, -1, &statement, nil) == SQLITE_OK {
253
315
  if let pattern = pattern {
254
- // Use % wildcard for SQL LIKE pattern matching
255
- sqlite3_bind_text(statement, 1, "%\(pattern)%", -1, nil)
316
+ // `%pattern%` mirrors the contains-matching used by other backends.
317
+ sqlite3_bind_text(statement, 1, "%\(pattern)%", -1, SQLITE_TRANSIENT)
256
318
  }
257
-
258
319
  while sqlite3_step(statement) == SQLITE_ROW {
259
320
  if let key = sqlite3_column_text(statement, 0) {
260
321
  keys.append(String(cString: key))
261
322
  }
262
323
  }
263
324
  }
264
-
265
- sqlite3_finalize(statement)
266
325
  return keys
267
326
  }
268
-
269
- @objc public func size() throws -> (total: Int, count: Int) {
270
- guard db != nil else {
271
- throw NSError(
272
- domain: "StrataStorage.SQLiteStorage",
273
- code: 1000,
274
- userInfo: [NSLocalizedDescriptionKey: "Database not initialized"]
275
- )
276
- }
277
327
 
278
- let querySQL = "SELECT COUNT(*), SUM(LENGTH(value)) FROM \(tableName)"
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
+ """
279
347
  var statement: OpaquePointer?
348
+ defer { sqlite3_finalize(statement) }
280
349
 
281
- var totalSize = 0
282
350
  var count = 0
283
-
284
- if sqlite3_prepare_v2(db, querySQL, -1, &statement, nil) == SQLITE_OK {
285
- if sqlite3_step(statement) == SQLITE_ROW {
286
- count = Int(sqlite3_column_int(statement, 0))
287
- totalSize = Int(sqlite3_column_int64(statement, 1))
288
- }
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))
289
361
  }
290
362
 
291
- sqlite3_finalize(statement)
292
- return (total: totalSize, count: count)
363
+ return [
364
+ "count": count,
365
+ "keys": keysSize,
366
+ "values": valuesSize,
367
+ "metadata": metadataSize,
368
+ "total": keysSize + valuesSize
369
+ ]
293
370
  }
294
371
 
295
372
  /**
296
- * Returns every row as `{ key, value }` where `value` is the decoded
297
- * stored payload. The JS SqliteAdapter applies the real query `condition`
298
- * by re-fetching and filtering each key, so this native side only needs to
299
- * enumerate candidate rows. The `condition` argument is accepted for
300
- * forward-compatibility but is not yet pushed down into SQL.
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.
301
377
  */
302
- @objc public func query(condition: [String: Any]) -> [[String: Any]] {
303
- guard db != nil else {
378
+ @objc public func query(table: String, condition: [String: Any]) -> [[String: Any]] {
379
+ guard let db = db else {
304
380
  os_log("Database not initialized", log: logger, type: .error)
305
381
  return []
306
382
  }
383
+ try? ensureTable(table)
307
384
 
308
- let querySQL = "SELECT key, value FROM \(tableName)"
385
+ let querySQL = "SELECT key FROM \(table)"
309
386
  var statement: OpaquePointer?
387
+ defer { sqlite3_finalize(statement) }
310
388
  var rows: [[String: Any]] = []
311
389
 
312
390
  if sqlite3_prepare_v2(db, querySQL, -1, &statement, nil) == SQLITE_OK {
313
391
  while sqlite3_step(statement) == SQLITE_ROW {
314
392
  guard let keyCString = sqlite3_column_text(statement, 0) else { continue }
315
- let key = String(cString: keyCString)
316
-
317
- var decoded: Any = NSNull()
318
- if let blob = sqlite3_column_blob(statement, 1) {
319
- let valueData = Data(bytes: blob, count: Int(sqlite3_column_bytes(statement, 1)))
320
- if let jsonObject = try? JSONSerialization.jsonObject(with: valueData, options: []) {
321
- decoded = jsonObject
322
- } else if let str = String(data: valueData, encoding: .utf8) {
323
- decoded = str
324
- }
325
- }
326
-
327
- rows.append(["key": key, "value": decoded])
393
+ rows.append(["key": String(cString: keyCString)])
328
394
  }
329
395
  }
330
-
331
- sqlite3_finalize(statement)
332
396
  return rows
333
397
  }
334
- }
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
+ }