strata-storage 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,10 +8,11 @@ import com.getcapacitor.PluginMethod;
8
8
  import com.getcapacitor.annotation.CapacitorPlugin;
9
9
  import com.strata.storage.SharedPreferencesStorage;
10
10
  import com.strata.storage.EncryptedStorage;
11
+ import com.strata.storage.FilesystemStorage;
11
12
  import com.strata.storage.SQLiteStorage;
12
13
  import android.util.Log;
13
14
  import org.json.JSONArray;
14
- import org.json.JSONException;
15
+ import java.util.HashMap;
15
16
  import java.util.List;
16
17
  import java.util.Map;
17
18
 
@@ -19,15 +20,24 @@ import java.util.Map;
19
20
  * Main Capacitor plugin for Strata Storage
20
21
  * Coordinates between different storage types on Android.
21
22
  *
22
- * Storage types with a real native backend: preferences, secure, sqlite.
23
- * `filesystem` is NOT implemented natively and is rejected explicitly rather
24
- * than silently falling through to SharedPreferences.
23
+ * Storage types with a real native backend: preferences, secure, sqlite,
24
+ * filesystem.
25
+ *
26
+ * SQLite supports multi-store: the `database` option selects the DB file and
27
+ * `table` selects the table within it. SQLite helper instances are cached per
28
+ * database name so the connection is not reopened per call.
25
29
  */
26
30
  @CapacitorPlugin(name = "StrataStorage")
27
31
  public class StrataStoragePlugin extends Plugin {
32
+ private static final String DEFAULT_DATABASE = "strata_storage";
33
+ private static final String DEFAULT_TABLE = "storage";
34
+
28
35
  private SharedPreferencesStorage sharedPrefsStorage;
29
36
  private EncryptedStorage encryptedStorage;
30
- private SQLiteStorage sqliteStorage;
37
+ private FilesystemStorage filesystemStorage;
38
+
39
+ /** SQLite helper cache keyed by resolved database name (without ".db"). */
40
+ private final Map<String, SQLiteStorage> sqliteStores = new HashMap<>();
31
41
 
32
42
  @Override
33
43
  public void load() {
@@ -44,18 +54,43 @@ public class StrataStoragePlugin extends Plugin {
44
54
  Log.e("StrataStorage", "Failed to initialize encrypted storage", e);
45
55
  }
46
56
  try {
47
- sqliteStorage = new SQLiteStorage(getContext());
57
+ filesystemStorage = new FilesystemStorage(getContext());
48
58
  } catch (Exception e) {
49
- Log.e("StrataStorage", "Failed to initialize SQLite storage", e);
59
+ Log.e("StrataStorage", "Failed to initialize filesystem storage", e);
50
60
  }
51
61
  }
52
62
 
53
- private void rejectUnsupportedFilesystem(PluginCall call) {
54
- call.reject(
55
- "Filesystem storage is not implemented in the native Android plugin. " +
56
- "Use the 'preferences', 'secure', or 'sqlite' storage types, or a " +
57
- "web/Capacitor Filesystem-backed adapter."
58
- );
63
+ /**
64
+ * Sanitize a database name to a safe file stem ({@code ^[A-Za-z0-9_]+$})
65
+ * and return the resolved stem. The ".db" suffix is appended when opening.
66
+ */
67
+ private static String sanitizeDatabase(String database) {
68
+ if (database == null || database.isEmpty()) {
69
+ return DEFAULT_DATABASE;
70
+ }
71
+ String cleaned = database.replaceAll("[^A-Za-z0-9_]", "");
72
+ return cleaned.isEmpty() ? DEFAULT_DATABASE : cleaned;
73
+ }
74
+
75
+ /**
76
+ * Return (creating + caching on first use) the SQLite helper for the given
77
+ * database name. The cache is keyed by the sanitized DB stem so the
78
+ * connection is reused across bridge calls. May return {@code null} if the
79
+ * helper cannot be constructed.
80
+ */
81
+ private synchronized SQLiteStorage getSqliteStore(String database) {
82
+ String dbStem = sanitizeDatabase(database);
83
+ SQLiteStorage store = sqliteStores.get(dbStem);
84
+ if (store == null) {
85
+ try {
86
+ store = new SQLiteStorage(getContext(), dbStem + ".db");
87
+ sqliteStores.put(dbStem, store);
88
+ } catch (Exception e) {
89
+ Log.e("StrataStorage", "Failed to open SQLite database " + dbStem, e);
90
+ return null;
91
+ }
92
+ }
93
+ return store;
59
94
  }
60
95
 
61
96
  /**
@@ -71,11 +106,10 @@ public class StrataStoragePlugin extends Plugin {
71
106
  available = encryptedStorage != null;
72
107
  break;
73
108
  case "sqlite":
74
- available = sqliteStorage != null;
109
+ available = getSqliteStore(call.getString("database", DEFAULT_DATABASE)) != null;
75
110
  break;
76
111
  case "filesystem":
77
- // No native filesystem backend.
78
- available = false;
112
+ available = filesystemStorage != null;
79
113
  break;
80
114
  case "preferences":
81
115
  default:
@@ -89,7 +123,10 @@ public class StrataStoragePlugin extends Plugin {
89
123
  }
90
124
 
91
125
  /**
92
- * Get value from storage
126
+ * Get value from storage.
127
+ *
128
+ * For sqlite/filesystem the resolved `value` is the full wrapper object
129
+ * (parsed back from JSON). A miss resolves `value` = null.
93
130
  */
94
131
  @PluginMethod
95
132
  public void get(PluginCall call) {
@@ -102,46 +139,70 @@ public class StrataStoragePlugin extends Plugin {
102
139
  String storage = call.getString("storage", "preferences");
103
140
 
104
141
  try {
105
- Object value = null;
106
-
107
142
  switch (storage) {
108
- case "secure":
143
+ case "secure": {
109
144
  if (encryptedStorage == null) {
110
145
  call.reject("Encrypted storage not available");
111
146
  return;
112
147
  }
113
- value = encryptedStorage.get(key);
114
- break;
115
- case "sqlite":
116
- if (sqliteStorage == null) {
148
+ resolveValue(call, encryptedStorage.get(key));
149
+ return;
150
+ }
151
+ case "sqlite": {
152
+ SQLiteStorage store = getSqliteStore(call.getString("database", DEFAULT_DATABASE));
153
+ if (store == null) {
117
154
  call.reject("SQLite storage not available");
118
155
  return;
119
156
  }
120
- value = sqliteStorage.get(key);
121
- break;
122
- case "filesystem":
123
- rejectUnsupportedFilesystem(call);
157
+ String table = call.getString("table", DEFAULT_TABLE);
158
+ resolveValue(call, store.get(table, key));
124
159
  return;
160
+ }
161
+ case "filesystem": {
162
+ if (filesystemStorage == null) {
163
+ call.reject("Filesystem storage not available");
164
+ return;
165
+ }
166
+ resolveValue(call, filesystemStorage.get(key));
167
+ return;
168
+ }
125
169
  case "preferences":
126
- default:
170
+ default: {
127
171
  if (sharedPrefsStorage == null) {
128
172
  call.reject("Preferences storage not available");
129
173
  return;
130
174
  }
131
- value = sharedPrefsStorage.get(key);
132
- break;
175
+ resolveValue(call, sharedPrefsStorage.get(key));
176
+ return;
177
+ }
133
178
  }
134
-
135
- JSObject result = new JSObject();
136
- result.put("value", value);
137
- call.resolve(result);
138
179
  } catch (Exception e) {
139
180
  call.reject("Failed to get value", e);
140
181
  }
141
182
  }
142
183
 
143
184
  /**
144
- * Set value in storage
185
+ * Resolve a get() call, mapping a Java {@code null} to a JS {@code null}
186
+ * value so the TS adapters treat it as a miss.
187
+ */
188
+ private void resolveValue(PluginCall call, Object value) {
189
+ JSObject result = new JSObject();
190
+ if (value == null) {
191
+ result.put("value", JSObject.NULL);
192
+ } else {
193
+ result.put("value", value);
194
+ }
195
+ call.resolve(result);
196
+ }
197
+
198
+ /**
199
+ * Set value in storage.
200
+ *
201
+ * For sqlite/filesystem the `value` is the FULL wrapper object
202
+ * ({ value, created, updated, expires?, tags?, metadata? }); it is read via
203
+ * call.getObject("value") and stored verbatim (with TTL/query columns
204
+ * extracted for sqlite). For preferences/secure the raw value is forwarded
205
+ * unchanged.
145
206
  */
146
207
  @PluginMethod
147
208
  public void set(PluginCall call) {
@@ -151,7 +212,6 @@ public class StrataStoragePlugin extends Plugin {
151
212
  return;
152
213
  }
153
214
 
154
- Object value = call.getData().opt("value");
155
215
  String storage = call.getString("storage", "preferences");
156
216
 
157
217
  try {
@@ -162,25 +222,42 @@ public class StrataStoragePlugin extends Plugin {
162
222
  call.reject("Encrypted storage not available");
163
223
  return;
164
224
  }
165
- ok = encryptedStorage.set(key, value);
225
+ ok = encryptedStorage.set(key, call.getData().opt("value"));
166
226
  break;
167
- case "sqlite":
168
- if (sqliteStorage == null) {
227
+ case "sqlite": {
228
+ SQLiteStorage store = getSqliteStore(call.getString("database", DEFAULT_DATABASE));
229
+ if (store == null) {
169
230
  call.reject("SQLite storage not available");
170
231
  return;
171
232
  }
172
- ok = sqliteStorage.set(key, value);
233
+ JSObject wrapper = call.getObject("value");
234
+ if (wrapper == null) {
235
+ call.reject("A wrapper object 'value' is required for sqlite storage");
236
+ return;
237
+ }
238
+ ok = store.set(call.getString("table", DEFAULT_TABLE), key, wrapper);
173
239
  break;
174
- case "filesystem":
175
- rejectUnsupportedFilesystem(call);
176
- return;
240
+ }
241
+ case "filesystem": {
242
+ if (filesystemStorage == null) {
243
+ call.reject("Filesystem storage not available");
244
+ return;
245
+ }
246
+ JSObject wrapper = call.getObject("value");
247
+ if (wrapper == null) {
248
+ call.reject("A wrapper object 'value' is required for filesystem storage");
249
+ return;
250
+ }
251
+ ok = filesystemStorage.set(key, wrapper);
252
+ break;
253
+ }
177
254
  case "preferences":
178
255
  default:
179
256
  if (sharedPrefsStorage == null) {
180
257
  call.reject("Preferences storage not available");
181
258
  return;
182
259
  }
183
- ok = sharedPrefsStorage.set(key, value);
260
+ ok = sharedPrefsStorage.set(key, call.getData().opt("value"));
184
261
  break;
185
262
  }
186
263
 
@@ -216,16 +293,22 @@ public class StrataStoragePlugin extends Plugin {
216
293
  }
217
294
  encryptedStorage.remove(key);
218
295
  break;
219
- case "sqlite":
220
- if (sqliteStorage == null) {
296
+ case "sqlite": {
297
+ SQLiteStorage store = getSqliteStore(call.getString("database", DEFAULT_DATABASE));
298
+ if (store == null) {
221
299
  call.reject("SQLite storage not available");
222
300
  return;
223
301
  }
224
- sqliteStorage.remove(key);
302
+ store.remove(call.getString("table", DEFAULT_TABLE), key);
225
303
  break;
304
+ }
226
305
  case "filesystem":
227
- rejectUnsupportedFilesystem(call);
228
- return;
306
+ if (filesystemStorage == null) {
307
+ call.reject("Filesystem storage not available");
308
+ return;
309
+ }
310
+ filesystemStorage.remove(key);
311
+ break;
229
312
  case "preferences":
230
313
  default:
231
314
  if (sharedPrefsStorage == null) {
@@ -263,16 +346,22 @@ public class StrataStoragePlugin extends Plugin {
263
346
  }
264
347
  encryptedStorage.clear(prefix);
265
348
  break;
266
- case "sqlite":
267
- if (sqliteStorage == null) {
349
+ case "sqlite": {
350
+ SQLiteStorage store = getSqliteStore(call.getString("database", DEFAULT_DATABASE));
351
+ if (store == null) {
268
352
  call.reject("SQLite storage not available");
269
353
  return;
270
354
  }
271
- sqliteStorage.clear(prefix);
355
+ store.clear(call.getString("table", DEFAULT_TABLE), prefix);
272
356
  break;
357
+ }
273
358
  case "filesystem":
274
- rejectUnsupportedFilesystem(call);
275
- return;
359
+ if (filesystemStorage == null) {
360
+ call.reject("Filesystem storage not available");
361
+ return;
362
+ }
363
+ filesystemStorage.clear(prefix);
364
+ break;
276
365
  case "preferences":
277
366
  default:
278
367
  if (sharedPrefsStorage == null) {
@@ -298,7 +387,7 @@ public class StrataStoragePlugin extends Plugin {
298
387
  String pattern = call.getString("pattern");
299
388
 
300
389
  try {
301
- List<String> keys = null;
390
+ List<String> keys;
302
391
 
303
392
  switch (storage) {
304
393
  case "secure":
@@ -308,16 +397,22 @@ public class StrataStoragePlugin extends Plugin {
308
397
  }
309
398
  keys = encryptedStorage.keys(pattern);
310
399
  break;
311
- case "sqlite":
312
- if (sqliteStorage == null) {
400
+ case "sqlite": {
401
+ SQLiteStorage store = getSqliteStore(call.getString("database", DEFAULT_DATABASE));
402
+ if (store == null) {
313
403
  call.reject("SQLite storage not available");
314
404
  return;
315
405
  }
316
- keys = sqliteStorage.keys(pattern);
406
+ keys = store.keys(call.getString("table", DEFAULT_TABLE), pattern);
317
407
  break;
408
+ }
318
409
  case "filesystem":
319
- rejectUnsupportedFilesystem(call);
320
- return;
410
+ if (filesystemStorage == null) {
411
+ call.reject("Filesystem storage not available");
412
+ return;
413
+ }
414
+ keys = filesystemStorage.keys(pattern);
415
+ break;
321
416
  case "preferences":
322
417
  default:
323
418
  if (sharedPrefsStorage == null) {
@@ -337,17 +432,21 @@ public class StrataStoragePlugin extends Plugin {
337
432
  }
338
433
 
339
434
  /**
340
- * Get storage size information
435
+ * Get storage size information.
436
+ *
437
+ * When `detailed` is true the result also carries a byte breakdown
438
+ * { detailed: { keys, values, metadata } }.
341
439
  */
342
440
  @PluginMethod
343
441
  public void size(PluginCall call) {
344
442
  String storage = call.getString("storage", "preferences");
443
+ boolean detailed = Boolean.TRUE.equals(call.getBoolean("detailed", false));
345
444
 
346
445
  try {
347
446
  JSObject result = new JSObject();
348
447
 
349
448
  switch (storage) {
350
- case "secure":
449
+ case "secure": {
351
450
  if (encryptedStorage == null) {
352
451
  call.reject("Encrypted storage not available");
353
452
  return;
@@ -356,20 +455,29 @@ public class StrataStoragePlugin extends Plugin {
356
455
  result.put("total", encryptedSizeInfo.total);
357
456
  result.put("count", encryptedSizeInfo.count);
358
457
  break;
359
- case "sqlite":
360
- if (sqliteStorage == null) {
458
+ }
459
+ case "sqlite": {
460
+ SQLiteStorage store = getSqliteStore(call.getString("database", DEFAULT_DATABASE));
461
+ if (store == null) {
361
462
  call.reject("SQLite storage not available");
362
463
  return;
363
464
  }
364
- SQLiteStorage.SizeInfo sqliteSizeInfo = sqliteStorage.size();
365
- result.put("total", sqliteSizeInfo.total);
366
- result.put("count", sqliteSizeInfo.count);
465
+ SQLiteStorage.SizeInfo sqliteSizeInfo =
466
+ store.size(call.getString("table", DEFAULT_TABLE), detailed);
467
+ putSizeInfo(result, sqliteSizeInfo);
367
468
  break;
368
- case "filesystem":
369
- rejectUnsupportedFilesystem(call);
370
- return;
469
+ }
470
+ case "filesystem": {
471
+ if (filesystemStorage == null) {
472
+ call.reject("Filesystem storage not available");
473
+ return;
474
+ }
475
+ SQLiteStorage.SizeInfo fsSizeInfo = filesystemStorage.size(detailed);
476
+ putSizeInfo(result, fsSizeInfo);
477
+ break;
478
+ }
371
479
  case "preferences":
372
- default:
480
+ default: {
373
481
  if (sharedPrefsStorage == null) {
374
482
  call.reject("Preferences storage not available");
375
483
  return;
@@ -378,6 +486,7 @@ public class StrataStoragePlugin extends Plugin {
378
486
  result.put("total", prefsSizeInfo.total);
379
487
  result.put("count", prefsSizeInfo.count);
380
488
  break;
489
+ }
381
490
  }
382
491
 
383
492
  call.resolve(result);
@@ -386,12 +495,27 @@ public class StrataStoragePlugin extends Plugin {
386
495
  }
387
496
  }
388
497
 
498
+ /**
499
+ * Populate a size result, including the detailed byte breakdown when the
500
+ * {@link SQLiteStorage.SizeInfo} carries one.
501
+ */
502
+ private void putSizeInfo(JSObject result, SQLiteStorage.SizeInfo info) {
503
+ result.put("total", info.total);
504
+ result.put("count", info.count);
505
+ if (info.detailed) {
506
+ JSObject breakdown = new JSObject();
507
+ breakdown.put("keys", info.keysBytes);
508
+ breakdown.put("values", info.valuesBytes);
509
+ breakdown.put("metadata", info.metadataBytes);
510
+ result.put("detailed", breakdown);
511
+ }
512
+ }
513
+
389
514
  /**
390
515
  * Query SQLite-backed storage.
391
516
  * Matches the optional `query` method in the JS contract:
392
- * resolves { results: [{ key, value }] }. The JS SqliteAdapter applies the
393
- * real condition by re-fetching/filtering, so this enumerates candidate
394
- * rows.
517
+ * resolves { results: [{ key }] }. The JS SqliteAdapter applies the real
518
+ * condition by re-fetching/filtering, so this enumerates candidate keys.
395
519
  */
396
520
  @PluginMethod
397
521
  public void query(PluginCall call) {
@@ -400,18 +524,18 @@ public class StrataStoragePlugin extends Plugin {
400
524
  call.reject("Query is only supported for the 'sqlite' storage type");
401
525
  return;
402
526
  }
403
- if (sqliteStorage == null) {
527
+ SQLiteStorage store = getSqliteStore(call.getString("database", DEFAULT_DATABASE));
528
+ if (store == null) {
404
529
  call.reject("SQLite storage not available");
405
530
  return;
406
531
  }
407
532
 
408
533
  try {
409
- List<Map<String, Object>> rows = sqliteStorage.query();
534
+ List<Map<String, Object>> rows = store.query(call.getString("table", DEFAULT_TABLE));
410
535
  JSArray results = new JSArray();
411
536
  for (Map<String, Object> row : rows) {
412
537
  JSObject obj = new JSObject();
413
538
  obj.put("key", row.get("key"));
414
- obj.put("value", row.get("value"));
415
539
  results.put(obj);
416
540
  }
417
541
  JSObject result = new JSObject();
@@ -1,6 +1,6 @@
1
1
  # AGENTS.md — ios/
2
2
 
3
- Last Updated: 2026-04-03
3
+ Last Updated: 2026-05-27
4
4
 
5
5
  > Agent instructions for iOS native development.
6
6
 
@@ -11,7 +11,16 @@ Last Updated: 2026-04-03
11
11
  | `Plugin/StrataStoragePlugin.swift` | Capacitor plugin entry |
12
12
  | `Plugin/UserDefaultsStorage.swift` | General key-value storage |
13
13
  | `Plugin/KeychainStorage.swift` | Secure storage |
14
- | `Plugin/SQLiteStorage.swift` | Database storage (~11KB) |
14
+ | `Plugin/SQLiteStorage.swift` | SQLite database storage — multi-store (v2.6.0), `database`/`table` options honoured, full `StorageValue` wrapper round-trip, `size(detailed)` supported |
15
+ | `Plugin/FilesystemStorage.swift` | File-per-key native storage under `NSDocumentsDirectory/strata_storage/` (new in v2.6.0); atomic writes via staging rename; `isAvailable()` returns `true` |
16
+
17
+ ## v2.6.0 Notes
18
+
19
+ - **SQLite multi-store:** `database` option → distinct `.db` file. `table` option → sanitised table identifier `[A-Za-z0-9_]`. Previously both were ignored and all adapters shared one table.
20
+ - **SQLite value shape:** `get` now returns the full `StorageValue` wrapper (`value`, `created`, `updated`, `expires`, `tags`, `metadata`). Corrupt rows are treated as a miss.
21
+ - **SQLite bind safety:** text/blob binds use `SQLITE_TRANSIENT` — removes a latent use-after-free for transient Swift buffers.
22
+ - **FilesystemStorage.swift:** new file. Writes are atomic (staging temp → rename). `keys()` excludes `.staging/` artifacts. `size(detailed: true)` returns `{ keys, values, metadata }`.
23
+ - **Pending on-device verification** — see `docs/guides/platforms/device-verification.md`.
15
24
 
16
25
  ## Agent Rules
17
26
 
@@ -20,14 +29,20 @@ Last Updated: 2026-04-03
20
29
  - NEVER store secrets in `UserDefaultsStorage`
21
30
 
22
31
  ### SQL Safety
23
- - ALWAYS use parameterized queries in SQLiteStorage
32
+ - ALWAYS use parameterized queries in `SQLiteStorage`
24
33
  - NEVER string-interpolate SQL values
34
+ - Use `SQLITE_TRANSIENT` for text/blob binds
35
+
36
+ ### Filesystem Safety
37
+ - NEVER read from `strata_storage/.staging/` — staging files are transient
38
+ - Key sanitisation must be consistent between `set`, `get`, `remove`, and `keys`
25
39
 
26
40
  ### Plugin Architecture
27
41
  - Methods exposed through `StrataStoragePlugin.swift`
28
42
  - Each storage backend is a separate Swift file
29
43
  - Bridges Capacitor JS calls to native Swift
44
+ - The `CAP_PLUGIN` macro must include every callable method name
30
45
 
31
46
  ### Before Modifying
32
47
  - Understand Capacitor plugin protocol
33
- - Test on iOS simulator after changes
48
+ - Test on iOS simulator after changes; follow device-verification guide
@@ -1,6 +1,6 @@
1
1
  # CLAUDE.md — ios/
2
2
 
3
- Last Updated: 2026-04-03
3
+ Last Updated: 2026-05-27
4
4
 
5
5
  ## iOS Native Plugin
6
6
 
@@ -13,7 +13,8 @@ Swift implementation of native storage backends for Capacitor.
13
13
  | `Plugin/StrataStoragePlugin.swift` | Main Capacitor plugin entry point |
14
14
  | `Plugin/UserDefaultsStorage.swift` | UserDefaults-based general storage |
15
15
  | `Plugin/KeychainStorage.swift` | Keychain-based secure storage |
16
- | `Plugin/SQLiteStorage.swift` | SQLite database storage (~11KB) |
16
+ | `Plugin/SQLiteStorage.swift` | SQLite database storage (multi-store, v2.6.0) |
17
+ | `Plugin/FilesystemStorage.swift` | File-per-key storage under `NSDocumentsDirectory/strata_storage/` (new in v2.6.0) |
17
18
 
18
19
  ### Configuration
19
20
 
@@ -21,6 +22,30 @@ Swift implementation of native storage backends for Capacitor.
21
22
  - Minimum iOS version: defined in podspec
22
23
  - Language: Swift
23
24
 
25
+ ## v2.6.0 Changes
26
+
27
+ ### SQLite multi-store
28
+ `SQLiteStorage.swift` now accepts `database` and `table` parameters from the
29
+ Capacitor call options. Each unique `(database, table)` pair opens a separate
30
+ `.db` file in the app's documents directory. Table identifiers are sanitised to
31
+ `[A-Za-z0-9_]` before use in SQL. The full `StorageValue` wrapper is serialised
32
+ to JSON and stored in a single `value` column; native `get` deserialises and
33
+ returns the full wrapper so TTL, tags, and metadata survive the round-trip.
34
+ Text/blob binds use `SQLITE_TRANSIENT` (removes a latent use-after-free for
35
+ transient Swift buffers). `size(detailed: true)` returns per-column byte
36
+ breakdown.
37
+
38
+ ### FilesystemStorage.swift (new)
39
+ Stores each key as `NSDocumentsDirectory/strata_storage/<sanitised-key>.json`.
40
+ Writes are atomic: the value is first written to
41
+ `strata_storage/.staging/<key>.tmp`, then renamed into place. Temp files live in
42
+ the staging subdirectory, so they are never exposed by `keys()` and are not
43
+ deleted by a targeted `remove()`. `isAvailable()` returns `true` on device.
44
+ `size(detailed: true)` supported.
45
+
46
+ > **Pending on-device verification** — native code is complete and reviewed; see
47
+ > `docs/guides/platforms/device-verification.md` for the test matrix.
48
+
24
49
  ## Rules
25
50
 
26
51
  ### Security (IRON-SOLID)
@@ -32,15 +57,25 @@ Swift implementation of native storage backends for Capacitor.
32
57
  - All native methods are exposed through `StrataStoragePlugin.swift`
33
58
  - Plugin bridges Capacitor JS calls to native Swift implementations
34
59
  - Each storage backend is a separate Swift file
60
+ - The `CAP_PLUGIN` macro must list every callable method; missing entries cause
61
+ silent `undefined` returns on the JS side
35
62
 
36
63
  ### SQLite
37
- - `SQLiteStorage.swift` is the largest file (~11KB)
64
+ - `SQLiteStorage.swift` opens or creates the `.db` file identified by the
65
+ `database` call option (default `strata_storage.db`)
38
66
  - Handles table creation, migrations, and CRUD operations
39
67
  - Use parameterized queries — NEVER string-interpolate SQL
68
+ - Use `SQLITE_TRANSIENT` for text/blob binds
69
+
70
+ ### Filesystem
71
+ - Key strings are sanitised before use as filenames (`/` → `_`, etc.)
72
+ - Staging renames ensure atomic writes; never read from `.staging/`
73
+ - `keys()` lists `.json` files only, ignoring staging artifacts
40
74
 
41
75
  ### Testing
42
76
  - Native code is tested through the Capacitor bridge
43
- - Test via the demo app on iOS simulator
77
+ - Test via the demo app on iOS simulator following
78
+ `docs/guides/platforms/device-verification.md`
44
79
  - Verify all adapter methods work end-to-end
45
80
 
46
81
  ### Before Modifying