@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.
Files changed (79) hide show
  1. package/CHANGELOG.md +28 -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,28 +1,176 @@
1
1
  import type {
2
2
  StorageImportEntriesEvent,
3
+ StorageImportPreviewRequestEvent,
4
+ StorageImportPreviewResponseEvent,
5
+ StorageImportProgressEvent,
3
6
  StorageImportResultEvent,
4
- StorageSetEntryEvent,
7
+ StorageInvalidatedEvent,
8
+ StorageRequestErrorEvent,
5
9
  } from '../shared/messaging';
10
+ import { computePreview, parseSnapshot } from '../shared/snapshot';
11
+ import {
12
+ supportsType,
13
+ type StorageEntry,
14
+ type StorageTarget,
15
+ } from '../shared/types';
6
16
  import type { StorageView } from './storage-view';
7
17
 
8
18
  export type ImportEmittedEvent =
9
- | StorageSetEntryEvent
10
- | StorageImportResultEvent;
19
+ | StorageImportProgressEvent
20
+ | StorageImportResultEvent
21
+ | StorageInvalidatedEvent;
22
+
23
+ type ImportPreviewResult =
24
+ | StorageImportPreviewResponseEvent
25
+ | StorageRequestErrorEvent;
26
+
27
+ const isSameTarget = (left: StorageTarget, right: StorageTarget) =>
28
+ left.adapterId === right.adapterId && left.storageId === right.storageId;
29
+
30
+ const isTarget = (value: unknown): value is StorageTarget =>
31
+ typeof value === 'object' &&
32
+ value != null &&
33
+ !Array.isArray(value) &&
34
+ typeof (value as { adapterId?: unknown }).adapterId === 'string' &&
35
+ (value as { adapterId: string }).adapterId.trim().length > 0 &&
36
+ typeof (value as { storageId?: unknown }).storageId === 'string' &&
37
+ (value as { storageId: string }).storageId.trim().length > 0;
38
+
39
+ const getRequestId = (payload: unknown) =>
40
+ typeof payload === 'object' &&
41
+ payload != null &&
42
+ !Array.isArray(payload) &&
43
+ typeof (payload as { requestId?: unknown }).requestId === 'string'
44
+ ? (payload as { requestId: string }).requestId
45
+ : '';
46
+
47
+ const isEntry = (value: unknown): value is StorageEntry => {
48
+ if (typeof value !== 'object' || value == null || Array.isArray(value)) {
49
+ return false;
50
+ }
51
+
52
+ const entry = value as { key?: unknown; type?: unknown; value?: unknown };
53
+ if (typeof entry.key !== 'string') return false;
54
+ if (entry.type === 'string') return typeof entry.value === 'string';
55
+ if (entry.type === 'number') return typeof entry.value === 'number';
56
+ if (entry.type === 'boolean') return typeof entry.value === 'boolean';
57
+ return (
58
+ entry.type === 'buffer' &&
59
+ Array.isArray(entry.value) &&
60
+ entry.value.every(
61
+ (byte) =>
62
+ typeof byte === 'number' &&
63
+ Number.isInteger(byte) &&
64
+ byte >= 0 &&
65
+ byte <= 255,
66
+ )
67
+ );
68
+ };
69
+
70
+ const isBlacklisted = (view: StorageView, key: string) => {
71
+ if (!view.blacklist) return false;
72
+ view.blacklist.lastIndex = 0;
73
+ return view.blacklist.test(key);
74
+ };
75
+
76
+ const findView = (views: readonly StorageView[], target: StorageTarget) =>
77
+ views.find((candidate) => isSameTarget(candidate.target, target));
78
+
79
+ export const handleImportPreviewRequest = async (
80
+ views: readonly StorageView[],
81
+ payload: unknown,
82
+ ): Promise<ImportPreviewResult> => {
83
+ const requestId = getRequestId(payload);
84
+ const request = payload as Partial<StorageImportPreviewRequestEvent>;
85
+ const parsedSnapshot = parseSnapshot(request.snapshot);
86
+ if (
87
+ typeof request !== 'object' ||
88
+ request == null ||
89
+ request.type !== 'preview-import' ||
90
+ typeof request.requestId !== 'string' ||
91
+ request.requestId.trim().length === 0 ||
92
+ !isTarget(request.target) ||
93
+ !parsedSnapshot.ok
94
+ ) {
95
+ return {
96
+ type: 'storage-request-error',
97
+ requestId,
98
+ code: 'INVALID_REQUEST',
99
+ message:
100
+ 'Import preview requests require a valid target, request ID, and snapshot.',
101
+ };
102
+ }
103
+
104
+ const view = findView(views, request.target);
105
+ if (!view) {
106
+ return {
107
+ type: 'storage-request-error',
108
+ requestId,
109
+ code: 'TARGET_NOT_FOUND',
110
+ message: 'The requested storage target was not found.',
111
+ };
112
+ }
113
+
114
+ try {
115
+ const entryKeys = new Set(await view.getAllKeys());
116
+ return {
117
+ type: 'import-preview',
118
+ requestId,
119
+ target: view.target,
120
+ preview: computePreview(parsedSnapshot.snapshot, {
121
+ target: view.target,
122
+ capabilities: view.capabilities,
123
+ entryKeys,
124
+ isBlacklisted: (key) => isBlacklisted(view, key),
125
+ }),
126
+ };
127
+ } catch {
128
+ return {
129
+ type: 'storage-request-error',
130
+ requestId,
131
+ code: 'READ_FAILED',
132
+ message: 'Failed to inspect the requested storage for import.',
133
+ };
134
+ }
135
+ };
11
136
 
12
137
  export const handleImportEntries = async (
13
138
  views: StorageView[],
14
- event: StorageImportEntriesEvent,
139
+ payload: unknown,
15
140
  emit: (out: ImportEmittedEvent) => void,
16
141
  ): Promise<void> => {
17
- const view = views.find(
18
- (candidate) =>
19
- candidate.target.adapterId === event.target.adapterId &&
20
- candidate.target.storageId === event.target.storageId,
21
- );
142
+ const requestId = getRequestId(payload);
143
+ const event = payload as Partial<StorageImportEntriesEvent>;
144
+ if (
145
+ typeof event !== 'object' ||
146
+ event == null ||
147
+ event.type !== 'import-entries' ||
148
+ typeof event.requestId !== 'string' ||
149
+ event.requestId.trim().length === 0 ||
150
+ !isTarget(event.target) ||
151
+ !Array.isArray(event.entries) ||
152
+ !event.entries.every(isEntry)
153
+ ) {
154
+ emit({
155
+ type: 'import-result',
156
+ requestId,
157
+ target: isTarget(event.target)
158
+ ? event.target
159
+ : { adapterId: '', storageId: '' },
160
+ ok: false,
161
+ written: 0,
162
+ total: 0,
163
+ error: 'Import requests require a valid target, request ID, and entries.',
164
+ });
165
+ return;
166
+ }
167
+
168
+ const view = findView(views, event.target);
22
169
 
23
170
  if (!view) {
24
171
  emit({
25
172
  type: 'import-result',
173
+ requestId: event.requestId,
26
174
  target: event.target,
27
175
  ok: false,
28
176
  written: 0,
@@ -32,18 +180,33 @@ export const handleImportEntries = async (
32
180
  return;
33
181
  }
34
182
 
35
- for (let i = 0; i < event.entries.length; i++) {
36
- const entry = event.entries[i];
183
+ // Recheck policy at apply time so a preview cannot bypass a changed target.
184
+ const entries = event.entries.filter(
185
+ (entry) =>
186
+ supportsType(view.capabilities, entry.type) &&
187
+ !isBlacklisted(view, entry.key),
188
+ );
189
+
190
+ for (let i = 0; i < entries.length; i++) {
191
+ const entry = entries[i];
37
192
 
38
193
  try {
39
194
  await view.set(entry);
40
195
  } catch (error) {
196
+ if (i > 0) {
197
+ emit({
198
+ type: 'storage-invalidated',
199
+ target: event.target,
200
+ operation: 'import',
201
+ });
202
+ }
41
203
  emit({
42
204
  type: 'import-result',
205
+ requestId: event.requestId,
43
206
  target: event.target,
44
207
  ok: false,
45
208
  written: i,
46
- total: event.entries.length,
209
+ total: entries.length,
47
210
  failedKey: entry.key,
48
211
  error: error instanceof Error ? error.message : String(error),
49
212
  });
@@ -51,17 +214,28 @@ export const handleImportEntries = async (
51
214
  }
52
215
 
53
216
  emit({
54
- type: 'set-entry',
217
+ type: 'import-progress',
218
+ requestId: event.requestId,
219
+ target: event.target,
220
+ written: i + 1,
221
+ total: entries.length,
222
+ });
223
+ }
224
+
225
+ if (entries.length > 0) {
226
+ emit({
227
+ type: 'storage-invalidated',
55
228
  target: event.target,
56
- entry,
229
+ operation: 'import',
57
230
  });
58
231
  }
59
232
 
60
233
  emit({
61
234
  type: 'import-result',
235
+ requestId: event.requestId,
62
236
  target: event.target,
63
237
  ok: true,
64
- written: event.entries.length,
65
- total: event.entries.length,
238
+ written: entries.length,
239
+ total: entries.length,
66
240
  });
67
241
  };
@@ -0,0 +1,96 @@
1
+ import type {
2
+ StorageDiscoverStoragesRequestEvent,
3
+ StorageDiscoverStoragesResponseEvent,
4
+ StorageRequestErrorEvent,
5
+ } from '../shared/messaging';
6
+ import type { StorageDescriptor } from '../shared/types';
7
+ import type { StorageView } from './storage-view';
8
+
9
+ type DiscoveryResult =
10
+ | StorageDiscoverStoragesResponseEvent
11
+ | StorageRequestErrorEvent;
12
+
13
+ const getRequestId = (payload: unknown) => {
14
+ if (
15
+ typeof payload === 'object' &&
16
+ payload != null &&
17
+ !Array.isArray(payload) &&
18
+ typeof (payload as { requestId?: unknown }).requestId === 'string'
19
+ ) {
20
+ return (payload as { requestId: string }).requestId;
21
+ }
22
+
23
+ return '';
24
+ };
25
+
26
+ const validateRequest = (
27
+ payload: unknown,
28
+ ): payload is StorageDiscoverStoragesRequestEvent =>
29
+ typeof payload === 'object' &&
30
+ payload != null &&
31
+ !Array.isArray(payload) &&
32
+ (payload as { type?: unknown }).type === 'discover-storages' &&
33
+ typeof (payload as { requestId?: unknown }).requestId === 'string' &&
34
+ (payload as { requestId: string }).requestId.trim().length > 0;
35
+
36
+ const toDescriptor = async (view: StorageView): Promise<StorageDescriptor> => ({
37
+ target: view.target,
38
+ adapterName: view.adapterName,
39
+ storageName: view.storageName,
40
+ entryCount: (await view.getAllKeys()).length,
41
+ capabilities: view.capabilities,
42
+ supportsSubscriptions: view.supportsSubscriptions,
43
+ });
44
+
45
+ const hasDuplicateTargets = (views: readonly Pick<StorageView, 'target'>[]) => {
46
+ const targets = new Set<string>();
47
+
48
+ return views.some(({ target }) => {
49
+ const id = `${target.adapterId}:${target.storageId}`;
50
+
51
+ if (targets.has(id)) {
52
+ return true;
53
+ }
54
+
55
+ targets.add(id);
56
+ return false;
57
+ });
58
+ };
59
+
60
+ export const handleStorageDiscoveryRequest = async (
61
+ views: readonly StorageView[],
62
+ payload: unknown,
63
+ ): Promise<DiscoveryResult> => {
64
+ const requestId = getRequestId(payload);
65
+
66
+ if (!validateRequest(payload)) {
67
+ return {
68
+ type: 'storage-request-error',
69
+ requestId,
70
+ code: 'INVALID_REQUEST',
71
+ message: 'Storage discovery requires a non-empty request ID.',
72
+ };
73
+ }
74
+
75
+ try {
76
+ if (hasDuplicateTargets(views)) {
77
+ return {
78
+ type: 'storage-request-error',
79
+ requestId,
80
+ code: 'INVALID_REQUEST',
81
+ message: 'Configured storages must have unique targets.',
82
+ };
83
+ }
84
+
85
+ const storages = await Promise.all(views.map(toDescriptor));
86
+
87
+ return { type: 'storage-descriptors', requestId, storages };
88
+ } catch {
89
+ return {
90
+ type: 'storage-request-error',
91
+ requestId,
92
+ code: 'READ_FAILED',
93
+ message: 'Failed to discover configured storages.',
94
+ };
95
+ }
96
+ };
@@ -12,27 +12,11 @@ import {
12
12
  type SyncStorage,
13
13
  } from '../shared/types';
14
14
 
15
- const POLLING_INTERVAL_MS = 1500;
16
-
17
- type StorageSnapshotMap = Map<string, StorageEntry>;
18
-
19
15
  type AsyncStorageLike = SyncStorage | AsyncStorage;
20
16
 
21
17
  const isAsyncStorage = (storage: AsyncStorageLike): storage is AsyncStorage =>
22
18
  storage.kind === 'async';
23
19
 
24
- const fingerprintEntry = (entry: StorageEntry) => {
25
- if (entry.type === 'buffer') {
26
- return `${entry.type}:${entry.value.join(',')}`;
27
- }
28
-
29
- return `${entry.type}:${String(entry.value)}`;
30
- };
31
-
32
- const toSnapshotMap = (entries: StorageEntry[]) => {
33
- return new Map(entries.map((entry) => [entry.key, entry]));
34
- };
35
-
36
20
  const shouldFilterKey = (storage: StorageNode, key: string) => {
37
21
  if (storage.shouldFilterKey?.(key)) {
38
22
  return true;
@@ -94,83 +78,25 @@ const deleteEntry = async (storage: AsyncStorageLike, key: string) => {
94
78
  storage.delete(key);
95
79
  };
96
80
 
81
+ const clearStorage = async (storage: AsyncStorageLike) => {
82
+ await storage.clear();
83
+ };
84
+
97
85
  export type StorageView = {
98
86
  id: string;
99
87
  target: StorageTarget;
100
88
  adapterName: string;
101
89
  storageName: string;
102
90
  capabilities: StorageCapabilities;
91
+ supportsSubscriptions: boolean;
103
92
  blacklist?: RegExp;
104
93
  get: (key: string) => Promise<StorageEntry | undefined>;
105
94
  set: (entry: StorageEntry) => Promise<void>;
106
95
  delete: (key: string) => Promise<void>;
96
+ purge: () => Promise<void>;
107
97
  getAllKeys: () => Promise<readonly string[]>;
108
98
  getAllEntries: () => Promise<StorageEntry[]>;
109
- watch: (callbacks: {
110
- onSet: (entry: StorageEntry) => void;
111
- onDelete: (key: string) => void;
112
- }) => Promise<StorageSubscription>;
113
- };
114
-
115
- const buildSnapshotMap = async (
116
- getAllEntries: () => Promise<StorageEntry[]>,
117
- ): Promise<StorageSnapshotMap> => {
118
- const entries = await getAllEntries();
119
- return toSnapshotMap(entries);
120
- };
121
-
122
- const diffSnapshots = (
123
- previous: StorageSnapshotMap,
124
- next: StorageSnapshotMap,
125
- handlers: {
126
- onSet: (entry: StorageEntry) => void;
127
- onDelete: (key: string) => void;
128
- },
129
- ) => {
130
- next.forEach((nextEntry, key) => {
131
- const previousEntry = previous.get(key);
132
-
133
- if (!previousEntry) {
134
- handlers.onSet(nextEntry);
135
- return;
136
- }
137
-
138
- if (fingerprintEntry(previousEntry) !== fingerprintEntry(nextEntry)) {
139
- handlers.onSet(nextEntry);
140
- }
141
- });
142
-
143
- previous.forEach((_value, key) => {
144
- if (!next.has(key)) {
145
- handlers.onDelete(key);
146
- }
147
- });
148
- };
149
-
150
- const createPollingSubscription = async (
151
- getAllEntries: () => Promise<StorageEntry[]>,
152
- handlers: {
153
- onSet: (entry: StorageEntry) => void;
154
- onDelete: (key: string) => void;
155
- },
156
- ): Promise<StorageSubscription> => {
157
- let previousSnapshot = await buildSnapshotMap(getAllEntries);
158
-
159
- const interval = setInterval(async () => {
160
- try {
161
- const nextSnapshot = await buildSnapshotMap(getAllEntries);
162
- diffSnapshots(previousSnapshot, nextSnapshot, handlers);
163
- previousSnapshot = nextSnapshot;
164
- } catch {
165
- // Silently ignore polling errors and try again on next tick.
166
- }
167
- }, POLLING_INTERVAL_MS);
168
-
169
- return {
170
- remove: () => {
171
- clearInterval(interval);
172
- },
173
- };
99
+ watch?: (onChange: (key: string) => void) => Promise<StorageSubscription>;
174
100
  };
175
101
 
176
102
  export const createStorageView = (
@@ -178,6 +104,9 @@ export const createStorageView = (
178
104
  storageNode: StorageNode,
179
105
  ): StorageView => {
180
106
  const storage = storageNode.storage;
107
+ const subscribe = storage.subscribe;
108
+ const locallyMutatingKeys = new Set<string>();
109
+ let isLocallyPurging = false;
181
110
  const target: StorageTarget = {
182
111
  adapterId: adapter.id,
183
112
  storageId: storageNode.id,
@@ -208,40 +137,53 @@ export const createStorageView = (
208
137
  adapterName: adapter.name,
209
138
  storageName: storageNode.name,
210
139
  capabilities: storageNode.capabilities,
140
+ supportsSubscriptions: subscribe != null,
211
141
  blacklist: storageNode.blacklist,
212
142
  get,
213
143
  set: async (entry) => {
214
144
  checkTypeSupport(storageNode.capabilities, entry.type, target);
215
- await setEntry(storage, entry);
145
+ locallyMutatingKeys.add(entry.key);
146
+ try {
147
+ await setEntry(storage, entry);
148
+ } finally {
149
+ locallyMutatingKeys.delete(entry.key);
150
+ }
216
151
  },
217
152
  delete: async (key) => {
218
- await deleteEntry(storage, key);
153
+ locallyMutatingKeys.add(key);
154
+ try {
155
+ await deleteEntry(storage, key);
156
+ } finally {
157
+ locallyMutatingKeys.delete(key);
158
+ }
159
+ },
160
+ purge: async () => {
161
+ isLocallyPurging = true;
162
+ try {
163
+ await clearStorage(storage);
164
+ } finally {
165
+ isLocallyPurging = false;
166
+ }
219
167
  },
220
168
  getAllKeys: async () => {
221
169
  const keys = await getAllKeys(storage);
222
170
  return keys.filter((key) => !shouldFilterKey(storageNode, key));
223
171
  },
224
172
  getAllEntries,
225
- watch: async ({ onSet, onDelete }) => {
226
- if (storage.subscribe) {
227
- return storage.subscribe(async (key) => {
228
- try {
229
- const entry = await get(key);
230
-
231
- if (!entry) {
232
- onDelete(key);
233
- return;
234
- }
235
-
236
- onSet(entry);
237
- } catch {
238
- // Ignore runtime callback errors; polling fallback is not needed when subscribe exists.
239
- }
240
- });
241
- }
242
-
243
- return createPollingSubscription(getAllEntries, { onSet, onDelete });
244
- },
173
+ ...(subscribe
174
+ ? {
175
+ watch: async (onChange) =>
176
+ subscribe((key) => {
177
+ if (
178
+ !isLocallyPurging &&
179
+ !locallyMutatingKeys.has(key) &&
180
+ !shouldFilterKey(storageNode, key)
181
+ ) {
182
+ onChange(key);
183
+ }
184
+ }),
185
+ }
186
+ : {}),
245
187
  };
246
188
  };
247
189