@rozenite/storage-plugin 1.13.0 → 2.0.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 +28 -0
- package/README.md +6 -1
- package/dist/devtools/assets/panel-B6Wp0eie.css +1 -0
- package/dist/devtools/assets/panel-D7MFc4HG.js +32 -0
- package/dist/devtools/panel.html +2 -2
- package/dist/react-native/chunks/async-storage.require.cjs +1 -1
- package/dist/react-native/chunks/async-storage.require.js +8 -7
- package/dist/react-native/chunks/index.require.cjs +1 -1
- package/dist/react-native/chunks/index.require.js +70 -64
- package/dist/react-native/chunks/secure-storage.require.cjs +1 -1
- package/dist/react-native/chunks/secure-storage.require.js +22 -14
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.cjs +1 -1
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.js +863 -315
- package/dist/react-native/index.d.ts +154 -18
- package/dist/react-native/index.js +10 -2
- package/dist/rozenite.json +1 -1
- package/dist/sdk/index.cjs +1 -1
- package/dist/sdk/index.d.ts +13 -5
- package/dist/sdk/index.js +21 -12
- package/package.json +13 -15
- package/react-native.ts +29 -14
- package/rozenite.config.ts +78 -0
- package/src/react-native/__tests__/entry-preview-pagination.test.ts +262 -0
- package/src/react-native/__tests__/export-snapshot.test.ts +142 -0
- package/src/react-native/__tests__/full-entry-request.test.ts +155 -0
- package/src/react-native/__tests__/import.test.ts +172 -10
- package/src/react-native/__tests__/storage-discovery.test.ts +155 -0
- package/src/react-native/__tests__/storage-view.test.ts +172 -0
- package/src/react-native/__tests__/stress-verification.test.ts +153 -0
- package/src/react-native/__tests__/use-storage-agent-tools.test.ts +231 -0
- package/src/react-native/adapters/async-storage.ts +9 -7
- package/src/react-native/adapters/mmkv.ts +5 -0
- package/src/react-native/adapters/secure-storage.ts +9 -1
- package/src/react-native/entry-preview-pagination.ts +277 -0
- package/src/react-native/export-snapshot.ts +94 -0
- package/src/react-native/full-entry-request.ts +96 -0
- package/src/react-native/import.ts +190 -16
- package/src/react-native/storage-discovery.ts +96 -0
- package/src/react-native/storage-view.ts +45 -103
- package/src/react-native/useRozeniteStoragePlugin.ts +108 -72
- package/src/react-native/useStorageAgentTools.ts +207 -86
- package/src/shared/__tests__/entry-preview.test.ts +117 -0
- package/src/shared/__tests__/snapshot.test.ts +21 -0
- package/src/shared/agent-tools.ts +27 -11
- package/src/shared/entry-preview.ts +59 -0
- package/src/shared/messaging.ts +136 -21
- package/src/shared/snapshot.ts +8 -1
- package/src/shared/types.ts +20 -1
- package/src/ui/__tests__/binary-value-editor-state.test.ts +13 -0
- package/src/ui/__tests__/binary.test.ts +18 -0
- package/src/ui/__tests__/large-value-viewer-state.test.ts +21 -0
- package/src/ui/__tests__/large-value-viewer.test.tsx +63 -0
- package/src/ui/__tests__/panel.test.tsx +409 -0
- package/src/ui/__tests__/storage-groups.test.ts +91 -0
- package/src/ui/__tests__/use-storage-entries.test.tsx +353 -0
- package/src/ui/add-entry-dialog.tsx +65 -105
- package/src/ui/binary-value-editor-state.ts +10 -1
- package/src/ui/binary-value-editor.tsx +57 -33
- package/src/ui/binary.ts +56 -4
- package/src/ui/edit-entry-dialog.tsx +71 -99
- package/src/ui/editor-switcher.tsx +7 -5
- package/src/ui/entry-detail-dialog.tsx +69 -179
- package/src/ui/format-value.tsx +32 -0
- package/src/ui/globals.css +1 -123
- package/src/ui/import-dialog.tsx +66 -91
- package/src/ui/large-value-viewer-state.ts +32 -0
- package/src/ui/large-value-viewer.tsx +71 -0
- package/src/ui/panel.tsx +724 -496
- package/src/ui/query-client.tsx +34 -0
- package/src/ui/storage-groups.ts +66 -0
- package/src/ui/typed-value-editor.tsx +14 -15
- package/src/ui/use-storage-entries.ts +207 -0
- package/tsconfig.json +5 -1
- package/dist/devtools/assets/panel-Bm-SWF7d.js +0 -33
- package/dist/devtools/assets/panel-DIqI4WSp.css +0 -1
- package/postcss.config.js +0 -6
- package/src/ui/confirm-dialog.tsx +0 -100
- package/src/ui/editable-table.tsx +0 -300
- package/tailwind.config.ts +0 -94
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
handleImportEntries,
|
|
4
|
+
handleImportPreviewRequest,
|
|
5
|
+
type ImportEmittedEvent,
|
|
6
|
+
} from '../import';
|
|
3
7
|
import type { StorageView } from '../storage-view';
|
|
4
8
|
import { getStorageViewId } from '../../shared/types';
|
|
5
9
|
import type { StorageEntry, StorageTarget } from '../../shared/types';
|
|
@@ -24,10 +28,12 @@ const buildView = (overrides?: Partial<StorageView>): StorageView => {
|
|
|
24
28
|
get: unexpected('get'),
|
|
25
29
|
set: vi.fn(async () => {}),
|
|
26
30
|
delete: unexpected('delete'),
|
|
31
|
+
purge: unexpected('purge'),
|
|
27
32
|
getAllKeys: unexpected('getAllKeys'),
|
|
28
33
|
getAllEntries: unexpected('getAllEntries'),
|
|
29
34
|
watch: unexpected('watch'),
|
|
30
35
|
...overrides,
|
|
36
|
+
supportsSubscriptions: overrides?.supportsSubscriptions ?? false,
|
|
31
37
|
};
|
|
32
38
|
};
|
|
33
39
|
|
|
@@ -39,11 +45,12 @@ const buildEntries = (count: number): StorageEntry[] =>
|
|
|
39
45
|
}));
|
|
40
46
|
|
|
41
47
|
describe('handleImportEntries', () => {
|
|
42
|
-
it('emits
|
|
48
|
+
it('emits value-free progress, one invalidation, and one success import-result', async () => {
|
|
43
49
|
const view = buildView();
|
|
44
50
|
const entries = buildEntries(5);
|
|
45
51
|
const event: StorageImportEntriesEvent = {
|
|
46
52
|
type: 'import-entries',
|
|
53
|
+
requestId: 'request-1',
|
|
47
54
|
target,
|
|
48
55
|
entries,
|
|
49
56
|
};
|
|
@@ -53,16 +60,24 @@ describe('handleImportEntries', () => {
|
|
|
53
60
|
|
|
54
61
|
expect(view.set).toHaveBeenCalledTimes(5);
|
|
55
62
|
|
|
56
|
-
expect(emitted).toHaveLength(
|
|
63
|
+
expect(emitted).toHaveLength(7);
|
|
57
64
|
expect(emitted.slice(0, 5)).toEqual(
|
|
58
|
-
entries.map((
|
|
59
|
-
type: '
|
|
65
|
+
entries.map((_, index) => ({
|
|
66
|
+
type: 'import-progress',
|
|
67
|
+
requestId: 'request-1',
|
|
60
68
|
target,
|
|
61
|
-
|
|
69
|
+
written: index + 1,
|
|
70
|
+
total: 5,
|
|
62
71
|
})),
|
|
63
72
|
);
|
|
64
73
|
expect(emitted[5]).toEqual({
|
|
74
|
+
type: 'storage-invalidated',
|
|
75
|
+
target,
|
|
76
|
+
operation: 'import',
|
|
77
|
+
});
|
|
78
|
+
expect(emitted[6]).toEqual({
|
|
65
79
|
type: 'import-result',
|
|
80
|
+
requestId: 'request-1',
|
|
66
81
|
target,
|
|
67
82
|
ok: true,
|
|
68
83
|
written: 5,
|
|
@@ -82,6 +97,7 @@ describe('handleImportEntries', () => {
|
|
|
82
97
|
const entries = buildEntries(5);
|
|
83
98
|
const event: StorageImportEntriesEvent = {
|
|
84
99
|
type: 'import-entries',
|
|
100
|
+
requestId: 'request-1',
|
|
85
101
|
target,
|
|
86
102
|
entries,
|
|
87
103
|
};
|
|
@@ -89,13 +105,27 @@ describe('handleImportEntries', () => {
|
|
|
89
105
|
|
|
90
106
|
await handleImportEntries([view], event, (out) => emitted.push(out));
|
|
91
107
|
|
|
92
|
-
// 2
|
|
108
|
+
// 2 value-free progress events (k0, k1), one batch invalidation, then failure.
|
|
93
109
|
expect(view.set).toHaveBeenCalledTimes(3);
|
|
94
|
-
expect(emitted).toHaveLength(
|
|
95
|
-
expect(emitted[0]).toMatchObject({
|
|
96
|
-
|
|
110
|
+
expect(emitted).toHaveLength(4);
|
|
111
|
+
expect(emitted[0]).toMatchObject({
|
|
112
|
+
type: 'import-progress',
|
|
113
|
+
requestId: 'request-1',
|
|
114
|
+
written: 1,
|
|
115
|
+
});
|
|
116
|
+
expect(emitted[1]).toMatchObject({
|
|
117
|
+
type: 'import-progress',
|
|
118
|
+
requestId: 'request-1',
|
|
119
|
+
written: 2,
|
|
120
|
+
});
|
|
97
121
|
expect(emitted[2]).toEqual({
|
|
122
|
+
type: 'storage-invalidated',
|
|
123
|
+
target,
|
|
124
|
+
operation: 'import',
|
|
125
|
+
});
|
|
126
|
+
expect(emitted[3]).toEqual({
|
|
98
127
|
type: 'import-result',
|
|
128
|
+
requestId: 'request-1',
|
|
99
129
|
target,
|
|
100
130
|
ok: false,
|
|
101
131
|
written: 2,
|
|
@@ -113,6 +143,7 @@ describe('handleImportEntries', () => {
|
|
|
113
143
|
});
|
|
114
144
|
const event: StorageImportEntriesEvent = {
|
|
115
145
|
type: 'import-entries',
|
|
146
|
+
requestId: 'request-1',
|
|
116
147
|
target,
|
|
117
148
|
entries: buildEntries(1),
|
|
118
149
|
};
|
|
@@ -123,6 +154,7 @@ describe('handleImportEntries', () => {
|
|
|
123
154
|
expect(emitted).toHaveLength(1);
|
|
124
155
|
expect(emitted[0]).toMatchObject({
|
|
125
156
|
type: 'import-result',
|
|
157
|
+
requestId: 'request-1',
|
|
126
158
|
ok: false,
|
|
127
159
|
error: 'plain string failure',
|
|
128
160
|
});
|
|
@@ -137,6 +169,7 @@ describe('handleImportEntries', () => {
|
|
|
137
169
|
const entries = buildEntries(3);
|
|
138
170
|
const event: StorageImportEntriesEvent = {
|
|
139
171
|
type: 'import-entries',
|
|
172
|
+
requestId: 'request-1',
|
|
140
173
|
target: unknownTarget,
|
|
141
174
|
entries,
|
|
142
175
|
};
|
|
@@ -148,6 +181,7 @@ describe('handleImportEntries', () => {
|
|
|
148
181
|
expect(emitted).toEqual([
|
|
149
182
|
{
|
|
150
183
|
type: 'import-result',
|
|
184
|
+
requestId: 'request-1',
|
|
151
185
|
target: unknownTarget,
|
|
152
186
|
ok: false,
|
|
153
187
|
written: 0,
|
|
@@ -161,6 +195,7 @@ describe('handleImportEntries', () => {
|
|
|
161
195
|
const view = buildView();
|
|
162
196
|
const event: StorageImportEntriesEvent = {
|
|
163
197
|
type: 'import-entries',
|
|
198
|
+
requestId: 'request-1',
|
|
164
199
|
target,
|
|
165
200
|
entries: [],
|
|
166
201
|
};
|
|
@@ -172,6 +207,7 @@ describe('handleImportEntries', () => {
|
|
|
172
207
|
expect(emitted).toEqual([
|
|
173
208
|
{
|
|
174
209
|
type: 'import-result',
|
|
210
|
+
requestId: 'request-1',
|
|
175
211
|
target,
|
|
176
212
|
ok: true,
|
|
177
213
|
written: 0,
|
|
@@ -179,4 +215,130 @@ describe('handleImportEntries', () => {
|
|
|
179
215
|
},
|
|
180
216
|
]);
|
|
181
217
|
});
|
|
218
|
+
|
|
219
|
+
it('previews current device keys and blacklist decisions without reading values', async () => {
|
|
220
|
+
const view = buildView({
|
|
221
|
+
getAllKeys: vi.fn(async () => ['existing', 'unseen']),
|
|
222
|
+
blacklist: /^private$/,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const result = await handleImportPreviewRequest([view], {
|
|
226
|
+
type: 'preview-import',
|
|
227
|
+
requestId: 'preview-1',
|
|
228
|
+
target,
|
|
229
|
+
snapshot: {
|
|
230
|
+
version: 1,
|
|
231
|
+
plugin: '@rozenite/storage-plugin',
|
|
232
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
233
|
+
storage: {
|
|
234
|
+
adapterId: target.adapterId,
|
|
235
|
+
storageId: target.storageId,
|
|
236
|
+
adapterName: 'MMKV',
|
|
237
|
+
storageName: 'user',
|
|
238
|
+
capabilities: view.capabilities,
|
|
239
|
+
},
|
|
240
|
+
entries: [
|
|
241
|
+
{ key: 'existing', type: 'string', value: 'new' },
|
|
242
|
+
{ key: 'fresh', type: 'string', value: 'value' },
|
|
243
|
+
{ key: 'private', type: 'string', value: 'hidden' },
|
|
244
|
+
],
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
expect(result).toMatchObject({
|
|
249
|
+
type: 'import-preview',
|
|
250
|
+
requestId: 'preview-1',
|
|
251
|
+
target,
|
|
252
|
+
preview: {
|
|
253
|
+
overwriteKeys: ['existing'],
|
|
254
|
+
newKeys: ['fresh'],
|
|
255
|
+
skippedKeys: [{ key: 'private', reason: 'blacklist' }],
|
|
256
|
+
acceptedEntryIndexes: [0, 1],
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
expect(view.get).not.toHaveBeenCalled();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('rechecks blacklist and supported types when applying a stale preview', async () => {
|
|
263
|
+
const view = buildView({
|
|
264
|
+
capabilities: { supportedTypes: ['string'] },
|
|
265
|
+
blacklist: /^blocked$/,
|
|
266
|
+
});
|
|
267
|
+
const emitted: ImportEmittedEvent[] = [];
|
|
268
|
+
|
|
269
|
+
await handleImportEntries(
|
|
270
|
+
[view],
|
|
271
|
+
{
|
|
272
|
+
type: 'import-entries',
|
|
273
|
+
requestId: 'request-1',
|
|
274
|
+
target,
|
|
275
|
+
entries: [
|
|
276
|
+
{ key: 'blocked', type: 'string', value: 'skip' },
|
|
277
|
+
{ key: 'binary', type: 'buffer', value: [1] },
|
|
278
|
+
{ key: 'kept', type: 'string', value: 'write' },
|
|
279
|
+
],
|
|
280
|
+
},
|
|
281
|
+
(out) => emitted.push(out),
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
expect(view.set).toHaveBeenCalledTimes(1);
|
|
285
|
+
expect(emitted).toEqual([
|
|
286
|
+
{
|
|
287
|
+
type: 'import-progress',
|
|
288
|
+
requestId: 'request-1',
|
|
289
|
+
target,
|
|
290
|
+
written: 1,
|
|
291
|
+
total: 1,
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
type: 'storage-invalidated',
|
|
295
|
+
target,
|
|
296
|
+
operation: 'import',
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
type: 'import-result',
|
|
300
|
+
requestId: 'request-1',
|
|
301
|
+
target,
|
|
302
|
+
ok: true,
|
|
303
|
+
written: 1,
|
|
304
|
+
total: 1,
|
|
305
|
+
},
|
|
306
|
+
]);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('returns predictable errors for malformed and unknown preview targets', async () => {
|
|
310
|
+
const view = buildView();
|
|
311
|
+
|
|
312
|
+
await expect(handleImportPreviewRequest([view], {})).resolves.toMatchObject(
|
|
313
|
+
{
|
|
314
|
+
type: 'storage-request-error',
|
|
315
|
+
requestId: '',
|
|
316
|
+
code: 'INVALID_REQUEST',
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
await expect(
|
|
320
|
+
handleImportPreviewRequest([view], {
|
|
321
|
+
type: 'preview-import',
|
|
322
|
+
requestId: 'preview-1',
|
|
323
|
+
target: { adapterId: 'missing', storageId: 'missing' },
|
|
324
|
+
snapshot: {
|
|
325
|
+
version: 1,
|
|
326
|
+
plugin: '@rozenite/storage-plugin',
|
|
327
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
328
|
+
storage: {
|
|
329
|
+
adapterId: target.adapterId,
|
|
330
|
+
storageId: target.storageId,
|
|
331
|
+
adapterName: 'MMKV',
|
|
332
|
+
storageName: 'user',
|
|
333
|
+
capabilities: view.capabilities,
|
|
334
|
+
},
|
|
335
|
+
entries: [],
|
|
336
|
+
},
|
|
337
|
+
}),
|
|
338
|
+
).resolves.toMatchObject({
|
|
339
|
+
type: 'storage-request-error',
|
|
340
|
+
requestId: 'preview-1',
|
|
341
|
+
code: 'TARGET_NOT_FOUND',
|
|
342
|
+
});
|
|
343
|
+
});
|
|
182
344
|
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type {
|
|
3
|
+
StorageAdapter,
|
|
4
|
+
StorageCapabilities,
|
|
5
|
+
StorageTarget,
|
|
6
|
+
} from '../../shared/types';
|
|
7
|
+
import { handleStorageDiscoveryRequest } from '../storage-discovery';
|
|
8
|
+
import { createStorageViews, type StorageView } from '../storage-view';
|
|
9
|
+
|
|
10
|
+
const capabilities: StorageCapabilities = {
|
|
11
|
+
supportedTypes: ['string', 'number', 'boolean', 'buffer'],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const createAdapter = (): StorageAdapter => {
|
|
15
|
+
const unexpectedRead = (method: string) =>
|
|
16
|
+
vi.fn(() => {
|
|
17
|
+
throw new Error(`Unexpected ${method} during discovery`);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
id: 'adapter',
|
|
22
|
+
name: 'Adapter',
|
|
23
|
+
storages: [
|
|
24
|
+
{
|
|
25
|
+
id: 'subscribed',
|
|
26
|
+
name: 'Subscribed storage',
|
|
27
|
+
capabilities,
|
|
28
|
+
storage: {
|
|
29
|
+
kind: 'sync',
|
|
30
|
+
getAllKeys: vi.fn(() => ['theme', 'token']),
|
|
31
|
+
get: unexpectedRead('get'),
|
|
32
|
+
set: vi.fn(),
|
|
33
|
+
delete: vi.fn(),
|
|
34
|
+
clear: vi.fn(),
|
|
35
|
+
subscribe: vi.fn(() => ({ remove: vi.fn() })),
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'manual',
|
|
40
|
+
name: 'Manual storage',
|
|
41
|
+
capabilities,
|
|
42
|
+
storage: {
|
|
43
|
+
kind: 'sync',
|
|
44
|
+
getAllKeys: vi.fn(() => ['session']),
|
|
45
|
+
get: unexpectedRead('get'),
|
|
46
|
+
set: vi.fn(),
|
|
47
|
+
delete: vi.fn(),
|
|
48
|
+
clear: vi.fn(),
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const request = { type: 'discover-storages', requestId: 'request-1' };
|
|
56
|
+
|
|
57
|
+
describe('handleStorageDiscoveryRequest', () => {
|
|
58
|
+
it('returns ordered descriptors with key counts without reading values', async () => {
|
|
59
|
+
const adapter = createAdapter();
|
|
60
|
+
const views = createStorageViews([adapter]);
|
|
61
|
+
|
|
62
|
+
const result = await handleStorageDiscoveryRequest(views, request);
|
|
63
|
+
|
|
64
|
+
expect(result).toEqual({
|
|
65
|
+
type: 'storage-descriptors',
|
|
66
|
+
requestId: 'request-1',
|
|
67
|
+
storages: [
|
|
68
|
+
{
|
|
69
|
+
target: { adapterId: 'adapter', storageId: 'subscribed' },
|
|
70
|
+
adapterName: 'Adapter',
|
|
71
|
+
storageName: 'Subscribed storage',
|
|
72
|
+
entryCount: 2,
|
|
73
|
+
capabilities,
|
|
74
|
+
supportsSubscriptions: true,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
target: { adapterId: 'adapter', storageId: 'manual' },
|
|
78
|
+
adapterName: 'Adapter',
|
|
79
|
+
storageName: 'Manual storage',
|
|
80
|
+
entryCount: 1,
|
|
81
|
+
capabilities,
|
|
82
|
+
supportsSubscriptions: false,
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
for (const storage of adapter.storages) {
|
|
88
|
+
expect(storage.storage.getAllKeys).toHaveBeenCalledTimes(1);
|
|
89
|
+
expect(storage.storage.get).not.toHaveBeenCalled();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns a correlated structured error for invalid requests', async () => {
|
|
94
|
+
expect(
|
|
95
|
+
await handleStorageDiscoveryRequest(
|
|
96
|
+
createStorageViews([createAdapter()]),
|
|
97
|
+
{
|
|
98
|
+
type: 'discover-storages',
|
|
99
|
+
requestId: ' ',
|
|
100
|
+
},
|
|
101
|
+
),
|
|
102
|
+
).toEqual({
|
|
103
|
+
type: 'storage-request-error',
|
|
104
|
+
requestId: ' ',
|
|
105
|
+
code: 'INVALID_REQUEST',
|
|
106
|
+
message: 'Storage discovery requires a non-empty request ID.',
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('rejects duplicate configured targets predictably', async () => {
|
|
111
|
+
const target: StorageTarget = { adapterId: 'adapter', storageId: 'shared' };
|
|
112
|
+
const view = (storageName: string): StorageView => ({
|
|
113
|
+
id: `adapter:${storageName}`,
|
|
114
|
+
target,
|
|
115
|
+
adapterName: 'Adapter',
|
|
116
|
+
storageName,
|
|
117
|
+
capabilities,
|
|
118
|
+
supportsSubscriptions: false,
|
|
119
|
+
get: vi.fn(),
|
|
120
|
+
set: vi.fn(),
|
|
121
|
+
delete: vi.fn(),
|
|
122
|
+
purge: vi.fn(),
|
|
123
|
+
getAllKeys: vi.fn(),
|
|
124
|
+
getAllEntries: vi.fn(),
|
|
125
|
+
watch: vi.fn(),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(
|
|
129
|
+
await handleStorageDiscoveryRequest([view('One'), view('Two')], request),
|
|
130
|
+
).toEqual({
|
|
131
|
+
type: 'storage-request-error',
|
|
132
|
+
requestId: 'request-1',
|
|
133
|
+
code: 'INVALID_REQUEST',
|
|
134
|
+
message: 'Configured storages must have unique targets.',
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('does not expose adapter exceptions over the bridge', async () => {
|
|
139
|
+
const view = {
|
|
140
|
+
target: { adapterId: 'adapter', storageId: 'broken' },
|
|
141
|
+
get adapterName() {
|
|
142
|
+
throw new Error('adapter internals');
|
|
143
|
+
},
|
|
144
|
+
} as unknown as StorageView;
|
|
145
|
+
|
|
146
|
+
await expect(
|
|
147
|
+
handleStorageDiscoveryRequest([view], request),
|
|
148
|
+
).resolves.toEqual({
|
|
149
|
+
type: 'storage-request-error',
|
|
150
|
+
requestId: 'request-1',
|
|
151
|
+
code: 'READ_FAILED',
|
|
152
|
+
message: 'Failed to discover configured storages.',
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createMMKVStorageAdapter } from '../adapters/mmkv';
|
|
3
|
+
import { createStorageViews } from '../storage-view';
|
|
4
|
+
import type { StorageAdapter, StorageEntry } from '../../shared/types';
|
|
5
|
+
|
|
6
|
+
describe('StorageView subscriptions', () => {
|
|
7
|
+
it('uses the storage clear operation when purging', async () => {
|
|
8
|
+
const clear = vi.fn();
|
|
9
|
+
const adapter: StorageAdapter = {
|
|
10
|
+
id: 'manual',
|
|
11
|
+
name: 'Manual',
|
|
12
|
+
storages: [
|
|
13
|
+
{
|
|
14
|
+
id: 'storage',
|
|
15
|
+
name: 'Storage',
|
|
16
|
+
capabilities: { supportedTypes: ['string'] },
|
|
17
|
+
storage: {
|
|
18
|
+
kind: 'sync',
|
|
19
|
+
getAllKeys: vi.fn(() => ['key']),
|
|
20
|
+
get: vi.fn(),
|
|
21
|
+
set: vi.fn(),
|
|
22
|
+
delete: vi.fn(),
|
|
23
|
+
clear,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const [view] = createStorageViews([adapter]);
|
|
30
|
+
await view.purge();
|
|
31
|
+
|
|
32
|
+
expect(clear).toHaveBeenCalledOnce();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('delegates purging to the storage implementation', async () => {
|
|
36
|
+
const clear = vi.fn();
|
|
37
|
+
const adapter: StorageAdapter = {
|
|
38
|
+
id: 'manual',
|
|
39
|
+
name: 'Manual',
|
|
40
|
+
storages: [
|
|
41
|
+
{
|
|
42
|
+
id: 'storage',
|
|
43
|
+
name: 'Storage',
|
|
44
|
+
capabilities: { supportedTypes: ['string'] },
|
|
45
|
+
storage: {
|
|
46
|
+
kind: 'sync',
|
|
47
|
+
getAllKeys: vi.fn(() => ['first', 'second']),
|
|
48
|
+
get: vi.fn(),
|
|
49
|
+
set: vi.fn(),
|
|
50
|
+
delete: vi.fn(),
|
|
51
|
+
clear,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const [view] = createStorageViews([adapter]);
|
|
58
|
+
await view.purge();
|
|
59
|
+
|
|
60
|
+
expect(clear).toHaveBeenCalledOnce();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('does not create a watcher or read unsupported storage while idle', async () => {
|
|
64
|
+
const getAllKeys = vi.fn(() => ['key']);
|
|
65
|
+
const get = vi.fn((): StorageEntry | undefined => ({
|
|
66
|
+
key: 'key',
|
|
67
|
+
type: 'string',
|
|
68
|
+
value: 'value',
|
|
69
|
+
}));
|
|
70
|
+
const adapter: StorageAdapter = {
|
|
71
|
+
id: 'manual',
|
|
72
|
+
name: 'Manual',
|
|
73
|
+
storages: [
|
|
74
|
+
{
|
|
75
|
+
id: 'storage',
|
|
76
|
+
name: 'Storage',
|
|
77
|
+
capabilities: { supportedTypes: ['string'] },
|
|
78
|
+
storage: {
|
|
79
|
+
kind: 'sync',
|
|
80
|
+
getAllKeys,
|
|
81
|
+
get,
|
|
82
|
+
set: vi.fn(),
|
|
83
|
+
delete: vi.fn(),
|
|
84
|
+
clear: vi.fn(),
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const [view] = createStorageViews([adapter]);
|
|
91
|
+
|
|
92
|
+
expect(view.supportsSubscriptions).toBe(false);
|
|
93
|
+
expect(view.watch).toBeUndefined();
|
|
94
|
+
expect(getAllKeys).not.toHaveBeenCalled();
|
|
95
|
+
expect(get).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('forwards native MMKV changes as key-only invalidations without reading values', async () => {
|
|
99
|
+
let onValueChanged: ((key: string) => void) | undefined;
|
|
100
|
+
const remove = vi.fn();
|
|
101
|
+
const storage = {
|
|
102
|
+
getBoolean: vi.fn(() => undefined),
|
|
103
|
+
getString: vi.fn((key: string) => (key === 'theme' ? 'dark' : undefined)),
|
|
104
|
+
getNumber: vi.fn(() => undefined),
|
|
105
|
+
getBuffer: vi.fn(() => undefined),
|
|
106
|
+
set: vi.fn(),
|
|
107
|
+
remove: vi.fn(),
|
|
108
|
+
getAllKeys: vi.fn(() => ['theme']),
|
|
109
|
+
addOnValueChangedListener: vi.fn((listener: (key: string) => void) => {
|
|
110
|
+
onValueChanged = listener;
|
|
111
|
+
return { remove };
|
|
112
|
+
}),
|
|
113
|
+
};
|
|
114
|
+
const [view] = createStorageViews([
|
|
115
|
+
createMMKVStorageAdapter({ storages: { settings: storage as never } }),
|
|
116
|
+
]);
|
|
117
|
+
const onChange = vi.fn();
|
|
118
|
+
|
|
119
|
+
expect(view.supportsSubscriptions).toBe(true);
|
|
120
|
+
expect(view.watch).toBeDefined();
|
|
121
|
+
const subscription = await view.watch?.(onChange);
|
|
122
|
+
expect(storage.addOnValueChangedListener).toHaveBeenCalledTimes(1);
|
|
123
|
+
|
|
124
|
+
onValueChanged?.('theme');
|
|
125
|
+
expect(onChange).toHaveBeenCalledWith('theme');
|
|
126
|
+
|
|
127
|
+
onValueChanged?.('removed');
|
|
128
|
+
expect(onChange).toHaveBeenCalledWith('removed');
|
|
129
|
+
expect(storage.getString).not.toHaveBeenCalled();
|
|
130
|
+
expect(storage.getNumber).not.toHaveBeenCalled();
|
|
131
|
+
expect(storage.getBoolean).not.toHaveBeenCalled();
|
|
132
|
+
expect(storage.getBuffer).not.toHaveBeenCalled();
|
|
133
|
+
expect(storage.getAllKeys).not.toHaveBeenCalled();
|
|
134
|
+
|
|
135
|
+
subscription?.remove();
|
|
136
|
+
expect(remove).toHaveBeenCalledTimes(1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('does not duplicate synchronous callbacks caused by plugin-originated writes', async () => {
|
|
140
|
+
let onValueChanged: ((key: string) => void) | undefined;
|
|
141
|
+
const adapter: StorageAdapter = {
|
|
142
|
+
id: 'subscribed',
|
|
143
|
+
name: 'Subscribed',
|
|
144
|
+
storages: [
|
|
145
|
+
{
|
|
146
|
+
id: 'storage',
|
|
147
|
+
name: 'Storage',
|
|
148
|
+
capabilities: { supportedTypes: ['string'] },
|
|
149
|
+
storage: {
|
|
150
|
+
kind: 'sync',
|
|
151
|
+
getAllKeys: vi.fn(() => []),
|
|
152
|
+
get: vi.fn(),
|
|
153
|
+
set: vi.fn((entry: StorageEntry) => onValueChanged?.(entry.key)),
|
|
154
|
+
delete: vi.fn(),
|
|
155
|
+
clear: vi.fn(),
|
|
156
|
+
subscribe: vi.fn((listener) => {
|
|
157
|
+
onValueChanged = listener;
|
|
158
|
+
return { remove: vi.fn() };
|
|
159
|
+
}),
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
const [view] = createStorageViews([adapter]);
|
|
165
|
+
const onChange = vi.fn();
|
|
166
|
+
await view.watch?.(onChange);
|
|
167
|
+
|
|
168
|
+
await view.set({ key: 'written', type: 'string', value: 'value' });
|
|
169
|
+
|
|
170
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
171
|
+
});
|
|
172
|
+
});
|