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