@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.
Files changed (84) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +6 -1
  3. package/dist/devtools/assets/panel-BlDRXgVW.js +32 -0
  4. package/dist/devtools/assets/panel-DBweZnW5.css +1 -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 -66
  10. package/dist/react-native/chunks/secure-storage.require.cjs +1 -1
  11. package/dist/react-native/chunks/secure-storage.require.js +17 -13
  12. package/dist/react-native/chunks/useRozeniteStoragePlugin.require.cjs +1 -1
  13. package/dist/react-native/chunks/useRozeniteStoragePlugin.require.js +863 -344
  14. package/dist/react-native/index.d.ts +166 -18
  15. package/dist/rozenite.json +1 -1
  16. package/dist/sdk/index.cjs +1 -1
  17. package/dist/sdk/index.d.ts +13 -5
  18. package/dist/sdk/index.js +21 -12
  19. package/package.json +35 -37
  20. package/react-native.ts +12 -22
  21. package/rozenite.config.ts +74 -0
  22. package/sdk.ts +1 -5
  23. package/src/react-native/__tests__/entry-preview-pagination.test.ts +234 -0
  24. package/src/react-native/__tests__/export-snapshot.test.ts +138 -0
  25. package/src/react-native/__tests__/full-entry-request.test.ts +143 -0
  26. package/src/react-native/__tests__/import.test.ts +174 -11
  27. package/src/react-native/__tests__/storage-discovery.test.ts +144 -0
  28. package/src/react-native/__tests__/storage-view.test.ts +172 -0
  29. package/src/react-native/__tests__/stress-verification.test.ts +140 -0
  30. package/src/react-native/__tests__/use-storage-agent-tools.test.ts +221 -0
  31. package/src/react-native/adapters/__tests__/mmkv.test.ts +20 -30
  32. package/src/react-native/adapters/async-storage.ts +8 -9
  33. package/src/react-native/adapters/index.ts +2 -8
  34. package/src/react-native/adapters/mmkv.ts +12 -24
  35. package/src/react-native/adapters/secure-storage.ts +4 -0
  36. package/src/react-native/entry-preview-pagination.ts +261 -0
  37. package/src/react-native/export-snapshot.ts +88 -0
  38. package/src/react-native/full-entry-request.ts +91 -0
  39. package/src/react-native/import.ts +179 -16
  40. package/src/react-native/storage-discovery.ts +92 -0
  41. package/src/react-native/storage-view.ts +47 -109
  42. package/src/react-native/useRozeniteStoragePlugin.ts +153 -129
  43. package/src/react-native/useStorageAgentTools.ts +196 -91
  44. package/src/shared/__tests__/entry-preview.test.ts +99 -0
  45. package/src/shared/__tests__/snapshot.test.ts +31 -32
  46. package/src/shared/agent-tools.ts +33 -38
  47. package/src/shared/entry-preview.ts +54 -0
  48. package/src/shared/messaging.ts +140 -21
  49. package/src/shared/snapshot.ts +12 -18
  50. package/src/shared/types.ts +21 -4
  51. package/src/ui/__tests__/binary-value-editor-state.test.ts +16 -13
  52. package/src/ui/__tests__/binary.test.ts +31 -33
  53. package/src/ui/__tests__/large-value-viewer-state.test.ts +21 -0
  54. package/src/ui/__tests__/large-value-viewer.test.tsx +54 -0
  55. package/src/ui/__tests__/panel.test.tsx +382 -0
  56. package/src/ui/__tests__/storage-groups.test.ts +88 -0
  57. package/src/ui/__tests__/type-conversion.test.ts +8 -24
  58. package/src/ui/__tests__/use-storage-entries.test.tsx +337 -0
  59. package/src/ui/add-entry-dialog.tsx +66 -117
  60. package/src/ui/binary-value-editor-state.ts +16 -25
  61. package/src/ui/binary-value-editor.tsx +59 -47
  62. package/src/ui/binary.ts +54 -15
  63. package/src/ui/edit-entry-dialog.tsx +68 -104
  64. package/src/ui/editor-switcher.tsx +9 -16
  65. package/src/ui/entry-detail-dialog.tsx +62 -181
  66. package/src/ui/format-value.tsx +25 -0
  67. package/src/ui/globals.css +1 -123
  68. package/src/ui/import-dialog.tsx +72 -109
  69. package/src/ui/large-value-viewer-state.ts +25 -0
  70. package/src/ui/large-value-viewer.tsx +65 -0
  71. package/src/ui/panel.tsx +649 -509
  72. package/src/ui/query-client.tsx +23 -0
  73. package/src/ui/storage-groups.ts +66 -0
  74. package/src/ui/type-conversion.ts +2 -5
  75. package/src/ui/typed-value-editor.tsx +15 -20
  76. package/src/ui/use-storage-entries.ts +192 -0
  77. package/src/ui/utils.ts +1 -4
  78. package/tsconfig.json +5 -1
  79. package/dist/devtools/assets/panel-Bm-SWF7d.js +0 -33
  80. package/dist/devtools/assets/panel-DIqI4WSp.css +0 -1
  81. package/postcss.config.js +0 -6
  82. package/src/ui/confirm-dialog.tsx +0 -100
  83. package/src/ui/editable-table.tsx +0 -300
  84. 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'),
27
- getAllKeys: unexpected('getAllKeys'),
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 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,25 @@ 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
+ 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 set-entry echoes (k0, k1), then the failure result. No echoes for k2+.
109
+ // 2 value-free progress events (k0, k1), one batch invalidation, then failure.
93
110
  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] });
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
+ });