@okint-digital/okint-rn-storage 0.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.
- package/LICENSE +21 -0
- package/README.md +217 -0
- package/android/build.gradle +59 -0
- package/android/proguard-rules.pro +8 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/okint/rnstorage/OkintRnStorageModule.kt +354 -0
- package/android/src/main/java/com/okint/rnstorage/OkintRnStoragePackage.kt +14 -0
- package/android/src/main/jni/CMakeLists.txt +24 -0
- package/android/src/main/jni/OkintJNI.cpp +15 -0
- package/cpp/OkintJSI.cpp +170 -0
- package/cpp/OkintJSI.h +18 -0
- package/ios/OkintRnStorage.m +457 -0
- package/ios/OkintRnStorageJSI.mm +24 -0
- package/lib/backends/memory.d.ts +16 -0
- package/lib/backends/memory.js +31 -0
- package/lib/backends/native-backend.d.ts +24 -0
- package/lib/backends/native-backend.js +73 -0
- package/lib/errors.d.ts +9 -0
- package/lib/errors.js +19 -0
- package/lib/facade.d.ts +29 -0
- package/lib/facade.js +93 -0
- package/lib/index.d.ts +48 -0
- package/lib/index.js +172 -0
- package/lib/native/bridge.d.ts +12 -0
- package/lib/native/bridge.js +23 -0
- package/lib/native/jsi.d.ts +7 -0
- package/lib/native/jsi.js +33 -0
- package/lib/sync/jsi-store.d.ts +28 -0
- package/lib/sync/jsi-store.js +81 -0
- package/lib/sync/persistence.d.ts +19 -0
- package/lib/sync/persistence.js +49 -0
- package/lib/sync/sync-store.d.ts +49 -0
- package/lib/sync/sync-store.js +159 -0
- package/lib/types.d.ts +140 -0
- package/lib/types.js +10 -0
- package/lib/validate.d.ts +20 -0
- package/lib/validate.js +91 -0
- package/okint-rn-storage.podspec +27 -0
- package/package.json +74 -0
- package/react-native.config.js +15 -0
- package/src/backends/memory.ts +35 -0
- package/src/backends/native-backend.ts +69 -0
- package/src/errors.ts +26 -0
- package/src/facade.ts +118 -0
- package/src/index.ts +194 -0
- package/src/native/bridge.ts +28 -0
- package/src/native/jsi.ts +37 -0
- package/src/sync/jsi-store.ts +98 -0
- package/src/sync/persistence.ts +47 -0
- package/src/sync/sync-store.ts +186 -0
- package/src/types.ts +174 -0
- package/src/validate.ts +102 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { JSIStore, OkintSyncStorage, SyncBackendKind } from '../types';
|
|
2
|
+
import {
|
|
3
|
+
assertKey,
|
|
4
|
+
assertStringValue,
|
|
5
|
+
fromJson,
|
|
6
|
+
numberToString,
|
|
7
|
+
stringToBoolean,
|
|
8
|
+
stringToNumber,
|
|
9
|
+
toJson,
|
|
10
|
+
} from '../validate';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* OkintSyncStorage backed by the C++/JSI HostObject. Reads and writes go
|
|
14
|
+
* straight into C++ with no bridge serialization and no snapshot — the
|
|
15
|
+
* maximum-performance synchronous path. Writes are persisted synchronously by
|
|
16
|
+
* the native engine, so `flush()` is a no-op.
|
|
17
|
+
*/
|
|
18
|
+
export class JSISyncStore implements OkintSyncStorage {
|
|
19
|
+
readonly backend: SyncBackendKind = 'fast';
|
|
20
|
+
|
|
21
|
+
constructor(private readonly store: JSIStore) {}
|
|
22
|
+
|
|
23
|
+
getString(key: string): string | null {
|
|
24
|
+
assertKey(key);
|
|
25
|
+
return this.store.getString(key);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
setString(key: string, value: string): void {
|
|
29
|
+
assertKey(key);
|
|
30
|
+
assertStringValue(value);
|
|
31
|
+
this.store.setString(key, value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
getItem<T>(key: string): T | null {
|
|
35
|
+
const raw = this.getString(key);
|
|
36
|
+
return raw == null ? null : fromJson<T>(key, raw);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
setItem<T>(key: string, value: T): void {
|
|
40
|
+
assertKey(key);
|
|
41
|
+
this.store.setString(key, toJson(key, value));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
getNumber(key: string): number | null {
|
|
45
|
+
const raw = this.getString(key);
|
|
46
|
+
return raw == null ? null : stringToNumber(raw);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
setNumber(key: string, value: number): void {
|
|
50
|
+
assertKey(key);
|
|
51
|
+
this.store.setString(key, numberToString(key, value));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
getBoolean(key: string): boolean | null {
|
|
55
|
+
const raw = this.getString(key);
|
|
56
|
+
return raw == null ? null : stringToBoolean(raw);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
setBoolean(key: string, value: boolean): void {
|
|
60
|
+
this.setString(key, value ? 'true' : 'false');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
has(key: string): boolean {
|
|
64
|
+
assertKey(key);
|
|
65
|
+
return this.store.contains(key);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
remove(key: string): void {
|
|
69
|
+
assertKey(key);
|
|
70
|
+
this.store.remove(key);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
clear(): void {
|
|
74
|
+
this.store.clear();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
keys(): string[] {
|
|
78
|
+
return this.store.getAllKeys();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
multiGet(keys: string[]): Record<string, string | null> {
|
|
82
|
+
const out: Record<string, string | null> = {};
|
|
83
|
+
for (const k of keys) out[k] = this.getString(k);
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
multiSet(entries: Record<string, string>): void {
|
|
88
|
+
for (const [k, v] of Object.entries(entries)) this.setString(k, v);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
multiRemove(keys: string[]): void {
|
|
92
|
+
for (const k of keys) this.remove(k);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async flush(): Promise<void> {
|
|
96
|
+
// The JSI engine persists synchronously on each write — nothing to flush.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { StorageBackend, SyncPersistence } from '../types';
|
|
2
|
+
|
|
3
|
+
/** No-op persistence for the ephemeral `memory` sync store. */
|
|
4
|
+
export class MemorySyncPersistence implements SyncPersistence {
|
|
5
|
+
async loadAll(): Promise<Record<string, string>> {
|
|
6
|
+
return {};
|
|
7
|
+
}
|
|
8
|
+
async persist(): Promise<void> {
|
|
9
|
+
/* nothing to persist */
|
|
10
|
+
}
|
|
11
|
+
async clearAll(): Promise<void> {
|
|
12
|
+
/* nothing to clear */
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Persists a sync store to any async StorageBackend (e.g. the native plain
|
|
18
|
+
* store). Pure TS — takes the backend by injection, so it's testable in Node
|
|
19
|
+
* with a MemoryBackend standing in for native.
|
|
20
|
+
*/
|
|
21
|
+
export class BackendSyncPersistence implements SyncPersistence {
|
|
22
|
+
constructor(private readonly backend: StorageBackend) {}
|
|
23
|
+
|
|
24
|
+
async loadAll(): Promise<Record<string, string>> {
|
|
25
|
+
const keys = await this.backend.keys();
|
|
26
|
+
const out: Record<string, string> = {};
|
|
27
|
+
await Promise.all(
|
|
28
|
+
keys.map(async (k) => {
|
|
29
|
+
const v = await this.backend.getString(k);
|
|
30
|
+
if (v !== null) out[k] = v;
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async persist(key: string, value: string | null): Promise<void> {
|
|
37
|
+
if (value === null) {
|
|
38
|
+
await this.backend.remove(key);
|
|
39
|
+
} else {
|
|
40
|
+
await this.backend.setString(key, value);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async clearAll(): Promise<void> {
|
|
45
|
+
await this.backend.clear();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { OkintSyncStorage, SyncBackendKind, SyncPersistence } from '../types';
|
|
2
|
+
import { OkintStorageError } from '../errors';
|
|
3
|
+
import {
|
|
4
|
+
assertKey,
|
|
5
|
+
assertStringValue,
|
|
6
|
+
fromJson,
|
|
7
|
+
numberToString,
|
|
8
|
+
stringToBoolean,
|
|
9
|
+
stringToNumber,
|
|
10
|
+
toJson,
|
|
11
|
+
} from '../validate';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Synchronous storage over an in-memory snapshot. After `load()`, every accessor
|
|
15
|
+
* is synchronous; writes update the map immediately and are persisted to the
|
|
16
|
+
* backing `SyncPersistence` in the background.
|
|
17
|
+
*
|
|
18
|
+
* Persistence is **coalesced**: a burst of writes collapses to the latest value
|
|
19
|
+
* per key and is drained once per microtask (bounded by the number of distinct
|
|
20
|
+
* dirty keys, not the number of writes). `flush()` awaits durability and
|
|
21
|
+
* surfaces any background failure.
|
|
22
|
+
*/
|
|
23
|
+
export class OkintSyncStore implements OkintSyncStorage {
|
|
24
|
+
readonly backend: SyncBackendKind;
|
|
25
|
+
private map = new Map<string, string>();
|
|
26
|
+
private loaded = false;
|
|
27
|
+
|
|
28
|
+
// Coalescing state.
|
|
29
|
+
private pending = new Map<string, string | null>(); // value === null → delete
|
|
30
|
+
private pendingClear = false;
|
|
31
|
+
private drainScheduled = false;
|
|
32
|
+
private chain: Promise<void> = Promise.resolve();
|
|
33
|
+
private persistErrors: unknown[] = [];
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
backend: SyncBackendKind,
|
|
37
|
+
private readonly persistence: SyncPersistence,
|
|
38
|
+
) {
|
|
39
|
+
this.backend = backend;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Load the snapshot. Idempotent — the factory calls it once; extra calls no-op. */
|
|
43
|
+
async load(): Promise<void> {
|
|
44
|
+
if (this.loaded) return;
|
|
45
|
+
const all = await this.persistence.loadAll();
|
|
46
|
+
this.map = new Map(Object.entries(all));
|
|
47
|
+
this.loaded = true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Synchronous hydration (the zero-load path). Used by `createSyncStorageSync`
|
|
52
|
+
* after one blocking native bulk-read. Idempotent like `load()`.
|
|
53
|
+
*/
|
|
54
|
+
loadSync(entries: Record<string, string>): void {
|
|
55
|
+
if (this.loaded) return;
|
|
56
|
+
this.map = new Map(Object.entries(entries));
|
|
57
|
+
this.loaded = true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
getString(key: string): string | null {
|
|
61
|
+
assertKey(key);
|
|
62
|
+
const v = this.map.get(key);
|
|
63
|
+
return v === undefined ? null : v;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
setString(key: string, value: string): void {
|
|
67
|
+
assertKey(key);
|
|
68
|
+
assertStringValue(value);
|
|
69
|
+
this.map.set(key, value);
|
|
70
|
+
this.queueWrite(key, value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
getItem<T>(key: string): T | null {
|
|
74
|
+
const raw = this.getString(key);
|
|
75
|
+
return raw == null ? null : fromJson<T>(key, raw);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setItem<T>(key: string, value: T): void {
|
|
79
|
+
assertKey(key);
|
|
80
|
+
const json = toJson(key, value);
|
|
81
|
+
this.map.set(key, json);
|
|
82
|
+
this.queueWrite(key, json);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
getNumber(key: string): number | null {
|
|
86
|
+
const raw = this.getString(key);
|
|
87
|
+
return raw == null ? null : stringToNumber(raw);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
setNumber(key: string, value: number): void {
|
|
91
|
+
assertKey(key);
|
|
92
|
+
const s = numberToString(key, value);
|
|
93
|
+
this.map.set(key, s);
|
|
94
|
+
this.queueWrite(key, s);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
getBoolean(key: string): boolean | null {
|
|
98
|
+
const raw = this.getString(key);
|
|
99
|
+
return raw == null ? null : stringToBoolean(raw);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
setBoolean(key: string, value: boolean): void {
|
|
103
|
+
this.setString(key, value ? 'true' : 'false');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
has(key: string): boolean {
|
|
107
|
+
assertKey(key);
|
|
108
|
+
return this.map.has(key);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
remove(key: string): void {
|
|
112
|
+
assertKey(key);
|
|
113
|
+
this.map.delete(key);
|
|
114
|
+
this.queueWrite(key, null);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
clear(): void {
|
|
118
|
+
this.map.clear();
|
|
119
|
+
this.pending.clear(); // superseded by the clear
|
|
120
|
+
this.pendingClear = true;
|
|
121
|
+
this.schedule();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
keys(): string[] {
|
|
125
|
+
return [...this.map.keys()];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
multiGet(keys: string[]): Record<string, string | null> {
|
|
129
|
+
const out: Record<string, string | null> = {};
|
|
130
|
+
for (const k of keys) out[k] = this.getString(k);
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
multiSet(entries: Record<string, string>): void {
|
|
135
|
+
for (const [k, v] of Object.entries(entries)) this.setString(k, v);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
multiRemove(keys: string[]): void {
|
|
139
|
+
for (const k of keys) this.remove(k);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async flush(): Promise<void> {
|
|
143
|
+
if ((this.pending.size > 0 || this.pendingClear) && !this.drainScheduled) {
|
|
144
|
+
this.schedule();
|
|
145
|
+
}
|
|
146
|
+
await this.chain;
|
|
147
|
+
if (this.persistErrors.length > 0) {
|
|
148
|
+
const first = this.persistErrors[0];
|
|
149
|
+
const count = this.persistErrors.length;
|
|
150
|
+
this.persistErrors = [];
|
|
151
|
+
throw new OkintStorageError(
|
|
152
|
+
'NATIVE_ERROR',
|
|
153
|
+
`${count} background persist operation(s) failed.`,
|
|
154
|
+
first,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private queueWrite(key: string, value: string | null): void {
|
|
160
|
+
this.pending.set(key, value);
|
|
161
|
+
this.schedule();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private schedule(): void {
|
|
165
|
+
if (this.drainScheduled) return;
|
|
166
|
+
this.drainScheduled = true;
|
|
167
|
+
this.chain = this.chain.then(() => this.drain());
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private async drain(): Promise<void> {
|
|
171
|
+
this.drainScheduled = false;
|
|
172
|
+
const doClear = this.pendingClear;
|
|
173
|
+
this.pendingClear = false;
|
|
174
|
+
const writes = this.pending;
|
|
175
|
+
this.pending = new Map();
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
if (doClear) await this.persistence.clearAll();
|
|
179
|
+
for (const [key, value] of writes) {
|
|
180
|
+
await this.persistence.persist(key, value);
|
|
181
|
+
}
|
|
182
|
+
} catch (e) {
|
|
183
|
+
this.persistErrors.push(e);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* okint-rn-storage — public types.
|
|
3
|
+
*
|
|
4
|
+
* One async API, several swappable backends. Pick the backend per the data's
|
|
5
|
+
* needs: secrets → `secure` (hardware Keystore / Keychain); large hardware-
|
|
6
|
+
* encrypted blobs → `encrypted`; structured/large data → `sqlite`; plain data →
|
|
7
|
+
* `async`; ephemeral → `memory`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type BackendKind = 'memory' | 'secure' | 'async' | 'encrypted' | 'sqlite';
|
|
11
|
+
|
|
12
|
+
/** The native-backed stores (everything except the pure-JS `memory`). */
|
|
13
|
+
export type NativeStoreKind = 'secure' | 'async' | 'encrypted' | 'sqlite';
|
|
14
|
+
|
|
15
|
+
export interface OkintStorageOptions {
|
|
16
|
+
/** Which storage backend to use. */
|
|
17
|
+
backend: BackendKind;
|
|
18
|
+
/**
|
|
19
|
+
* Logical store name. Partitions data so two stores never collide. Maps to:
|
|
20
|
+
* - secure → Keychain service (iOS) / EncryptedSharedPreferences file (Android)
|
|
21
|
+
* - async → UserDefaults suite (iOS) / SharedPreferences file (Android)
|
|
22
|
+
* - memory → a private in-process Map
|
|
23
|
+
* Defaults to `'okint'`.
|
|
24
|
+
*/
|
|
25
|
+
namespace?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Low-level backend contract. Everything is async (the secure/native backends
|
|
30
|
+
* cross the bridge). Keys reaching a backend are already partitioned by
|
|
31
|
+
* namespace at construction, so backends store keys verbatim.
|
|
32
|
+
*/
|
|
33
|
+
export interface StorageBackend {
|
|
34
|
+
readonly kind: BackendKind;
|
|
35
|
+
getString(key: string): Promise<string | null>;
|
|
36
|
+
setString(key: string, value: string): Promise<void>;
|
|
37
|
+
remove(key: string): Promise<void>;
|
|
38
|
+
clear(): Promise<void>;
|
|
39
|
+
keys(): Promise<string[]>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The storage instance returned by `createStorage`. Adds ergonomic typed
|
|
44
|
+
* accessors (JSON / number / boolean) on top of the raw string backend.
|
|
45
|
+
*/
|
|
46
|
+
export interface OkintStorage {
|
|
47
|
+
/** The backend kind backing this instance. */
|
|
48
|
+
readonly backend: BackendKind;
|
|
49
|
+
|
|
50
|
+
getString(key: string): Promise<string | null>;
|
|
51
|
+
setString(key: string, value: string): Promise<void>;
|
|
52
|
+
|
|
53
|
+
/** JSON-parsed read. Throws OkintStorageError('PARSE_ERROR') on malformed data. */
|
|
54
|
+
getItem<T>(key: string): Promise<T | null>;
|
|
55
|
+
/** JSON-stringified write. */
|
|
56
|
+
setItem<T>(key: string, value: T): Promise<void>;
|
|
57
|
+
|
|
58
|
+
getNumber(key: string): Promise<number | null>;
|
|
59
|
+
setNumber(key: string, value: number): Promise<void>;
|
|
60
|
+
|
|
61
|
+
getBoolean(key: string): Promise<boolean | null>;
|
|
62
|
+
setBoolean(key: string, value: boolean): Promise<void>;
|
|
63
|
+
|
|
64
|
+
has(key: string): Promise<boolean>;
|
|
65
|
+
remove(key: string): Promise<void>;
|
|
66
|
+
clear(): Promise<void>;
|
|
67
|
+
keys(): Promise<string[]>;
|
|
68
|
+
|
|
69
|
+
/** Batched string reads/writes/removes. */
|
|
70
|
+
multiGet(keys: string[]): Promise<Record<string, string | null>>;
|
|
71
|
+
multiSet(entries: Record<string, string>): Promise<void>;
|
|
72
|
+
multiRemove(keys: string[]): Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
76
|
+
// Synchronous stores (the MMKV-style use case)
|
|
77
|
+
//
|
|
78
|
+
// React Native's bridge is async, so true zero-latency sync needs JSI. Instead
|
|
79
|
+
// of reinventing MMKV's C++, the `fast` store loads a snapshot once (async),
|
|
80
|
+
// then serves get/set SYNCHRONOUSLY from memory while persisting writes in the
|
|
81
|
+
// background. Covers the dominant sync need — persist/rehydrate, flags, cached
|
|
82
|
+
// UI state — with no native risk. `memory` is ephemeral sync (no persistence).
|
|
83
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
export type SyncBackendKind = 'memory' | 'fast';
|
|
86
|
+
|
|
87
|
+
export interface OkintSyncStorageOptions {
|
|
88
|
+
backend: SyncBackendKind;
|
|
89
|
+
/** Partitions the store (see OkintStorageOptions.namespace). Defaults to 'okint'. */
|
|
90
|
+
namespace?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A synchronous storage instance. Obtained via `await createSyncStorage(...)`,
|
|
95
|
+
* which resolves only after the initial snapshot has loaded — so every method
|
|
96
|
+
* below is safe to call synchronously thereafter.
|
|
97
|
+
*/
|
|
98
|
+
export interface OkintSyncStorage {
|
|
99
|
+
readonly backend: SyncBackendKind;
|
|
100
|
+
|
|
101
|
+
getString(key: string): string | null;
|
|
102
|
+
setString(key: string, value: string): void;
|
|
103
|
+
|
|
104
|
+
getItem<T>(key: string): T | null;
|
|
105
|
+
setItem<T>(key: string, value: T): void;
|
|
106
|
+
|
|
107
|
+
getNumber(key: string): number | null;
|
|
108
|
+
setNumber(key: string, value: number): void;
|
|
109
|
+
|
|
110
|
+
getBoolean(key: string): boolean | null;
|
|
111
|
+
setBoolean(key: string, value: boolean): void;
|
|
112
|
+
|
|
113
|
+
has(key: string): boolean;
|
|
114
|
+
remove(key: string): void;
|
|
115
|
+
clear(): void;
|
|
116
|
+
keys(): string[];
|
|
117
|
+
|
|
118
|
+
/** Batched (synchronous) reads/writes/removes. */
|
|
119
|
+
multiGet(keys: string[]): Record<string, string | null>;
|
|
120
|
+
multiSet(entries: Record<string, string>): void;
|
|
121
|
+
multiRemove(keys: string[]): void;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Await pending background writes (the `fast` store persists asynchronously).
|
|
125
|
+
* Call on app background / before exit for guaranteed durability. Rejects if a
|
|
126
|
+
* background persist failed.
|
|
127
|
+
*/
|
|
128
|
+
flush(): Promise<void>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Persistence sink for sync stores. Snapshot load + per-key write-through. */
|
|
132
|
+
export interface SyncPersistence {
|
|
133
|
+
loadAll(): Promise<Record<string, string>>;
|
|
134
|
+
/** value === null → delete the key. */
|
|
135
|
+
persist(key: string, value: string | null): Promise<void>;
|
|
136
|
+
clearAll(): Promise<void>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Shape of the native module (Android/iOS). The JS layer talks to this; tests
|
|
141
|
+
* inject a fake implementation. `store` selects which native store to target.
|
|
142
|
+
*/
|
|
143
|
+
export interface NativeOkintStorage {
|
|
144
|
+
setItem(service: string, key: string, value: string, store: NativeStoreKind): Promise<void>;
|
|
145
|
+
getItem(service: string, key: string, store: NativeStoreKind): Promise<string | null>;
|
|
146
|
+
removeItem(service: string, key: string, store: NativeStoreKind): Promise<void>;
|
|
147
|
+
clear(service: string, store: NativeStoreKind): Promise<void>;
|
|
148
|
+
getAllKeys(service: string, store: NativeStoreKind): Promise<string[]>;
|
|
149
|
+
/**
|
|
150
|
+
* Synchronous bulk read (blocking bridge call) used by the zero-load sync
|
|
151
|
+
* store: it hydrates the in-memory snapshot in one shot at construction, so
|
|
152
|
+
* subsequent reads are pure in-JS Map lookups (no per-call bridge crossing).
|
|
153
|
+
*/
|
|
154
|
+
getEntriesSync(service: string, store: NativeStoreKind): Record<string, string>;
|
|
155
|
+
/**
|
|
156
|
+
* Install the C++/JSI fast-path engine into the JS runtime (exposes
|
|
157
|
+
* `global.__okintCreateJSI`). Returns false if the runtime isn't reachable
|
|
158
|
+
* (e.g. remote JS debugging). Blocking-synchronous.
|
|
159
|
+
*/
|
|
160
|
+
installJSI(): boolean;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The C++ JSI HostObject returned by `global.__okintCreateJSI(namespace)`.
|
|
165
|
+
* Every method is synchronous and crosses no bridge — the fastest path.
|
|
166
|
+
*/
|
|
167
|
+
export interface JSIStore {
|
|
168
|
+
getString(key: string): string | null;
|
|
169
|
+
setString(key: string, value: string): void;
|
|
170
|
+
remove(key: string): void;
|
|
171
|
+
clear(): void;
|
|
172
|
+
contains(key: string): boolean;
|
|
173
|
+
getAllKeys(): string[];
|
|
174
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { OkintStorageError } from './errors';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Namespace becomes an Android SharedPreferences FILE NAME and an iOS Keychain
|
|
5
|
+
* service / UserDefaults suite. It must therefore be filename-safe — no path
|
|
6
|
+
* separators, `..`, NUL, or other characters that could traverse or collide.
|
|
7
|
+
* Restrict to a conservative charset.
|
|
8
|
+
*/
|
|
9
|
+
const NAMESPACE_RE = /^[A-Za-z0-9._-]{1,200}$/;
|
|
10
|
+
|
|
11
|
+
const MAX_KEY_LENGTH = 1024;
|
|
12
|
+
|
|
13
|
+
/** Validate + default the namespace. Throws on anything unsafe. */
|
|
14
|
+
export function normalizeNamespace(ns: string | undefined, fallback: string): string {
|
|
15
|
+
const value = (typeof ns === 'string' ? ns.trim() : '') || fallback;
|
|
16
|
+
if (!NAMESPACE_RE.test(value)) {
|
|
17
|
+
throw new OkintStorageError(
|
|
18
|
+
'INVALID_NAMESPACE',
|
|
19
|
+
`Invalid namespace "${String(ns)}". Use only letters, digits, "." "-" "_" (1-200 chars).`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Validate a key. Keys are stored verbatim (not used as filenames), so the rules
|
|
27
|
+
* are looser than namespaces — but must be a non-empty string free of control
|
|
28
|
+
* characters (NUL etc.), within a sane length.
|
|
29
|
+
*/
|
|
30
|
+
export function assertKey(key: string): void {
|
|
31
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
32
|
+
throw new OkintStorageError('INVALID_KEY', 'Key must be a non-empty string.');
|
|
33
|
+
}
|
|
34
|
+
if (key.length > MAX_KEY_LENGTH) {
|
|
35
|
+
throw new OkintStorageError('INVALID_KEY', `Key exceeds the ${MAX_KEY_LENGTH}-character limit.`);
|
|
36
|
+
}
|
|
37
|
+
for (let i = 0; i < key.length; i++) {
|
|
38
|
+
if (key.charCodeAt(i) < 0x20) {
|
|
39
|
+
throw new OkintStorageError('INVALID_KEY', 'Key must not contain control characters.');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Serialize a value to JSON, surfacing non-serializable inputs as a typed error. */
|
|
45
|
+
export function toJson(key: string, value: unknown): string {
|
|
46
|
+
let json: string | undefined;
|
|
47
|
+
try {
|
|
48
|
+
json = JSON.stringify(value);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
throw new OkintStorageError(
|
|
51
|
+
'INVALID_VALUE',
|
|
52
|
+
`Value for "${key}" is not JSON-serializable (circular reference or BigInt).`,
|
|
53
|
+
e,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (json === undefined) {
|
|
57
|
+
throw new OkintStorageError(
|
|
58
|
+
'INVALID_VALUE',
|
|
59
|
+
`Value for "${key}" is not JSON-serializable (undefined, function, or symbol).`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return json;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Parse JSON, surfacing malformed data as a typed error. */
|
|
66
|
+
export function fromJson<T>(key: string, raw: string): T {
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(raw) as T;
|
|
69
|
+
} catch (e) {
|
|
70
|
+
throw new OkintStorageError('PARSE_ERROR', `Stored value for "${key}" is not valid JSON.`, e);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Serialize a number, rejecting NaN/+-Infinity (which don't round-trip). */
|
|
75
|
+
export function numberToString(key: string, value: number): string {
|
|
76
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
77
|
+
throw new OkintStorageError(
|
|
78
|
+
'INVALID_VALUE',
|
|
79
|
+
`setNumber requires a finite number for "${key}" (got ${String(value)}). ` +
|
|
80
|
+
`For integers above 2^53 use setString to avoid precision loss.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return String(value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Parse a stored number; non-numeric -> null. */
|
|
87
|
+
export function stringToNumber(raw: string): number | null {
|
|
88
|
+
const n = Number(raw);
|
|
89
|
+
return Number.isFinite(n) ? n : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Strict boolean parse: only canonical 'true'/'false' map; anything else -> null. */
|
|
93
|
+
export function stringToBoolean(raw: string): boolean | null {
|
|
94
|
+
return raw === 'true' ? true : raw === 'false' ? false : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Assert a raw value is a string (for setString / multiSet). */
|
|
98
|
+
export function assertStringValue(value: unknown): asserts value is string {
|
|
99
|
+
if (typeof value !== 'string') {
|
|
100
|
+
throw new OkintStorageError('INVALID_VALUE', 'Value must be a string.');
|
|
101
|
+
}
|
|
102
|
+
}
|