@rozenite/storage-plugin 1.8.1 → 1.10.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 (42) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +23 -0
  3. package/dist/devtools/assets/panel-Bm-SWF7d.js +33 -0
  4. package/dist/devtools/assets/panel-DIqI4WSp.css +1 -0
  5. package/dist/devtools/panel.html +2 -2
  6. package/dist/react-native/chunks/index.require.cjs +1 -1
  7. package/dist/react-native/chunks/index.require.js +43 -57
  8. package/dist/react-native/chunks/useRozeniteStoragePlugin.require.cjs +1 -1
  9. package/dist/react-native/chunks/useRozeniteStoragePlugin.require.js +253 -191
  10. package/dist/react-native/index.d.ts +29 -6
  11. package/dist/rozenite.json +1 -1
  12. package/dist/sdk/index.d.ts +1 -0
  13. package/package.json +9 -6
  14. package/src/react-native/__tests__/import.test.ts +182 -0
  15. package/src/react-native/adapters/__tests__/mmkv.test.ts +436 -0
  16. package/src/react-native/adapters/mmkv.ts +65 -39
  17. package/src/react-native/import.ts +67 -0
  18. package/src/react-native/storage-view.ts +16 -8
  19. package/src/react-native/useRozeniteStoragePlugin.ts +61 -36
  20. package/src/shared/__tests__/snapshot.test.ts +361 -0
  21. package/src/shared/messaging.ts +25 -1
  22. package/src/shared/snapshot.ts +276 -0
  23. package/src/shared/types.ts +1 -0
  24. package/src/ui/__tests__/binary-value-editor-state.test.ts +199 -0
  25. package/src/ui/__tests__/binary.test.ts +251 -0
  26. package/src/ui/__tests__/type-conversion.test.ts +123 -0
  27. package/src/ui/add-entry-dialog.tsx +83 -173
  28. package/src/ui/binary-value-editor-state.ts +125 -0
  29. package/src/ui/binary-value-editor.tsx +168 -0
  30. package/src/ui/binary.ts +123 -0
  31. package/src/ui/edit-entry-dialog.tsx +64 -161
  32. package/src/ui/editable-table.tsx +12 -10
  33. package/src/ui/editor-switcher.tsx +62 -0
  34. package/src/ui/entry-detail-dialog.tsx +14 -6
  35. package/src/ui/import-dialog.tsx +261 -0
  36. package/src/ui/panel.tsx +257 -57
  37. package/src/ui/type-conversion.ts +105 -0
  38. package/src/ui/typed-value-editor.tsx +96 -0
  39. package/src/ui/utils.ts +30 -0
  40. package/dist/devtools/assets/panel-DMhXYHH4.css +0 -1
  41. package/dist/devtools/assets/panel-eGOuOVos.js +0 -22
  42. package/src/react-native/is-garbled.ts +0 -17
@@ -34,6 +34,10 @@ const toSnapshotMap = (entries: StorageEntry[]) => {
34
34
  };
35
35
 
36
36
  const shouldFilterKey = (storage: StorageNode, key: string) => {
37
+ if (storage.shouldFilterKey?.(key)) {
38
+ return true;
39
+ }
40
+
37
41
  if (!storage.blacklist) {
38
42
  return false;
39
43
  }
@@ -45,14 +49,14 @@ const shouldFilterKey = (storage: StorageNode, key: string) => {
45
49
  const checkTypeSupport = (
46
50
  capabilities: StorageCapabilities,
47
51
  type: StorageEntryType,
48
- target: StorageTarget
52
+ target: StorageTarget,
49
53
  ) => {
50
54
  if (supportsType(capabilities, type)) {
51
55
  return;
52
56
  }
53
57
 
54
58
  throw new Error(
55
- `Type "${type}" is not supported by storage "${target.storageId}" in adapter "${target.adapterId}".`
59
+ `Type "${type}" is not supported by storage "${target.storageId}" in adapter "${target.adapterId}".`,
56
60
  );
57
61
  };
58
62
 
@@ -96,6 +100,7 @@ export type StorageView = {
96
100
  adapterName: string;
97
101
  storageName: string;
98
102
  capabilities: StorageCapabilities;
103
+ blacklist?: RegExp;
99
104
  get: (key: string) => Promise<StorageEntry | undefined>;
100
105
  set: (entry: StorageEntry) => Promise<void>;
101
106
  delete: (key: string) => Promise<void>;
@@ -108,7 +113,7 @@ export type StorageView = {
108
113
  };
109
114
 
110
115
  const buildSnapshotMap = async (
111
- getAllEntries: () => Promise<StorageEntry[]>
116
+ getAllEntries: () => Promise<StorageEntry[]>,
112
117
  ): Promise<StorageSnapshotMap> => {
113
118
  const entries = await getAllEntries();
114
119
  return toSnapshotMap(entries);
@@ -120,7 +125,7 @@ const diffSnapshots = (
120
125
  handlers: {
121
126
  onSet: (entry: StorageEntry) => void;
122
127
  onDelete: (key: string) => void;
123
- }
128
+ },
124
129
  ) => {
125
130
  next.forEach((nextEntry, key) => {
126
131
  const previousEntry = previous.get(key);
@@ -147,7 +152,7 @@ const createPollingSubscription = async (
147
152
  handlers: {
148
153
  onSet: (entry: StorageEntry) => void;
149
154
  onDelete: (key: string) => void;
150
- }
155
+ },
151
156
  ): Promise<StorageSubscription> => {
152
157
  let previousSnapshot = await buildSnapshotMap(getAllEntries);
153
158
 
@@ -170,7 +175,7 @@ const createPollingSubscription = async (
170
175
 
171
176
  export const createStorageView = (
172
177
  adapter: StorageAdapter,
173
- storageNode: StorageNode
178
+ storageNode: StorageNode,
174
179
  ): StorageView => {
175
180
  const storage = storageNode.storage;
176
181
  const target: StorageTarget = {
@@ -191,7 +196,7 @@ export const createStorageView = (
191
196
  const visibleEntries = await Promise.all(
192
197
  keys
193
198
  .filter((key) => !shouldFilterKey(storageNode, key))
194
- .map((key) => getEntry(storage, key))
199
+ .map((key) => getEntry(storage, key)),
195
200
  );
196
201
 
197
202
  return visibleEntries.filter((entry): entry is StorageEntry => !!entry);
@@ -203,6 +208,7 @@ export const createStorageView = (
203
208
  adapterName: adapter.name,
204
209
  storageName: storageNode.name,
205
210
  capabilities: storageNode.capabilities,
211
+ blacklist: storageNode.blacklist,
206
212
  get,
207
213
  set: async (entry) => {
208
214
  checkTypeSupport(storageNode.capabilities, entry.type, target);
@@ -241,5 +247,7 @@ export const createStorageView = (
241
247
 
242
248
  export const createStorageViews = (storages: StorageAdapter[]) =>
243
249
  storages.flatMap((adapter) =>
244
- adapter.storages.map((storageNode) => createStorageView(adapter, storageNode))
250
+ adapter.storages.map((storageNode) =>
251
+ createStorageView(adapter, storageNode),
252
+ ),
245
253
  );
@@ -4,9 +4,11 @@ import type {
4
4
  StorageDeleteEntryEvent,
5
5
  StorageEventMap,
6
6
  StorageGetSnapshotEvent,
7
+ StorageImportEntriesEvent,
7
8
  StorageSetEntryEvent,
8
9
  } from '../shared/messaging';
9
10
  import type { StorageAdapter } from '../shared/types';
11
+ import { handleImportEntries } from './import';
10
12
  import { createStorageViews } from './storage-view';
11
13
  import { useStorageAgentTools } from './useStorageAgentTools';
12
14
 
@@ -31,7 +33,9 @@ export const useRozeniteStoragePlugin = ({
31
33
  }
32
34
 
33
35
  const pushSnapshot = async (viewId?: string) => {
34
- const selectedViews = viewId ? views.filter((view) => view.id === viewId) : views;
36
+ const selectedViews = viewId
37
+ ? views.filter((view) => view.id === viewId)
38
+ : views;
35
39
 
36
40
  for (const view of selectedViews) {
37
41
  try {
@@ -42,12 +46,15 @@ export const useRozeniteStoragePlugin = ({
42
46
  adapterName: view.adapterName,
43
47
  storageName: view.storageName,
44
48
  capabilities: view.capabilities,
49
+ blacklist: view.blacklist
50
+ ? { source: view.blacklist.source, flags: view.blacklist.flags }
51
+ : undefined,
45
52
  entries,
46
53
  });
47
54
  } catch (error) {
48
55
  console.warn(
49
56
  `[Rozenite] Storage Plugin: Failed to snapshot ${view.target.adapterId}/${view.target.storageId}.`,
50
- error
57
+ error,
51
58
  );
52
59
  }
53
60
  }
@@ -88,48 +95,51 @@ export const useRozeniteStoragePlugin = ({
88
95
  } catch (error) {
89
96
  console.warn(
90
97
  `[Rozenite] Storage Plugin: Failed to attach watcher for ${view.target.adapterId}/${view.target.storageId}.`,
91
- error
98
+ error,
92
99
  );
93
100
  }
94
- })
101
+ }),
95
102
  );
96
103
 
97
104
  const messageSubscriptions = [
98
- client.onMessage('set-entry', async ({ target, entry }: StorageSetEntryEvent) => {
99
- const view = views.find(
100
- (candidate) =>
101
- candidate.target.adapterId === target.adapterId &&
102
- candidate.target.storageId === target.storageId
103
- );
104
-
105
- if (!view) {
106
- console.warn(
107
- `[Rozenite] Storage Plugin: Storage target not found for ${target.adapterId}/${target.storageId}`
105
+ client.onMessage(
106
+ 'set-entry',
107
+ async ({ target, entry }: StorageSetEntryEvent) => {
108
+ const view = views.find(
109
+ (candidate) =>
110
+ candidate.target.adapterId === target.adapterId &&
111
+ candidate.target.storageId === target.storageId,
108
112
  );
109
- return;
110
- }
111
113
 
112
- try {
113
- await view.set(entry);
114
- } catch (error) {
115
- console.warn(
116
- `[Rozenite] Storage Plugin: Failed to set entry in ${target.adapterId}/${target.storageId}.`,
117
- error
118
- );
119
- }
120
- }),
114
+ if (!view) {
115
+ console.warn(
116
+ `[Rozenite] Storage Plugin: Storage target not found for ${target.adapterId}/${target.storageId}`,
117
+ );
118
+ return;
119
+ }
120
+
121
+ try {
122
+ await view.set(entry);
123
+ } catch (error) {
124
+ console.warn(
125
+ `[Rozenite] Storage Plugin: Failed to set entry in ${target.adapterId}/${target.storageId}.`,
126
+ error,
127
+ );
128
+ }
129
+ },
130
+ ),
121
131
  client.onMessage(
122
132
  'delete-entry',
123
133
  async ({ target, key }: StorageDeleteEntryEvent) => {
124
134
  const view = views.find(
125
135
  (candidate) =>
126
136
  candidate.target.adapterId === target.adapterId &&
127
- candidate.target.storageId === target.storageId
137
+ candidate.target.storageId === target.storageId,
128
138
  );
129
139
 
130
140
  if (!view) {
131
141
  console.warn(
132
- `[Rozenite] Storage Plugin: Storage target not found for ${target.adapterId}/${target.storageId}`
142
+ `[Rozenite] Storage Plugin: Storage target not found for ${target.adapterId}/${target.storageId}`,
133
143
  );
134
144
  return;
135
145
  }
@@ -139,19 +149,34 @@ export const useRozeniteStoragePlugin = ({
139
149
  } catch (error) {
140
150
  console.warn(
141
151
  `[Rozenite] Storage Plugin: Failed to delete entry in ${target.adapterId}/${target.storageId}.`,
142
- error
152
+ error,
143
153
  );
144
154
  }
145
- }
155
+ },
146
156
  ),
147
- client.onMessage('get-snapshot', async ({ target }: StorageGetSnapshotEvent) => {
148
- if (target === 'all') {
149
- await pushSnapshot();
150
- return;
151
- }
157
+ client.onMessage(
158
+ 'get-snapshot',
159
+ async ({ target }: StorageGetSnapshotEvent) => {
160
+ if (target === 'all') {
161
+ await pushSnapshot();
162
+ return;
163
+ }
152
164
 
153
- await pushSnapshot(`${target.adapterId}:${target.storageId}`);
154
- }),
165
+ await pushSnapshot(`${target.adapterId}:${target.storageId}`);
166
+ },
167
+ ),
168
+ client.onMessage(
169
+ 'import-entries',
170
+ async (event: StorageImportEntriesEvent) => {
171
+ await handleImportEntries(views, event, (out) => {
172
+ if (out.type === 'set-entry') {
173
+ client.send('set-entry', out);
174
+ } else {
175
+ client.send('import-result', out);
176
+ }
177
+ });
178
+ },
179
+ ),
155
180
  ];
156
181
 
157
182
  return () => {
@@ -0,0 +1,361 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ buildSnapshot,
4
+ computePreview,
5
+ parseSnapshot,
6
+ type StorageSnapshotV1,
7
+ } from '../snapshot';
8
+ import type {
9
+ StorageCapabilities,
10
+ StorageEntry,
11
+ StorageTarget,
12
+ } from '../types';
13
+
14
+ const validSnapshot = (
15
+ overrides: Partial<StorageSnapshotV1> = {},
16
+ ): StorageSnapshotV1 => ({
17
+ version: 1,
18
+ plugin: '@rozenite/storage-plugin',
19
+ createdAt: '2026-05-11T12:00:00.000Z',
20
+ storage: {
21
+ adapterId: 'mmkv',
22
+ storageId: 'user',
23
+ adapterName: 'MMKV',
24
+ storageName: 'user',
25
+ capabilities: { supportedTypes: ['string', 'number', 'boolean', 'buffer'] },
26
+ },
27
+ entries: [
28
+ { key: 'token', type: 'string', value: 'abc' },
29
+ { key: 'launchCount', type: 'number', value: 3 },
30
+ { key: 'seenOnboarding', type: 'boolean', value: true },
31
+ { key: 'blob', type: 'buffer', value: [1, 2, 255] },
32
+ ],
33
+ ...overrides,
34
+ });
35
+
36
+ describe('parseSnapshot', () => {
37
+ it('accepts a valid snapshot covering all entry types', () => {
38
+ const result = parseSnapshot(validSnapshot());
39
+ expect(result.ok).toBe(true);
40
+ if (result.ok) {
41
+ expect(result.snapshot).toEqual(validSnapshot());
42
+ }
43
+ });
44
+
45
+ it('accepts a snapshot with an empty entries array', () => {
46
+ const result = parseSnapshot(validSnapshot({ entries: [] }));
47
+ expect(result.ok).toBe(true);
48
+ });
49
+
50
+ it('ignores unknown top-level fields (forward-tolerant)', () => {
51
+ const input = { ...validSnapshot(), extraField: 'ignored' };
52
+ const result = parseSnapshot(input);
53
+ expect(result.ok).toBe(true);
54
+ if (result.ok) {
55
+ expect(result.snapshot).not.toHaveProperty('extraField');
56
+ }
57
+ });
58
+
59
+ describe('top-level rejections', () => {
60
+ it.each([
61
+ ['null', null],
62
+ ['array', []],
63
+ ['string', 'hi'],
64
+ ['number', 1],
65
+ ])('rejects non-object root (%s)', (_label, raw) => {
66
+ const result = parseSnapshot(raw);
67
+ expect(result.ok).toBe(false);
68
+ if (!result.ok) {
69
+ expect(result.error.path).toBe('$');
70
+ }
71
+ });
72
+
73
+ it('rejects when version is missing', () => {
74
+ const snapshot = validSnapshot() as Record<string, unknown>;
75
+ delete snapshot.version;
76
+ const result = parseSnapshot(snapshot);
77
+ expect(result.ok).toBe(false);
78
+ if (!result.ok) expect(result.error.path).toBe('version');
79
+ });
80
+
81
+ it.each([0, 2, '1', null])('rejects unsupported version: %s', (version) => {
82
+ const result = parseSnapshot({ ...validSnapshot(), version });
83
+ expect(result.ok).toBe(false);
84
+ if (!result.ok) {
85
+ expect(result.error.path).toBe('version');
86
+ expect(result.error.message).toMatch(/version/i);
87
+ }
88
+ });
89
+
90
+ it('rejects when plugin is not a string', () => {
91
+ const result = parseSnapshot({ ...validSnapshot(), plugin: 42 });
92
+ expect(result.ok).toBe(false);
93
+ if (!result.ok) expect(result.error.path).toBe('plugin');
94
+ });
95
+
96
+ it('rejects when createdAt is not a string', () => {
97
+ const result = parseSnapshot({ ...validSnapshot(), createdAt: 0 });
98
+ expect(result.ok).toBe(false);
99
+ if (!result.ok) expect(result.error.path).toBe('createdAt');
100
+ });
101
+
102
+ it('rejects when storage is missing', () => {
103
+ const snapshot = validSnapshot() as Record<string, unknown>;
104
+ delete snapshot.storage;
105
+ const result = parseSnapshot(snapshot);
106
+ expect(result.ok).toBe(false);
107
+ if (!result.ok) expect(result.error.path).toBe('storage');
108
+ });
109
+
110
+ it('rejects when entries is not an array', () => {
111
+ const result = parseSnapshot({ ...validSnapshot(), entries: 'nope' });
112
+ expect(result.ok).toBe(false);
113
+ if (!result.ok) expect(result.error.path).toBe('entries');
114
+ });
115
+
116
+ it('rejects when capabilities.supportedTypes has an invalid type', () => {
117
+ const snapshot = validSnapshot();
118
+ const input = {
119
+ ...snapshot,
120
+ storage: {
121
+ ...snapshot.storage,
122
+ capabilities: { supportedTypes: ['string', 'date'] },
123
+ },
124
+ };
125
+ const result = parseSnapshot(input);
126
+ expect(result.ok).toBe(false);
127
+ if (!result.ok) {
128
+ expect(result.error.path).toBe(
129
+ 'storage.capabilities.supportedTypes[1]',
130
+ );
131
+ }
132
+ });
133
+ });
134
+
135
+ describe('per-entry rejections', () => {
136
+ const withEntry = (entry: unknown) =>
137
+ parseSnapshot({ ...validSnapshot(), entries: [entry] });
138
+
139
+ it('rejects entry missing key', () => {
140
+ const result = withEntry({ type: 'string', value: 'x' });
141
+ expect(result.ok).toBe(false);
142
+ if (!result.ok) expect(result.error.path).toBe('entries[0].key');
143
+ });
144
+
145
+ it('rejects entry with unknown type', () => {
146
+ const result = withEntry({ key: 'k', type: 'date', value: 'x' });
147
+ expect(result.ok).toBe(false);
148
+ if (!result.ok) expect(result.error.path).toBe('entries[0].type');
149
+ });
150
+
151
+ it('rejects string type with non-string value', () => {
152
+ const result = withEntry({ key: 'k', type: 'string', value: 42 });
153
+ expect(result.ok).toBe(false);
154
+ if (!result.ok) expect(result.error.path).toBe('entries[0].value');
155
+ });
156
+
157
+ it('rejects number type with non-number value', () => {
158
+ const result = withEntry({ key: 'k', type: 'number', value: '42' });
159
+ expect(result.ok).toBe(false);
160
+ if (!result.ok) expect(result.error.path).toBe('entries[0].value');
161
+ });
162
+
163
+ it.each([NaN, Infinity, -Infinity])(
164
+ 'rejects number type with non-finite value (%s)',
165
+ (value) => {
166
+ const result = withEntry({ key: 'k', type: 'number', value });
167
+ expect(result.ok).toBe(false);
168
+ if (!result.ok) expect(result.error.path).toBe('entries[0].value');
169
+ },
170
+ );
171
+
172
+ it('rejects boolean type with non-boolean value', () => {
173
+ const result = withEntry({ key: 'k', type: 'boolean', value: 'true' });
174
+ expect(result.ok).toBe(false);
175
+ if (!result.ok) expect(result.error.path).toBe('entries[0].value');
176
+ });
177
+
178
+ it('rejects buffer type with non-array value', () => {
179
+ const result = withEntry({ key: 'k', type: 'buffer', value: 'data' });
180
+ expect(result.ok).toBe(false);
181
+ if (!result.ok) expect(result.error.path).toBe('entries[0].value');
182
+ });
183
+
184
+ it.each([
185
+ ['byte > 255', [1, 2, 256]],
186
+ ['byte < 0', [1, -1]],
187
+ ['non-integer byte', [1, 1.5]],
188
+ ['NaN byte', [1, NaN]],
189
+ ['non-number byte', [1, 'x']],
190
+ ])('rejects buffer with invalid byte (%s)', (_label, value) => {
191
+ const result = withEntry({ key: 'k', type: 'buffer', value });
192
+ expect(result.ok).toBe(false);
193
+ if (!result.ok) {
194
+ expect(result.error.path).toMatch(/^entries\[0\]\.value\[\d+\]$/);
195
+ }
196
+ });
197
+
198
+ it('produces a path-precise error for a nested failure', () => {
199
+ const result = parseSnapshot({
200
+ ...validSnapshot(),
201
+ entries: [
202
+ { key: 'a', type: 'string', value: 'ok' },
203
+ { key: 'b', type: 'string', value: 'ok' },
204
+ { key: 'c', type: 'string', value: 'ok' },
205
+ { key: 'd', type: 'number', value: 'wrong' },
206
+ ],
207
+ });
208
+ expect(result.ok).toBe(false);
209
+ if (!result.ok) expect(result.error.path).toBe('entries[3].value');
210
+ });
211
+ });
212
+ });
213
+
214
+ describe('buildSnapshot', () => {
215
+ it('produces a v1-shaped snapshot with the plugin id and an ISO createdAt', () => {
216
+ const target: StorageTarget = { adapterId: 'mmkv', storageId: 'user' };
217
+ const capabilities: StorageCapabilities = {
218
+ supportedTypes: ['string', 'number'],
219
+ };
220
+ const entries: StorageEntry[] = [
221
+ { key: 'token', type: 'string', value: 'abc' },
222
+ ];
223
+
224
+ const result = buildSnapshot({
225
+ target,
226
+ adapterName: 'MMKV',
227
+ storageName: 'user',
228
+ capabilities,
229
+ entries,
230
+ });
231
+
232
+ expect(result.version).toBe(1);
233
+ expect(result.plugin).toBe('@rozenite/storage-plugin');
234
+ expect(() => new Date(result.createdAt).toISOString()).not.toThrow();
235
+ expect(result.storage).toEqual({
236
+ adapterId: 'mmkv',
237
+ storageId: 'user',
238
+ adapterName: 'MMKV',
239
+ storageName: 'user',
240
+ capabilities,
241
+ });
242
+ expect(result.entries).toBe(entries);
243
+ });
244
+ });
245
+
246
+ describe('computePreview', () => {
247
+ const target: StorageTarget = { adapterId: 'mmkv', storageId: 'user' };
248
+ const allTypes: StorageCapabilities = {
249
+ supportedTypes: ['string', 'number', 'boolean', 'buffer'],
250
+ };
251
+ const noBlacklist = () => false;
252
+
253
+ it('splits entries into new vs overwrite', () => {
254
+ const snapshot = validSnapshot({
255
+ entries: [
256
+ { key: 'existing', type: 'string', value: 'a' },
257
+ { key: 'fresh', type: 'string', value: 'b' },
258
+ ],
259
+ });
260
+ const preview = computePreview(snapshot, {
261
+ target,
262
+ capabilities: allTypes,
263
+ entryKeys: new Set(['existing']),
264
+ isBlacklisted: noBlacklist,
265
+ });
266
+ expect(preview.newKeys).toEqual(['fresh']);
267
+ expect(preview.overwriteKeys).toEqual(['existing']);
268
+ });
269
+
270
+ it('surfaces blacklisted keys in skippedKeys', () => {
271
+ const snapshot = validSnapshot({
272
+ entries: [
273
+ { key: 'visible', type: 'string', value: 'a' },
274
+ { key: '__internal', type: 'string', value: 'b' },
275
+ ],
276
+ });
277
+ const preview = computePreview(snapshot, {
278
+ target,
279
+ capabilities: allTypes,
280
+ entryKeys: new Set(),
281
+ isBlacklisted: (key) => key.startsWith('__'),
282
+ });
283
+ expect(preview.skippedKeys).toEqual([
284
+ { key: '__internal', reason: 'blacklist' },
285
+ ]);
286
+ expect(preview.newKeys).toEqual(['visible']);
287
+ });
288
+
289
+ it('surfaces unsupported types', () => {
290
+ const stringOnly: StorageCapabilities = { supportedTypes: ['string'] };
291
+ const snapshot = validSnapshot({
292
+ entries: [
293
+ { key: 'ok', type: 'string', value: 'a' },
294
+ { key: 'bad', type: 'buffer', value: [1, 2] },
295
+ ],
296
+ });
297
+ const preview = computePreview(snapshot, {
298
+ target,
299
+ capabilities: stringOnly,
300
+ entryKeys: new Set(),
301
+ isBlacklisted: noBlacklist,
302
+ });
303
+ expect(preview.unsupportedTypes).toEqual([{ key: 'bad', type: 'buffer' }]);
304
+ expect(preview.newKeys).toEqual(['ok']);
305
+ });
306
+
307
+ it('prioritises unsupportedTypes over blacklist over overwrite/new', () => {
308
+ const stringOnly: StorageCapabilities = { supportedTypes: ['string'] };
309
+ const snapshot = validSnapshot({
310
+ entries: [
311
+ { key: '__blocked', type: 'buffer', value: [1] }, // unsupported wins
312
+ { key: '__skipped', type: 'string', value: 'a' }, // blacklist
313
+ { key: 'existing', type: 'string', value: 'b' },
314
+ { key: 'fresh', type: 'string', value: 'c' },
315
+ ],
316
+ });
317
+ const preview = computePreview(snapshot, {
318
+ target,
319
+ capabilities: stringOnly,
320
+ entryKeys: new Set(['existing']),
321
+ isBlacklisted: (key) => key.startsWith('__'),
322
+ });
323
+ expect(preview.unsupportedTypes).toEqual([
324
+ { key: '__blocked', type: 'buffer' },
325
+ ]);
326
+ expect(preview.skippedKeys).toEqual([
327
+ { key: '__skipped', reason: 'blacklist' },
328
+ ]);
329
+ expect(preview.overwriteKeys).toEqual(['existing']);
330
+ expect(preview.newKeys).toEqual(['fresh']);
331
+ });
332
+
333
+ it('flags metadataMismatch when adapter or storage IDs differ', () => {
334
+ const snapshot = validSnapshot({
335
+ storage: {
336
+ adapterId: 'async-storage',
337
+ storageId: 'default',
338
+ adapterName: 'AsyncStorage',
339
+ storageName: 'default',
340
+ capabilities: allTypes,
341
+ },
342
+ });
343
+ const preview = computePreview(snapshot, {
344
+ target,
345
+ capabilities: allTypes,
346
+ entryKeys: new Set(),
347
+ isBlacklisted: noBlacklist,
348
+ });
349
+ expect(preview.metadataMismatch).toBe(true);
350
+ });
351
+
352
+ it('reports metadataMismatch=false for matching target', () => {
353
+ const preview = computePreview(validSnapshot(), {
354
+ target,
355
+ capabilities: allTypes,
356
+ entryKeys: new Set(),
357
+ isBlacklisted: noBlacklist,
358
+ });
359
+ expect(preview.metadataMismatch).toBe(false);
360
+ });
361
+ });
@@ -1,11 +1,17 @@
1
1
  import type { StorageCapabilities, StorageEntry, StorageTarget } from './types';
2
2
 
3
+ export type SerializedBlacklist = {
4
+ source: string;
5
+ flags: string;
6
+ };
7
+
3
8
  export type StorageSnapshotEvent = {
4
9
  type: 'snapshot';
5
10
  target: StorageTarget;
6
11
  adapterName: string;
7
12
  storageName: string;
8
13
  capabilities: StorageCapabilities;
14
+ blacklist?: SerializedBlacklist;
9
15
  entries: StorageEntry[];
10
16
  };
11
17
 
@@ -26,11 +32,29 @@ export type StorageGetSnapshotEvent = {
26
32
  target: StorageTarget | 'all';
27
33
  };
28
34
 
35
+ export type StorageImportEntriesEvent = {
36
+ type: 'import-entries';
37
+ target: StorageTarget;
38
+ entries: StorageEntry[];
39
+ };
40
+
41
+ export type StorageImportResultEvent = {
42
+ type: 'import-result';
43
+ target: StorageTarget;
44
+ ok: boolean;
45
+ written: number;
46
+ total: number;
47
+ failedKey?: string;
48
+ error?: string;
49
+ };
50
+
29
51
  export type StorageEvent =
30
52
  | StorageSnapshotEvent
31
53
  | StorageSetEntryEvent
32
54
  | StorageDeleteEntryEvent
33
- | StorageGetSnapshotEvent;
55
+ | StorageGetSnapshotEvent
56
+ | StorageImportEntriesEvent
57
+ | StorageImportResultEvent;
34
58
 
35
59
  export type StorageEventMap = {
36
60
  [K in StorageEvent['type']]: Extract<StorageEvent, { type: K }>;