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.
- package/AI-INTEGRATION-GUIDE.md +12 -3
- package/README.md +31 -9
- package/android/AGENTS.md +24 -7
- package/android/CLAUDE.md +42 -4
- package/android/build.gradle +1 -1
- package/android/src/main/java/com/strata/storage/FilesystemStorage.java +287 -0
- package/android/src/main/java/com/strata/storage/SQLiteStorage.java +243 -221
- package/android/src/main/java/com/stratastorage/StrataStoragePlugin.java +202 -78
- package/dist/README.md +31 -9
- package/dist/android/AGENTS.md +24 -7
- package/dist/android/CLAUDE.md +42 -4
- package/dist/android/build.gradle +1 -1
- package/dist/android/src/main/java/com/strata/storage/FilesystemStorage.java +287 -0
- package/dist/android/src/main/java/com/strata/storage/SQLiteStorage.java +243 -221
- package/dist/android/src/main/java/com/stratastorage/StrataStoragePlugin.java +202 -78
- package/dist/ios/AGENTS.md +19 -4
- package/dist/ios/CLAUDE.md +39 -4
- package/dist/ios/Plugin/FilesystemStorage.swift +218 -0
- package/dist/ios/Plugin/SQLiteStorage.swift +265 -173
- package/dist/ios/Plugin/StrataStoragePlugin.swift +100 -42
- package/dist/package.json +1 -1
- package/ios/AGENTS.md +19 -4
- package/ios/CLAUDE.md +39 -4
- package/ios/Plugin/FilesystemStorage.swift +218 -0
- package/ios/Plugin/SQLiteStorage.swift +265 -173
- package/ios/Plugin/StrataStoragePlugin.swift +100 -42
- package/package.json +6 -6
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import os.log
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Filesystem-backed storage: one file per key under
|
|
6
|
+
* `<Documents>/strata_storage/`.
|
|
7
|
+
*
|
|
8
|
+
* Value-shape contract (matches src/plugin/definitions.ts, same as SQLite):
|
|
9
|
+
* - File contents are the JSON-serialized FULL `StorageValue` wrapper
|
|
10
|
+
* `{ value, created, updated, expires?, tags?, metadata?, ... }`.
|
|
11
|
+
* - `get` parses the bytes back into the wrapper object; the plugin resolves
|
|
12
|
+
* `{ value: wrapper }`. Missing file OR non-object bytes → miss (no crash).
|
|
13
|
+
*
|
|
14
|
+
* File naming: the storage key is percent-encoded with an allowed set that
|
|
15
|
+
* EXCLUDES `/` (and `%`), so the encoding is reversible and never escapes the
|
|
16
|
+
* storage directory. `decodeFileName` reverses it for `keys()`.
|
|
17
|
+
*
|
|
18
|
+
* Durability: writes go to a temp file in the same directory and are then
|
|
19
|
+
* atomically replaced into place, so a crash mid-write cannot leave a torn
|
|
20
|
+
* file at the real path.
|
|
21
|
+
*/
|
|
22
|
+
@objc public class FilesystemStorage: NSObject {
|
|
23
|
+
private let directory: URL
|
|
24
|
+
private let stagingDirectory: URL
|
|
25
|
+
private let logger = OSLog(subsystem: "com.strata.storage", category: "FilesystemStorage")
|
|
26
|
+
|
|
27
|
+
/// Reserved staging subdirectory name (inside the storage dir) holding
|
|
28
|
+
/// in-flight temp files for atomic writes. Enumeration skips it by name, so
|
|
29
|
+
/// a temp file can never collide with an encoded key file (e.g. a real key
|
|
30
|
+
/// literally named ".tmp-x").
|
|
31
|
+
private static let stagingDirName = ".strata-staging"
|
|
32
|
+
|
|
33
|
+
/// Characters allowed verbatim in an on-disk file name. Anything else
|
|
34
|
+
/// (notably `/` and `%`) is percent-escaped, keeping the name reversible
|
|
35
|
+
/// and confined to a single path component.
|
|
36
|
+
private static let fileNameAllowed: CharacterSet = {
|
|
37
|
+
var set = CharacterSet.alphanumerics
|
|
38
|
+
set.insert(charactersIn: "-_. ")
|
|
39
|
+
return set
|
|
40
|
+
}()
|
|
41
|
+
|
|
42
|
+
@objc public override init() {
|
|
43
|
+
let base = (try? FileManager.default.url(
|
|
44
|
+
for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true
|
|
45
|
+
)) ?? URL(fileURLWithPath: NSTemporaryDirectory())
|
|
46
|
+
let storageDir = base.appendingPathComponent("strata_storage", isDirectory: true)
|
|
47
|
+
self.directory = storageDir
|
|
48
|
+
self.stagingDirectory = storageDir.appendingPathComponent(FilesystemStorage.stagingDirName, isDirectory: true)
|
|
49
|
+
super.init()
|
|
50
|
+
ensureDirectory()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private func ensureDirectory() {
|
|
54
|
+
for dir in [directory, stagingDirectory] {
|
|
55
|
+
if !FileManager.default.fileExists(atPath: dir.path) {
|
|
56
|
+
do {
|
|
57
|
+
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
58
|
+
} catch {
|
|
59
|
+
os_log("Failed to create filesystem storage dir: %{public}@", log: logger, type: .error, error.localizedDescription)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Reversible encoding of a storage key → safe single-component file name.
|
|
66
|
+
private func encodeFileName(_ key: String) -> String {
|
|
67
|
+
// addingPercentEncoding only returns nil for invalid unichar sequences,
|
|
68
|
+
// which a Swift String key cannot contain; fall back defensively.
|
|
69
|
+
return key.addingPercentEncoding(withAllowedCharacters: FilesystemStorage.fileNameAllowed) ?? key
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// Reverses `encodeFileName`. Returns nil if the name cannot be decoded.
|
|
73
|
+
private func decodeFileName(_ name: String) -> String? {
|
|
74
|
+
return name.removingPercentEncoding
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private func fileURL(forKey key: String) -> URL {
|
|
78
|
+
return directory.appendingPathComponent(encodeFileName(key), isDirectory: false)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Persist the full `StorageValue` wrapper for `key`. The wrapper is
|
|
83
|
+
* JSON-encoded and written atomically (temp file + replace).
|
|
84
|
+
*/
|
|
85
|
+
@objc public func set(key: String, wrapper: [String: Any]) throws -> Bool {
|
|
86
|
+
ensureDirectory()
|
|
87
|
+
let data = try JSONSerialization.data(withJSONObject: wrapper, options: [])
|
|
88
|
+
let target = fileURL(forKey: key)
|
|
89
|
+
|
|
90
|
+
// Atomic write: stage to a unique temp file in the reserved staging
|
|
91
|
+
// subdir (same volume → atomic rename), then replace. The staging subdir
|
|
92
|
+
// keeps temp names out of the key namespace entirely.
|
|
93
|
+
let temp = stagingDirectory.appendingPathComponent(UUID().uuidString, isDirectory: false)
|
|
94
|
+
do {
|
|
95
|
+
try data.write(to: temp, options: .atomic)
|
|
96
|
+
if FileManager.default.fileExists(atPath: target.path) {
|
|
97
|
+
_ = try FileManager.default.replaceItemAt(target, withItemAt: temp)
|
|
98
|
+
} else {
|
|
99
|
+
try FileManager.default.moveItem(at: temp, to: target)
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// Clean up the temp file on any failure so we don't leak partials.
|
|
103
|
+
try? FileManager.default.removeItem(at: temp)
|
|
104
|
+
throw error
|
|
105
|
+
}
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Read the wrapper object for `key`. Missing file OR bytes that are not a
|
|
111
|
+
* JSON object → nil (treated as a miss; never throws on corrupt data).
|
|
112
|
+
*/
|
|
113
|
+
@objc public func get(key: String) -> [String: Any]? {
|
|
114
|
+
let url = fileURL(forKey: key)
|
|
115
|
+
guard let data = try? Data(contentsOf: url) else {
|
|
116
|
+
return nil
|
|
117
|
+
}
|
|
118
|
+
guard let wrapper = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
|
|
119
|
+
return nil
|
|
120
|
+
}
|
|
121
|
+
return wrapper
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
@objc public func remove(key: String) -> Bool {
|
|
125
|
+
let url = fileURL(forKey: key)
|
|
126
|
+
if !FileManager.default.fileExists(atPath: url.path) {
|
|
127
|
+
return true // already absent — treat as success/idempotent
|
|
128
|
+
}
|
|
129
|
+
do {
|
|
130
|
+
try FileManager.default.removeItem(at: url)
|
|
131
|
+
return true
|
|
132
|
+
} catch {
|
|
133
|
+
os_log("Failed to remove filesystem key: %{public}@", log: logger, type: .error, error.localizedDescription)
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/// Clears stored entries. When `prefix` is set, only keys starting with it
|
|
139
|
+
/// are removed; otherwise the whole storage directory contents are removed.
|
|
140
|
+
@objc public func clear(prefix: String? = nil) -> Bool {
|
|
141
|
+
let names = (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? []
|
|
142
|
+
var ok = true
|
|
143
|
+
for name in names {
|
|
144
|
+
// Skip the staging subdirectory (never a key).
|
|
145
|
+
if name == FilesystemStorage.stagingDirName { continue }
|
|
146
|
+
guard let key = decodeFileName(name) else { continue }
|
|
147
|
+
if let prefix = prefix, !key.hasPrefix(prefix) { continue }
|
|
148
|
+
do {
|
|
149
|
+
try FileManager.default.removeItem(at: directory.appendingPathComponent(name, isDirectory: false))
|
|
150
|
+
} catch {
|
|
151
|
+
ok = false
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// On a full clear, also drop any orphaned in-flight temp files.
|
|
155
|
+
if prefix == nil, let temps = try? FileManager.default.contentsOfDirectory(atPath: stagingDirectory.path) {
|
|
156
|
+
for temp in temps {
|
|
157
|
+
try? FileManager.default.removeItem(at: stagingDirectory.appendingPathComponent(temp, isDirectory: false))
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return ok
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/// Lists stored keys, decoding file names back to their original keys.
|
|
164
|
+
/// When `pattern` is set, applies the same prefix/contains matching the
|
|
165
|
+
/// other backends use.
|
|
166
|
+
@objc public func keys(pattern: String? = nil) -> [String] {
|
|
167
|
+
let names = (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? []
|
|
168
|
+
var keys: [String] = []
|
|
169
|
+
for name in names {
|
|
170
|
+
if name == FilesystemStorage.stagingDirName { continue }
|
|
171
|
+
guard let key = decodeFileName(name) else { continue }
|
|
172
|
+
keys.append(key)
|
|
173
|
+
}
|
|
174
|
+
guard let pattern = pattern else { return keys }
|
|
175
|
+
return keys.filter { $0.hasPrefix(pattern) || $0.contains(pattern) }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Aggregate sizes over the storage directory.
|
|
180
|
+
*
|
|
181
|
+
* `count` = number of entry files; `values` = sum of file byte sizes;
|
|
182
|
+
* `keys` = sum of decoded-key UTF-8 byte lengths; `metadata` = sum of the
|
|
183
|
+
* serialized `metadata` segment per file (0 when absent or unparseable);
|
|
184
|
+
* `total` = `keys` + `values`.
|
|
185
|
+
*/
|
|
186
|
+
@objc public func size() -> [String: Int] {
|
|
187
|
+
let names = (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? []
|
|
188
|
+
var count = 0
|
|
189
|
+
var keysSize = 0
|
|
190
|
+
var valuesSize = 0
|
|
191
|
+
var metadataSize = 0
|
|
192
|
+
|
|
193
|
+
for name in names {
|
|
194
|
+
if name == FilesystemStorage.stagingDirName { continue }
|
|
195
|
+
guard let key = decodeFileName(name) else { continue }
|
|
196
|
+
let url = directory.appendingPathComponent(name, isDirectory: false)
|
|
197
|
+
count += 1
|
|
198
|
+
keysSize += key.data(using: .utf8)?.count ?? 0
|
|
199
|
+
|
|
200
|
+
if let data = try? Data(contentsOf: url) {
|
|
201
|
+
valuesSize += data.count
|
|
202
|
+
if let wrapper = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
|
|
203
|
+
let metadata = wrapper["metadata"],
|
|
204
|
+
let metadataData = try? JSONSerialization.data(withJSONObject: metadata, options: []) {
|
|
205
|
+
metadataSize += metadataData.count
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return [
|
|
211
|
+
"count": count,
|
|
212
|
+
"keys": keysSize,
|
|
213
|
+
"values": valuesSize,
|
|
214
|
+
"metadata": metadataSize,
|
|
215
|
+
"total": keysSize + valuesSize
|
|
216
|
+
]
|
|
217
|
+
}
|
|
218
|
+
}
|