@rozenite/storage-plugin 1.8.0 → 1.9.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.
@@ -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 }>;