@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.
- package/CHANGELOG.md +34 -0
- package/README.md +23 -0
- package/dist/devtools/assets/panel-Bm-SWF7d.js +33 -0
- package/dist/devtools/assets/panel-DIqI4WSp.css +1 -0
- package/dist/devtools/panel.html +2 -2
- package/dist/react-native/chunks/index.require.cjs +1 -1
- package/dist/react-native/chunks/index.require.js +43 -57
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.cjs +1 -1
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.js +253 -191
- package/dist/react-native/index.d.ts +29 -6
- package/dist/rozenite.json +1 -1
- package/dist/sdk/index.d.ts +1 -0
- package/package.json +9 -6
- package/src/react-native/__tests__/import.test.ts +182 -0
- package/src/react-native/adapters/__tests__/mmkv.test.ts +436 -0
- package/src/react-native/adapters/mmkv.ts +65 -39
- package/src/react-native/import.ts +67 -0
- package/src/react-native/storage-view.ts +16 -8
- package/src/react-native/useRozeniteStoragePlugin.ts +61 -36
- package/src/shared/__tests__/snapshot.test.ts +361 -0
- package/src/shared/messaging.ts +25 -1
- package/src/shared/snapshot.ts +276 -0
- package/src/shared/types.ts +1 -0
- package/src/ui/__tests__/binary-value-editor-state.test.ts +199 -0
- package/src/ui/__tests__/binary.test.ts +251 -0
- package/src/ui/__tests__/type-conversion.test.ts +123 -0
- package/src/ui/add-entry-dialog.tsx +83 -173
- package/src/ui/binary-value-editor-state.ts +125 -0
- package/src/ui/binary-value-editor.tsx +168 -0
- package/src/ui/binary.ts +123 -0
- package/src/ui/edit-entry-dialog.tsx +64 -161
- package/src/ui/editable-table.tsx +12 -10
- package/src/ui/editor-switcher.tsx +62 -0
- package/src/ui/entry-detail-dialog.tsx +14 -6
- package/src/ui/import-dialog.tsx +261 -0
- package/src/ui/panel.tsx +257 -57
- package/src/ui/type-conversion.ts +105 -0
- package/src/ui/typed-value-editor.tsx +96 -0
- package/src/ui/utils.ts +30 -0
- package/dist/devtools/assets/panel-DMhXYHH4.css +0 -1
- package/dist/devtools/assets/panel-eGOuOVos.js +0 -22
- 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) =>
|
|
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
|
|
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(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
candidate
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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(
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
157
|
+
client.onMessage(
|
|
158
|
+
'get-snapshot',
|
|
159
|
+
async ({ target }: StorageGetSnapshotEvent) => {
|
|
160
|
+
if (target === 'all') {
|
|
161
|
+
await pushSnapshot();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
152
164
|
|
|
153
|
-
|
|
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
|
+
});
|
package/src/shared/messaging.ts
CHANGED
|
@@ -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 }>;
|