@rozenite/storage-plugin 1.8.1 → 1.10.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/CHANGELOG.md +34 -0
- package/README.md +23 -0
- package/dist/devtools/assets/panel-Bm-SWF7d.js +33 -0
- package/dist/devtools/assets/panel-DIqI4WSp.css +1 -0
- package/dist/devtools/panel.html +2 -2
- package/dist/react-native/chunks/index.require.cjs +1 -1
- package/dist/react-native/chunks/index.require.js +43 -57
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.cjs +1 -1
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.js +253 -191
- package/dist/react-native/index.d.ts +29 -6
- package/dist/rozenite.json +1 -1
- package/dist/sdk/index.d.ts +1 -0
- package/package.json +9 -6
- package/src/react-native/__tests__/import.test.ts +182 -0
- package/src/react-native/adapters/__tests__/mmkv.test.ts +436 -0
- package/src/react-native/adapters/mmkv.ts +65 -39
- package/src/react-native/import.ts +67 -0
- package/src/react-native/storage-view.ts +16 -8
- package/src/react-native/useRozeniteStoragePlugin.ts +61 -36
- package/src/shared/__tests__/snapshot.test.ts +361 -0
- package/src/shared/messaging.ts +25 -1
- package/src/shared/snapshot.ts +276 -0
- package/src/shared/types.ts +1 -0
- package/src/ui/__tests__/binary-value-editor-state.test.ts +199 -0
- package/src/ui/__tests__/binary.test.ts +251 -0
- package/src/ui/__tests__/type-conversion.test.ts +123 -0
- package/src/ui/add-entry-dialog.tsx +83 -173
- package/src/ui/binary-value-editor-state.ts +125 -0
- package/src/ui/binary-value-editor.tsx +168 -0
- package/src/ui/binary.ts +123 -0
- package/src/ui/edit-entry-dialog.tsx +64 -161
- package/src/ui/editable-table.tsx +12 -10
- package/src/ui/editor-switcher.tsx +62 -0
- package/src/ui/entry-detail-dialog.tsx +14 -6
- package/src/ui/import-dialog.tsx +261 -0
- package/src/ui/panel.tsx +257 -57
- package/src/ui/type-conversion.ts +105 -0
- package/src/ui/typed-value-editor.tsx +96 -0
- package/src/ui/utils.ts +30 -0
- package/dist/devtools/assets/panel-DMhXYHH4.css +0 -1
- package/dist/devtools/assets/panel-eGOuOVos.js +0 -22
- package/src/react-native/is-garbled.ts +0 -17
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createMMKVStorageAdapter } from '../mmkv';
|
|
3
|
+
import { createStorageViews } from '../../storage-view';
|
|
4
|
+
|
|
5
|
+
type StoredValue = string | number | boolean | ArrayBuffer;
|
|
6
|
+
|
|
7
|
+
const createFakeMMKVV3 = (id: string) => {
|
|
8
|
+
const values = new Map<string, StoredValue>();
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
id,
|
|
12
|
+
set: vi.fn((key: string, value: StoredValue) => {
|
|
13
|
+
values.set(key, value);
|
|
14
|
+
}),
|
|
15
|
+
getBoolean: vi.fn((key: string) => {
|
|
16
|
+
const value = values.get(key);
|
|
17
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
18
|
+
}),
|
|
19
|
+
getString: vi.fn((key: string) => {
|
|
20
|
+
const value = values.get(key);
|
|
21
|
+
return typeof value === 'string' ? value : undefined;
|
|
22
|
+
}),
|
|
23
|
+
getNumber: vi.fn((key: string) => {
|
|
24
|
+
const value = values.get(key);
|
|
25
|
+
return typeof value === 'number' ? value : undefined;
|
|
26
|
+
}),
|
|
27
|
+
getBuffer: vi.fn((key: string) => {
|
|
28
|
+
const value = values.get(key);
|
|
29
|
+
return value instanceof ArrayBuffer ? value : undefined;
|
|
30
|
+
}),
|
|
31
|
+
delete: vi.fn((key: string) => {
|
|
32
|
+
values.delete(key);
|
|
33
|
+
}),
|
|
34
|
+
getAllKeys: vi.fn(() => [...values.keys()]),
|
|
35
|
+
addOnValueChangedListener: vi.fn(() => ({ remove: vi.fn() })),
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const createFakeMMKVV4 = () => {
|
|
40
|
+
const values = new Map<string, StoredValue>();
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
set: vi.fn((key: string, value: StoredValue) => {
|
|
44
|
+
values.set(key, value);
|
|
45
|
+
}),
|
|
46
|
+
getBoolean: vi.fn((key: string) => {
|
|
47
|
+
const value = values.get(key);
|
|
48
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
49
|
+
}),
|
|
50
|
+
getString: vi.fn((key: string) => {
|
|
51
|
+
const value = values.get(key);
|
|
52
|
+
return typeof value === 'string' ? value : undefined;
|
|
53
|
+
}),
|
|
54
|
+
getNumber: vi.fn((key: string) => {
|
|
55
|
+
const value = values.get(key);
|
|
56
|
+
return typeof value === 'number' ? value : undefined;
|
|
57
|
+
}),
|
|
58
|
+
getBuffer: vi.fn((key: string) => {
|
|
59
|
+
const value = values.get(key);
|
|
60
|
+
return value instanceof ArrayBuffer ? value : undefined;
|
|
61
|
+
}),
|
|
62
|
+
remove: vi.fn((key: string) => {
|
|
63
|
+
values.delete(key);
|
|
64
|
+
}),
|
|
65
|
+
getAllKeys: vi.fn(() => [...values.keys()]),
|
|
66
|
+
addOnValueChangedListener: vi.fn(() => ({ remove: vi.fn() })),
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
describe('createMMKVStorageAdapter', () => {
|
|
71
|
+
it('supports MMKV v3 storages passed as an array', async () => {
|
|
72
|
+
const storage = createFakeMMKVV3('user-storage');
|
|
73
|
+
const adapter = createMMKVStorageAdapter({ storages: [storage as any] });
|
|
74
|
+
const [view] = createStorageViews([adapter]);
|
|
75
|
+
|
|
76
|
+
await view.set({ key: 'theme', type: 'string', value: 'dark' });
|
|
77
|
+
|
|
78
|
+
expect(view.target).toEqual({
|
|
79
|
+
adapterId: 'mmkv',
|
|
80
|
+
storageId: 'user-storage',
|
|
81
|
+
});
|
|
82
|
+
await expect(view.get('theme')).resolves.toEqual({
|
|
83
|
+
key: 'theme',
|
|
84
|
+
type: 'string',
|
|
85
|
+
value: 'dark',
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('supports MMKV v4 storages passed as a record', async () => {
|
|
90
|
+
const storage = createFakeMMKVV4();
|
|
91
|
+
const adapter = createMMKVStorageAdapter({
|
|
92
|
+
storages: { 'user-storage': storage as any },
|
|
93
|
+
});
|
|
94
|
+
const [view] = createStorageViews([adapter]);
|
|
95
|
+
|
|
96
|
+
await view.set({ key: 'logged-in', type: 'boolean', value: true });
|
|
97
|
+
await expect(view.get('logged-in')).resolves.toEqual({
|
|
98
|
+
key: 'logged-in',
|
|
99
|
+
type: 'boolean',
|
|
100
|
+
value: true,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await view.delete('logged-in');
|
|
104
|
+
|
|
105
|
+
expect(storage.remove).toHaveBeenCalledWith('logged-in');
|
|
106
|
+
await expect(view.get('logged-in')).resolves.toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('rejects MMKV v4 storages passed as an array', () => {
|
|
110
|
+
const storage = createFakeMMKVV4();
|
|
111
|
+
|
|
112
|
+
expect(() =>
|
|
113
|
+
createMMKVStorageAdapter({ storages: [storage as any] }),
|
|
114
|
+
).toThrow(/MMKV arrays are not supported for v4 storages/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('applies global blacklist patterns against storageId:key', async () => {
|
|
118
|
+
const userStorage = createFakeMMKVV3('user-storage');
|
|
119
|
+
const cacheStorage = createFakeMMKVV3('cache-storage');
|
|
120
|
+
|
|
121
|
+
userStorage.set('visible', 'ok');
|
|
122
|
+
userStorage.set('secret', 'hide');
|
|
123
|
+
cacheStorage.set('temp-file', 'hide');
|
|
124
|
+
cacheStorage.set('persisted', 'ok');
|
|
125
|
+
|
|
126
|
+
const adapter = createMMKVStorageAdapter({
|
|
127
|
+
storages: [userStorage as any, cacheStorage as any],
|
|
128
|
+
blacklist: /user-storage:secret|cache-storage:temp.*/,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const views = createStorageViews([adapter]);
|
|
132
|
+
const userView = views.find(
|
|
133
|
+
(view) => view.target.storageId === 'user-storage',
|
|
134
|
+
);
|
|
135
|
+
const cacheView = views.find(
|
|
136
|
+
(view) => view.target.storageId === 'cache-storage',
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(userView).toBeDefined();
|
|
140
|
+
expect(cacheView).toBeDefined();
|
|
141
|
+
|
|
142
|
+
await expect(userView?.getAllKeys()).resolves.toEqual(['visible']);
|
|
143
|
+
await expect(cacheView?.getAllKeys()).resolves.toEqual(['persisted']);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// MMKV in production stores raw bytes per key without a type tag, so
|
|
148
|
+
// getString returns data for any stored value whose bytes decode as
|
|
149
|
+
// valid UTF-8 — including values the app wrote via setBuffer. This
|
|
150
|
+
// fake mirrors that behaviour: getString attempts a strict UTF-8 decode
|
|
151
|
+
// and returns undefined when the bytes are not valid UTF-8, exposing
|
|
152
|
+
// the read priority chain that the adapter relies on.
|
|
153
|
+
//
|
|
154
|
+
// `lenientGetString`, when true, returns `""` instead of `undefined`
|
|
155
|
+
// on a failed UTF-8 decode — matching real MMKV builds on certain
|
|
156
|
+
// platforms (notably iOS NSString conversion). The adapter has to
|
|
157
|
+
// handle that case by falling through to getBuffer.
|
|
158
|
+
//
|
|
159
|
+
// `lenientGetNumber`, when true, reinterprets 8-byte buffer payloads
|
|
160
|
+
// as IEEE 754 doubles (instead of returning undefined). Some MMKV
|
|
161
|
+
// builds expose this behavior — without the buffer-before-number
|
|
162
|
+
// priority, the adapter would misreport such buffers as numbers.
|
|
163
|
+
const createAmbiguousFakeMMKVV4 = ({
|
|
164
|
+
lenientGetString = false,
|
|
165
|
+
lenientGetNumber = false,
|
|
166
|
+
}: { lenientGetString?: boolean; lenientGetNumber?: boolean } = {}) => {
|
|
167
|
+
type Stored =
|
|
168
|
+
| { kind: 'string'; value: string }
|
|
169
|
+
| { kind: 'number'; value: number }
|
|
170
|
+
| { kind: 'boolean'; value: boolean }
|
|
171
|
+
| { kind: 'buffer'; bytes: Uint8Array };
|
|
172
|
+
|
|
173
|
+
const values = new Map<string, Stored>();
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
set: vi.fn(
|
|
177
|
+
(key: string, value: string | number | boolean | ArrayBuffer) => {
|
|
178
|
+
if (typeof value === 'string') {
|
|
179
|
+
values.set(key, { kind: 'string', value });
|
|
180
|
+
} else if (typeof value === 'number') {
|
|
181
|
+
values.set(key, { kind: 'number', value });
|
|
182
|
+
} else if (typeof value === 'boolean') {
|
|
183
|
+
values.set(key, { kind: 'boolean', value });
|
|
184
|
+
} else {
|
|
185
|
+
values.set(key, {
|
|
186
|
+
kind: 'buffer',
|
|
187
|
+
bytes: new Uint8Array(value),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
),
|
|
192
|
+
getString: vi.fn((key: string) => {
|
|
193
|
+
const stored = values.get(key);
|
|
194
|
+
if (!stored) return undefined;
|
|
195
|
+
if (stored.kind === 'string') return stored.value;
|
|
196
|
+
if (stored.kind === 'buffer') {
|
|
197
|
+
try {
|
|
198
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(stored.bytes);
|
|
199
|
+
} catch {
|
|
200
|
+
return lenientGetString ? '' : undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return undefined;
|
|
204
|
+
}),
|
|
205
|
+
getNumber: vi.fn((key: string) => {
|
|
206
|
+
const stored = values.get(key);
|
|
207
|
+
if (stored?.kind === 'number') return stored.value;
|
|
208
|
+
if (
|
|
209
|
+
lenientGetNumber &&
|
|
210
|
+
stored?.kind === 'buffer' &&
|
|
211
|
+
stored.bytes.byteLength === 8
|
|
212
|
+
) {
|
|
213
|
+
return new DataView(stored.bytes.buffer).getFloat64(0, true);
|
|
214
|
+
}
|
|
215
|
+
return undefined;
|
|
216
|
+
}),
|
|
217
|
+
getBoolean: vi.fn((key: string) => {
|
|
218
|
+
const stored = values.get(key);
|
|
219
|
+
return stored?.kind === 'boolean' ? stored.value : undefined;
|
|
220
|
+
}),
|
|
221
|
+
getBuffer: vi.fn((key: string) => {
|
|
222
|
+
const stored = values.get(key);
|
|
223
|
+
return stored?.kind === 'buffer' ? stored.bytes.buffer : undefined;
|
|
224
|
+
}),
|
|
225
|
+
remove: vi.fn((key: string) => {
|
|
226
|
+
values.delete(key);
|
|
227
|
+
}),
|
|
228
|
+
getAllKeys: vi.fn(() => [...values.keys()]),
|
|
229
|
+
addOnValueChangedListener: vi.fn(() => ({ remove: vi.fn() })),
|
|
230
|
+
};
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
describe('createMMKVStorageAdapter read priority', () => {
|
|
234
|
+
it('classifies a buffer of valid UTF-8 bytes as a string (documented limitation)', async () => {
|
|
235
|
+
const storage = createAmbiguousFakeMMKVV4();
|
|
236
|
+
const adapter = createMMKVStorageAdapter({
|
|
237
|
+
storages: { 'user-storage': storage as any },
|
|
238
|
+
});
|
|
239
|
+
const [view] = createStorageViews([adapter]);
|
|
240
|
+
|
|
241
|
+
// 0x68 0x65 0x6c 0x6c 0x6f = "hello" in ASCII. MMKV's getString
|
|
242
|
+
// decodes the bytes as UTF-8 and we trust that report — users who
|
|
243
|
+
// know the value is binary disambiguate at edit time via the Hex
|
|
244
|
+
// editor.
|
|
245
|
+
await view.set({
|
|
246
|
+
key: 'token',
|
|
247
|
+
type: 'buffer',
|
|
248
|
+
value: [0x68, 0x65, 0x6c, 0x6c, 0x6f],
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
await expect(view.get('token')).resolves.toEqual({
|
|
252
|
+
key: 'token',
|
|
253
|
+
type: 'string',
|
|
254
|
+
value: 'hello',
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('classifies a buffer of non-UTF-8 bytes as a buffer', async () => {
|
|
259
|
+
const storage = createAmbiguousFakeMMKVV4();
|
|
260
|
+
const adapter = createMMKVStorageAdapter({
|
|
261
|
+
storages: { 'user-storage': storage as any },
|
|
262
|
+
});
|
|
263
|
+
const [view] = createStorageViews([adapter]);
|
|
264
|
+
|
|
265
|
+
// 0xFF 0xFE is invalid UTF-8 — getString returns undefined, the
|
|
266
|
+
// chain falls through to getBuffer.
|
|
267
|
+
await view.set({
|
|
268
|
+
key: 'bin',
|
|
269
|
+
type: 'buffer',
|
|
270
|
+
value: [0xff, 0xfe, 0xfd],
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
await expect(view.get('bin')).resolves.toEqual({
|
|
274
|
+
key: 'bin',
|
|
275
|
+
type: 'buffer',
|
|
276
|
+
value: [0xff, 0xfe, 0xfd],
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('overwriting a buffer with a string returns the string on the next read', async () => {
|
|
281
|
+
const storage = createAmbiguousFakeMMKVV4();
|
|
282
|
+
const adapter = createMMKVStorageAdapter({
|
|
283
|
+
storages: { 'user-storage': storage as any },
|
|
284
|
+
});
|
|
285
|
+
const [view] = createStorageViews([adapter]);
|
|
286
|
+
|
|
287
|
+
await view.set({
|
|
288
|
+
key: 'flip',
|
|
289
|
+
type: 'buffer',
|
|
290
|
+
value: [0xff, 0xfe],
|
|
291
|
+
});
|
|
292
|
+
await view.set({
|
|
293
|
+
key: 'flip',
|
|
294
|
+
type: 'string',
|
|
295
|
+
value: 'world',
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
await expect(view.get('flip')).resolves.toEqual({
|
|
299
|
+
key: 'flip',
|
|
300
|
+
type: 'string',
|
|
301
|
+
value: 'world',
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('returns numbers via getNumber when no string is present at the key', async () => {
|
|
306
|
+
const storage = createAmbiguousFakeMMKVV4();
|
|
307
|
+
const adapter = createMMKVStorageAdapter({
|
|
308
|
+
storages: { 'user-storage': storage as any },
|
|
309
|
+
});
|
|
310
|
+
const [view] = createStorageViews([adapter]);
|
|
311
|
+
|
|
312
|
+
await view.set({ key: 'count', type: 'number', value: 42 });
|
|
313
|
+
|
|
314
|
+
await expect(view.get('count')).resolves.toEqual({
|
|
315
|
+
key: 'count',
|
|
316
|
+
type: 'number',
|
|
317
|
+
value: 42,
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('returns booleans via getBoolean when no string or number is present at the key', async () => {
|
|
322
|
+
const storage = createAmbiguousFakeMMKVV4();
|
|
323
|
+
const adapter = createMMKVStorageAdapter({
|
|
324
|
+
storages: { 'user-storage': storage as any },
|
|
325
|
+
});
|
|
326
|
+
const [view] = createStorageViews([adapter]);
|
|
327
|
+
|
|
328
|
+
await view.set({ key: 'flag', type: 'boolean', value: true });
|
|
329
|
+
|
|
330
|
+
await expect(view.get('flag')).resolves.toEqual({
|
|
331
|
+
key: 'flag',
|
|
332
|
+
type: 'boolean',
|
|
333
|
+
value: true,
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('deleting a key removes it from the storage view', async () => {
|
|
338
|
+
const storage = createAmbiguousFakeMMKVV4();
|
|
339
|
+
const adapter = createMMKVStorageAdapter({
|
|
340
|
+
storages: { 'user-storage': storage as any },
|
|
341
|
+
});
|
|
342
|
+
const [view] = createStorageViews([adapter]);
|
|
343
|
+
|
|
344
|
+
await view.set({ key: 'temp', type: 'string', value: 'hi' });
|
|
345
|
+
await view.delete('temp');
|
|
346
|
+
|
|
347
|
+
await expect(view.get('temp')).resolves.toBeUndefined();
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe('createMMKVStorageAdapter read priority — lenient getString platforms', () => {
|
|
352
|
+
// Real MMKV on some platforms returns `""` for invalid UTF-8 bytes
|
|
353
|
+
// rather than `undefined`. Without the empty-string carve-out in the
|
|
354
|
+
// priority chain, the adapter would misreport such buffers as empty
|
|
355
|
+
// strings and the bytes would appear lost from the panel's POV.
|
|
356
|
+
it('falls through to getBuffer when getString returns an empty string', async () => {
|
|
357
|
+
const storage = createAmbiguousFakeMMKVV4({ lenientGetString: true });
|
|
358
|
+
const adapter = createMMKVStorageAdapter({
|
|
359
|
+
storages: { 'user-storage': storage as any },
|
|
360
|
+
});
|
|
361
|
+
const [view] = createStorageViews([adapter]);
|
|
362
|
+
|
|
363
|
+
// PNG magic bytes — first byte (0x89) is an invalid UTF-8 lead.
|
|
364
|
+
await view.set({
|
|
365
|
+
key: 'png-magic',
|
|
366
|
+
type: 'buffer',
|
|
367
|
+
value: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
await expect(view.get('png-magic')).resolves.toEqual({
|
|
371
|
+
key: 'png-magic',
|
|
372
|
+
type: 'buffer',
|
|
373
|
+
value: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('preserves an intentional empty string when no buffer is present', async () => {
|
|
378
|
+
const storage = createAmbiguousFakeMMKVV4({ lenientGetString: true });
|
|
379
|
+
const adapter = createMMKVStorageAdapter({
|
|
380
|
+
storages: { 'user-storage': storage as any },
|
|
381
|
+
});
|
|
382
|
+
const [view] = createStorageViews([adapter]);
|
|
383
|
+
|
|
384
|
+
await view.set({ key: 'blank', type: 'string', value: '' });
|
|
385
|
+
|
|
386
|
+
await expect(view.get('blank')).resolves.toEqual({
|
|
387
|
+
key: 'blank',
|
|
388
|
+
type: 'string',
|
|
389
|
+
value: '',
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('keeps an 8-byte buffer as buffer when getNumber lenient-decodes it as a double', async () => {
|
|
394
|
+
// Some MMKV builds reinterpret 8-byte setBuffer payloads as IEEE
|
|
395
|
+
// 754 doubles via getNumber. The priority chain has to consult
|
|
396
|
+
// getBuffer before getNumber so the bytes that are actually on
|
|
397
|
+
// disk win — otherwise PNG-magic-shaped buffers would be reported
|
|
398
|
+
// as some arbitrary number.
|
|
399
|
+
const storage = createAmbiguousFakeMMKVV4({
|
|
400
|
+
lenientGetString: true,
|
|
401
|
+
lenientGetNumber: true,
|
|
402
|
+
});
|
|
403
|
+
const adapter = createMMKVStorageAdapter({
|
|
404
|
+
storages: { 'user-storage': storage as any },
|
|
405
|
+
});
|
|
406
|
+
const [view] = createStorageViews([adapter]);
|
|
407
|
+
|
|
408
|
+
const pngMagic = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
409
|
+
await view.set({ key: 'png-magic', type: 'buffer', value: pngMagic });
|
|
410
|
+
|
|
411
|
+
await expect(view.get('png-magic')).resolves.toEqual({
|
|
412
|
+
key: 'png-magic',
|
|
413
|
+
type: 'buffer',
|
|
414
|
+
value: pngMagic,
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('reports an empty buffer as buffer (matching its setBuffer write)', async () => {
|
|
419
|
+
const storage = createAmbiguousFakeMMKVV4({ lenientGetString: true });
|
|
420
|
+
const adapter = createMMKVStorageAdapter({
|
|
421
|
+
storages: { 'user-storage': storage as any },
|
|
422
|
+
});
|
|
423
|
+
const [view] = createStorageViews([adapter]);
|
|
424
|
+
|
|
425
|
+
// 0xFF is invalid UTF-8 — getString returns "" under lenient decode.
|
|
426
|
+
// The bytes are still present via getBuffer; we want the buffer
|
|
427
|
+
// reading to win since byteLength > 0.
|
|
428
|
+
await view.set({ key: 'invalid', type: 'buffer', value: [0xff] });
|
|
429
|
+
|
|
430
|
+
await expect(view.get('invalid')).resolves.toEqual({
|
|
431
|
+
key: 'invalid',
|
|
432
|
+
type: 'buffer',
|
|
433
|
+
value: [0xff],
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
});
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { MMKV as MMKVV3 } from 'react-native-mmkv-v3';
|
|
2
2
|
import type { MMKV as MMKVV4 } from 'react-native-mmkv-v4';
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
StorageAdapter,
|
|
5
|
+
StorageEntry,
|
|
6
|
+
StorageNode,
|
|
7
|
+
} from '../../shared/types';
|
|
4
8
|
import { DEFAULT_SUPPORTED_TYPES } from '../../shared/types';
|
|
5
|
-
import { looksLikeGarbled } from '../is-garbled';
|
|
6
9
|
|
|
7
10
|
type MMKV = MMKVV3 | MMKVV4;
|
|
8
11
|
|
|
@@ -36,12 +39,12 @@ const normalizeStorages = (storages: MMKV[] | Record<string, MMKV>) => {
|
|
|
36
39
|
|
|
37
40
|
if (isAnyStorageV4) {
|
|
38
41
|
throw new Error(
|
|
39
|
-
'[Rozenite] Storage Plugin: MMKV arrays are not supported for v4 storages. Pass a record of storage IDs and MMKV instances.'
|
|
42
|
+
'[Rozenite] Storage Plugin: MMKV arrays are not supported for v4 storages. Pass a record of storage IDs and MMKV instances.',
|
|
40
43
|
);
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
return Object.fromEntries(
|
|
44
|
-
(storages as MMKVV3[]).map((storage) => [storage['id'], storage])
|
|
47
|
+
(storages as MMKVV3[]).map((storage) => [storage['id'], storage]),
|
|
45
48
|
);
|
|
46
49
|
}
|
|
47
50
|
|
|
@@ -71,54 +74,60 @@ const getMMKVAdapter = (mmkv: MMKV): MMKVAdapter => {
|
|
|
71
74
|
getBuffer: (key) => mmkv.getBuffer(key) as ArrayBuffer | undefined,
|
|
72
75
|
delete: (key) => mmkv.delete(key),
|
|
73
76
|
getAllKeys: () => mmkv.getAllKeys(),
|
|
74
|
-
addOnValueChangedListener: (callback) =>
|
|
77
|
+
addOnValueChangedListener: (callback) =>
|
|
78
|
+
mmkv.addOnValueChangedListener(callback),
|
|
75
79
|
};
|
|
76
80
|
};
|
|
77
81
|
|
|
78
|
-
|
|
82
|
+
// MMKV's typed getters can disagree on the same key — on some platforms
|
|
83
|
+
// `getString` lenient-decodes invalid-UTF-8 buffer bytes to `""`, and
|
|
84
|
+
// `getNumber` reinterprets 8-byte buffers as IEEE 754 doubles without
|
|
85
|
+
// honoring the original `setBuffer` write. `getBuffer` is more reliably
|
|
86
|
+
// strict (only returns for keys actually written via `setBuffer`), so
|
|
87
|
+
// we consult it ahead of the numeric getters to break ties in favour of
|
|
88
|
+
// the bytes that are actually on disk.
|
|
89
|
+
//
|
|
90
|
+
// 1. Non-empty string — the common case. Buffer bytes that happen to
|
|
91
|
+
// be valid UTF-8 surface here too (documented trade-off); users
|
|
92
|
+
// disambiguate at edit time via the Hex editor.
|
|
93
|
+
// 2. Non-empty buffer — catches `setBuffer` payloads regardless of
|
|
94
|
+
// byte count and regardless of whether the other typed getters
|
|
95
|
+
// also return spurious values.
|
|
96
|
+
// 3. Number, then boolean — for keys actually written via `setNumber`
|
|
97
|
+
// / `setBoolean`. `getBuffer` will have returned `undefined` for
|
|
98
|
+
// those keys, so the chain falls through.
|
|
99
|
+
// 4. Empty string — intentional `setString(key, "")` lands here once
|
|
100
|
+
// we've ruled out a non-empty buffer payload at the same key.
|
|
101
|
+
const getEntry = (
|
|
102
|
+
adapter: MMKVAdapter,
|
|
103
|
+
key: string,
|
|
104
|
+
): StorageEntry | undefined => {
|
|
79
105
|
const stringValue = adapter.getString(key);
|
|
80
|
-
|
|
81
106
|
if (stringValue !== undefined && stringValue.length > 0) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
key,
|
|
85
|
-
type: 'buffer',
|
|
86
|
-
value: Array.from(new TextEncoder().encode(stringValue)),
|
|
87
|
-
};
|
|
88
|
-
}
|
|
107
|
+
return { key, type: 'string', value: stringValue };
|
|
108
|
+
}
|
|
89
109
|
|
|
110
|
+
const bufferValue = adapter.getBuffer(key);
|
|
111
|
+
if (bufferValue !== undefined && bufferValue.byteLength > 0) {
|
|
90
112
|
return {
|
|
91
113
|
key,
|
|
92
|
-
type: '
|
|
93
|
-
value:
|
|
114
|
+
type: 'buffer',
|
|
115
|
+
value: Array.from(new Uint8Array(bufferValue)),
|
|
94
116
|
};
|
|
95
117
|
}
|
|
96
118
|
|
|
97
119
|
const numberValue = adapter.getNumber(key);
|
|
98
120
|
if (numberValue !== undefined) {
|
|
99
|
-
return {
|
|
100
|
-
key,
|
|
101
|
-
type: 'number',
|
|
102
|
-
value: numberValue,
|
|
103
|
-
};
|
|
121
|
+
return { key, type: 'number', value: numberValue };
|
|
104
122
|
}
|
|
105
123
|
|
|
106
124
|
const booleanValue = adapter.getBoolean(key);
|
|
107
125
|
if (booleanValue !== undefined) {
|
|
108
|
-
return {
|
|
109
|
-
key,
|
|
110
|
-
type: 'boolean',
|
|
111
|
-
value: booleanValue,
|
|
112
|
-
};
|
|
126
|
+
return { key, type: 'boolean', value: booleanValue };
|
|
113
127
|
}
|
|
114
128
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
return {
|
|
118
|
-
key,
|
|
119
|
-
type: 'buffer',
|
|
120
|
-
value: Array.from(new Uint8Array(bufferValue)),
|
|
121
|
-
};
|
|
129
|
+
if (stringValue !== undefined) {
|
|
130
|
+
return { key, type: 'string', value: stringValue };
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
return undefined;
|
|
@@ -127,15 +136,14 @@ const getEntry = (adapter: MMKVAdapter, key: string): StorageEntry | undefined =
|
|
|
127
136
|
const setEntry = (adapter: MMKVAdapter, entry: StorageEntry) => {
|
|
128
137
|
if (entry.type === 'buffer') {
|
|
129
138
|
adapter.set(entry.key, new Uint8Array(entry.value).buffer);
|
|
130
|
-
|
|
139
|
+
} else {
|
|
140
|
+
adapter.set(entry.key, entry.value);
|
|
131
141
|
}
|
|
132
|
-
|
|
133
|
-
adapter.set(entry.key, entry.value);
|
|
134
142
|
};
|
|
135
143
|
|
|
136
144
|
const getStorageBlacklist = (
|
|
137
145
|
config: MMKVBlacklistConfig | undefined,
|
|
138
|
-
storageId: string
|
|
146
|
+
storageId: string,
|
|
139
147
|
) => {
|
|
140
148
|
if (!config) {
|
|
141
149
|
return undefined;
|
|
@@ -148,13 +156,30 @@ const getStorageBlacklist = (
|
|
|
148
156
|
return config[storageId];
|
|
149
157
|
};
|
|
150
158
|
|
|
159
|
+
const createStorageBlacklistMatcher = (
|
|
160
|
+
config: MMKVBlacklistConfig | undefined,
|
|
161
|
+
storageId: string,
|
|
162
|
+
) => {
|
|
163
|
+
if (!(config instanceof RegExp)) {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return (key: string) => {
|
|
168
|
+
config.lastIndex = 0;
|
|
169
|
+
return config.test(`${storageId}:${key}`);
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
|
|
151
173
|
export const createMMKVStorageAdapter = ({
|
|
152
174
|
adapterId = 'mmkv',
|
|
153
175
|
adapterName = 'MMKV',
|
|
154
176
|
storages,
|
|
155
177
|
blacklist,
|
|
156
178
|
}: CreateMMKVStorageAdapterOptions): StorageAdapter => {
|
|
157
|
-
const normalizedStorages = normalizeStorages(storages) as Record<
|
|
179
|
+
const normalizedStorages = normalizeStorages(storages) as Record<
|
|
180
|
+
string,
|
|
181
|
+
MMKV
|
|
182
|
+
>;
|
|
158
183
|
|
|
159
184
|
const storageNodes: StorageNode[] = Object.entries(normalizedStorages).map(
|
|
160
185
|
([storageId, storage]) => {
|
|
@@ -164,6 +189,7 @@ export const createMMKVStorageAdapter = ({
|
|
|
164
189
|
id: storageId,
|
|
165
190
|
name: storageId,
|
|
166
191
|
blacklist: getStorageBlacklist(blacklist, storageId),
|
|
192
|
+
shouldFilterKey: createStorageBlacklistMatcher(blacklist, storageId),
|
|
167
193
|
capabilities: {
|
|
168
194
|
supportedTypes: DEFAULT_SUPPORTED_TYPES,
|
|
169
195
|
},
|
|
@@ -176,7 +202,7 @@ export const createMMKVStorageAdapter = ({
|
|
|
176
202
|
subscribe: (callback) => mmkv.addOnValueChangedListener(callback),
|
|
177
203
|
},
|
|
178
204
|
};
|
|
179
|
-
}
|
|
205
|
+
},
|
|
180
206
|
);
|
|
181
207
|
|
|
182
208
|
return {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
StorageImportEntriesEvent,
|
|
3
|
+
StorageImportResultEvent,
|
|
4
|
+
StorageSetEntryEvent,
|
|
5
|
+
} from '../shared/messaging';
|
|
6
|
+
import type { StorageView } from './storage-view';
|
|
7
|
+
|
|
8
|
+
export type ImportEmittedEvent =
|
|
9
|
+
| StorageSetEntryEvent
|
|
10
|
+
| StorageImportResultEvent;
|
|
11
|
+
|
|
12
|
+
export const handleImportEntries = async (
|
|
13
|
+
views: StorageView[],
|
|
14
|
+
event: StorageImportEntriesEvent,
|
|
15
|
+
emit: (out: ImportEmittedEvent) => void,
|
|
16
|
+
): Promise<void> => {
|
|
17
|
+
const view = views.find(
|
|
18
|
+
(candidate) =>
|
|
19
|
+
candidate.target.adapterId === event.target.adapterId &&
|
|
20
|
+
candidate.target.storageId === event.target.storageId,
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
if (!view) {
|
|
24
|
+
emit({
|
|
25
|
+
type: 'import-result',
|
|
26
|
+
target: event.target,
|
|
27
|
+
ok: false,
|
|
28
|
+
written: 0,
|
|
29
|
+
total: event.entries.length,
|
|
30
|
+
error: 'Target storage not found',
|
|
31
|
+
});
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (let i = 0; i < event.entries.length; i++) {
|
|
36
|
+
const entry = event.entries[i];
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
await view.set(entry);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
emit({
|
|
42
|
+
type: 'import-result',
|
|
43
|
+
target: event.target,
|
|
44
|
+
ok: false,
|
|
45
|
+
written: i,
|
|
46
|
+
total: event.entries.length,
|
|
47
|
+
failedKey: entry.key,
|
|
48
|
+
error: error instanceof Error ? error.message : String(error),
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
emit({
|
|
54
|
+
type: 'set-entry',
|
|
55
|
+
target: event.target,
|
|
56
|
+
entry,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
emit({
|
|
61
|
+
type: 'import-result',
|
|
62
|
+
target: event.target,
|
|
63
|
+
ok: true,
|
|
64
|
+
written: event.entries.length,
|
|
65
|
+
total: event.entries.length,
|
|
66
|
+
});
|
|
67
|
+
};
|