@rozenite/storage-plugin 1.12.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +6 -1
  3. package/dist/devtools/assets/panel-B6Wp0eie.css +1 -0
  4. package/dist/devtools/assets/panel-D7MFc4HG.js +32 -0
  5. package/dist/devtools/panel.html +2 -2
  6. package/dist/react-native/chunks/async-storage.require.cjs +1 -1
  7. package/dist/react-native/chunks/async-storage.require.js +8 -7
  8. package/dist/react-native/chunks/index.require.cjs +1 -1
  9. package/dist/react-native/chunks/index.require.js +70 -64
  10. package/dist/react-native/chunks/secure-storage.require.cjs +1 -1
  11. package/dist/react-native/chunks/secure-storage.require.js +22 -14
  12. package/dist/react-native/chunks/useRozeniteStoragePlugin.require.cjs +1 -1
  13. package/dist/react-native/chunks/useRozeniteStoragePlugin.require.js +863 -315
  14. package/dist/react-native/index.d.ts +154 -18
  15. package/dist/react-native/index.js +10 -2
  16. package/dist/rozenite.json +1 -1
  17. package/dist/sdk/index.cjs +1 -1
  18. package/dist/sdk/index.d.ts +13 -5
  19. package/dist/sdk/index.js +21 -12
  20. package/package.json +13 -15
  21. package/react-native.ts +29 -14
  22. package/rozenite.config.ts +78 -0
  23. package/src/react-native/__tests__/entry-preview-pagination.test.ts +262 -0
  24. package/src/react-native/__tests__/export-snapshot.test.ts +142 -0
  25. package/src/react-native/__tests__/full-entry-request.test.ts +155 -0
  26. package/src/react-native/__tests__/import.test.ts +172 -10
  27. package/src/react-native/__tests__/storage-discovery.test.ts +155 -0
  28. package/src/react-native/__tests__/storage-view.test.ts +172 -0
  29. package/src/react-native/__tests__/stress-verification.test.ts +153 -0
  30. package/src/react-native/__tests__/use-storage-agent-tools.test.ts +231 -0
  31. package/src/react-native/adapters/async-storage.ts +9 -7
  32. package/src/react-native/adapters/mmkv.ts +5 -0
  33. package/src/react-native/adapters/secure-storage.ts +9 -1
  34. package/src/react-native/entry-preview-pagination.ts +277 -0
  35. package/src/react-native/export-snapshot.ts +94 -0
  36. package/src/react-native/full-entry-request.ts +96 -0
  37. package/src/react-native/import.ts +190 -16
  38. package/src/react-native/storage-discovery.ts +96 -0
  39. package/src/react-native/storage-view.ts +45 -103
  40. package/src/react-native/useRozeniteStoragePlugin.ts +108 -72
  41. package/src/react-native/useStorageAgentTools.ts +207 -86
  42. package/src/shared/__tests__/entry-preview.test.ts +117 -0
  43. package/src/shared/__tests__/snapshot.test.ts +21 -0
  44. package/src/shared/agent-tools.ts +27 -11
  45. package/src/shared/entry-preview.ts +59 -0
  46. package/src/shared/messaging.ts +136 -21
  47. package/src/shared/snapshot.ts +8 -1
  48. package/src/shared/types.ts +20 -1
  49. package/src/ui/__tests__/binary-value-editor-state.test.ts +13 -0
  50. package/src/ui/__tests__/binary.test.ts +18 -0
  51. package/src/ui/__tests__/large-value-viewer-state.test.ts +21 -0
  52. package/src/ui/__tests__/large-value-viewer.test.tsx +63 -0
  53. package/src/ui/__tests__/panel.test.tsx +409 -0
  54. package/src/ui/__tests__/storage-groups.test.ts +91 -0
  55. package/src/ui/__tests__/use-storage-entries.test.tsx +353 -0
  56. package/src/ui/add-entry-dialog.tsx +65 -105
  57. package/src/ui/binary-value-editor-state.ts +10 -1
  58. package/src/ui/binary-value-editor.tsx +57 -33
  59. package/src/ui/binary.ts +56 -4
  60. package/src/ui/edit-entry-dialog.tsx +71 -99
  61. package/src/ui/editor-switcher.tsx +7 -5
  62. package/src/ui/entry-detail-dialog.tsx +69 -179
  63. package/src/ui/format-value.tsx +32 -0
  64. package/src/ui/globals.css +1 -123
  65. package/src/ui/import-dialog.tsx +66 -91
  66. package/src/ui/large-value-viewer-state.ts +32 -0
  67. package/src/ui/large-value-viewer.tsx +71 -0
  68. package/src/ui/panel.tsx +724 -496
  69. package/src/ui/query-client.tsx +34 -0
  70. package/src/ui/storage-groups.ts +66 -0
  71. package/src/ui/typed-value-editor.tsx +14 -15
  72. package/src/ui/use-storage-entries.ts +207 -0
  73. package/tsconfig.json +5 -1
  74. package/dist/devtools/assets/panel-Bm-SWF7d.js +0 -33
  75. package/dist/devtools/assets/panel-DIqI4WSp.css +0 -1
  76. package/postcss.config.js +0 -6
  77. package/src/ui/confirm-dialog.tsx +0 -100
  78. package/src/ui/editable-table.tsx +0 -300
  79. package/tailwind.config.ts +0 -94
@@ -1,5 +1,9 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
- import { handleImportEntries, type ImportEmittedEvent } from '../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 a set-entry echo per write followed by a success import-result', async () => {
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(6);
63
+ expect(emitted).toHaveLength(7);
57
64
  expect(emitted.slice(0, 5)).toEqual(
58
- entries.map((entry) => ({
59
- type: 'set-entry',
65
+ entries.map((_, index) => ({
66
+ type: 'import-progress',
67
+ requestId: 'request-1',
60
68
  target,
61
- entry,
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 set-entry echoes (k0, k1), then the failure result. No echoes for k2+.
108
+ // 2 value-free progress events (k0, k1), one batch invalidation, then failure.
93
109
  expect(view.set).toHaveBeenCalledTimes(3);
94
- expect(emitted).toHaveLength(3);
95
- expect(emitted[0]).toMatchObject({ type: 'set-entry', entry: entries[0] });
96
- expect(emitted[1]).toMatchObject({ type: 'set-entry', entry: entries[1] });
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
+ });