@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
@@ -0,0 +1,276 @@
1
+ import {
2
+ DEFAULT_SUPPORTED_TYPES,
3
+ supportsType,
4
+ type StorageCapabilities,
5
+ type StorageEntry,
6
+ type StorageEntryType,
7
+ type StorageTarget,
8
+ } from './types';
9
+
10
+ const PLUGIN_ID = '@rozenite/storage-plugin';
11
+ const SUPPORTED_VERSION = 1;
12
+
13
+ export type StorageSnapshotV1 = {
14
+ version: 1;
15
+ plugin: string;
16
+ createdAt: string;
17
+ storage: {
18
+ adapterId: string;
19
+ storageId: string;
20
+ adapterName: string;
21
+ storageName: string;
22
+ capabilities: StorageCapabilities;
23
+ };
24
+ entries: StorageEntry[];
25
+ };
26
+
27
+ export type ParseError = { path: string; message: string };
28
+
29
+ export type ParseResult =
30
+ | { ok: true; snapshot: StorageSnapshotV1 }
31
+ | { ok: false; error: ParseError };
32
+
33
+ class ParseException extends Error {
34
+ constructor(
35
+ public path: string,
36
+ message: string,
37
+ ) {
38
+ super(message);
39
+ }
40
+ }
41
+
42
+ const describe = (value: unknown): string => {
43
+ if (value === null) return 'null';
44
+ if (Array.isArray(value)) return 'array';
45
+ return typeof value;
46
+ };
47
+
48
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
49
+ typeof value === 'object' && value !== null && !Array.isArray(value);
50
+
51
+ const expectObject = (
52
+ value: unknown,
53
+ path: string,
54
+ ): Record<string, unknown> => {
55
+ if (!isPlainObject(value)) {
56
+ throw new ParseException(path, `Expected object, got ${describe(value)}`);
57
+ }
58
+ return value;
59
+ };
60
+
61
+ const expectString = (value: unknown, path: string): string => {
62
+ if (typeof value !== 'string') {
63
+ throw new ParseException(path, `Expected string, got ${describe(value)}`);
64
+ }
65
+ return value;
66
+ };
67
+
68
+ const expectArray = (value: unknown, path: string): unknown[] => {
69
+ if (!Array.isArray(value)) {
70
+ throw new ParseException(path, `Expected array, got ${describe(value)}`);
71
+ }
72
+ return value;
73
+ };
74
+
75
+ const expectEntryType = (value: unknown, path: string): StorageEntryType => {
76
+ if (
77
+ typeof value !== 'string' ||
78
+ !DEFAULT_SUPPORTED_TYPES.includes(value as StorageEntryType)
79
+ ) {
80
+ throw new ParseException(
81
+ path,
82
+ `Expected one of ${DEFAULT_SUPPORTED_TYPES.join(', ')}, got ${describe(value)}`,
83
+ );
84
+ }
85
+ return value as StorageEntryType;
86
+ };
87
+
88
+ const parseCapabilities = (raw: unknown, path: string): StorageCapabilities => {
89
+ const obj = expectObject(raw, path);
90
+ const supported = expectArray(obj.supportedTypes, `${path}.supportedTypes`);
91
+ const supportedTypes = supported.map((type, index) =>
92
+ expectEntryType(type, `${path}.supportedTypes[${index}]`),
93
+ );
94
+ return { supportedTypes };
95
+ };
96
+
97
+ const parseStorageMeta = (raw: unknown): StorageSnapshotV1['storage'] => {
98
+ const obj = expectObject(raw, 'storage');
99
+ return {
100
+ adapterId: expectString(obj.adapterId, 'storage.adapterId'),
101
+ storageId: expectString(obj.storageId, 'storage.storageId'),
102
+ adapterName: expectString(obj.adapterName, 'storage.adapterName'),
103
+ storageName: expectString(obj.storageName, 'storage.storageName'),
104
+ capabilities: parseCapabilities(obj.capabilities, 'storage.capabilities'),
105
+ };
106
+ };
107
+
108
+ const parseEntry = (raw: unknown, path: string): StorageEntry => {
109
+ const obj = expectObject(raw, path);
110
+ const key = expectString(obj.key, `${path}.key`);
111
+ const type = expectEntryType(obj.type, `${path}.type`);
112
+ const valuePath = `${path}.value`;
113
+
114
+ switch (type) {
115
+ case 'string': {
116
+ const value = obj.value;
117
+ if (typeof value !== 'string') {
118
+ throw new ParseException(
119
+ valuePath,
120
+ `Expected string for type "string", got ${describe(value)}`,
121
+ );
122
+ }
123
+ return { key, type: 'string', value };
124
+ }
125
+ case 'number': {
126
+ const value = obj.value;
127
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
128
+ throw new ParseException(
129
+ valuePath,
130
+ `Expected finite number for type "number", got ${describe(value)}`,
131
+ );
132
+ }
133
+ return { key, type: 'number', value };
134
+ }
135
+ case 'boolean': {
136
+ const value = obj.value;
137
+ if (typeof value !== 'boolean') {
138
+ throw new ParseException(
139
+ valuePath,
140
+ `Expected boolean for type "boolean", got ${describe(value)}`,
141
+ );
142
+ }
143
+ return { key, type: 'boolean', value };
144
+ }
145
+ case 'buffer': {
146
+ const value = obj.value;
147
+ if (!Array.isArray(value)) {
148
+ throw new ParseException(
149
+ valuePath,
150
+ `Expected number[] for type "buffer", got ${describe(value)}`,
151
+ );
152
+ }
153
+ const bytes = value.map((byte, index) => {
154
+ if (
155
+ typeof byte !== 'number' ||
156
+ !Number.isInteger(byte) ||
157
+ byte < 0 ||
158
+ byte > 255
159
+ ) {
160
+ throw new ParseException(
161
+ `${valuePath}[${index}]`,
162
+ `Expected uint8 (integer 0-255), got ${describe(byte)}`,
163
+ );
164
+ }
165
+ return byte;
166
+ });
167
+ return { key, type: 'buffer', value: bytes };
168
+ }
169
+ }
170
+ };
171
+
172
+ export const parseSnapshot = (raw: unknown): ParseResult => {
173
+ try {
174
+ const root = expectObject(raw, '$');
175
+ const version = root.version;
176
+ if (version !== SUPPORTED_VERSION) {
177
+ throw new ParseException(
178
+ 'version',
179
+ `Unsupported snapshot version: ${describe(version)} (this build supports version ${SUPPORTED_VERSION})`,
180
+ );
181
+ }
182
+ const plugin = expectString(root.plugin, 'plugin');
183
+ const createdAt = expectString(root.createdAt, 'createdAt');
184
+ const storage = parseStorageMeta(root.storage);
185
+ const rawEntries = expectArray(root.entries, 'entries');
186
+ const entries = rawEntries.map((entry, index) =>
187
+ parseEntry(entry, `entries[${index}]`),
188
+ );
189
+ return {
190
+ ok: true,
191
+ snapshot: {
192
+ version: SUPPORTED_VERSION,
193
+ plugin,
194
+ createdAt,
195
+ storage,
196
+ entries,
197
+ },
198
+ };
199
+ } catch (error) {
200
+ if (error instanceof ParseException) {
201
+ return { ok: false, error: { path: error.path, message: error.message } };
202
+ }
203
+ throw error;
204
+ }
205
+ };
206
+
207
+ export const buildSnapshot = (args: {
208
+ target: StorageTarget;
209
+ adapterName: string;
210
+ storageName: string;
211
+ capabilities: StorageCapabilities;
212
+ entries: StorageEntry[];
213
+ }): StorageSnapshotV1 => ({
214
+ version: 1,
215
+ plugin: PLUGIN_ID,
216
+ createdAt: new Date().toISOString(),
217
+ storage: {
218
+ adapterId: args.target.adapterId,
219
+ storageId: args.target.storageId,
220
+ adapterName: args.adapterName,
221
+ storageName: args.storageName,
222
+ capabilities: args.capabilities,
223
+ },
224
+ entries: args.entries,
225
+ });
226
+
227
+ export type ImportPreview = {
228
+ newKeys: string[];
229
+ overwriteKeys: string[];
230
+ skippedKeys: { key: string; reason: 'blacklist' }[];
231
+ unsupportedTypes: { key: string; type: StorageEntryType }[];
232
+ metadataMismatch: boolean;
233
+ };
234
+
235
+ export const computePreview = (
236
+ snapshot: StorageSnapshotV1,
237
+ current: {
238
+ target: StorageTarget;
239
+ capabilities: StorageCapabilities;
240
+ entryKeys: Set<string>;
241
+ isBlacklisted: (key: string) => boolean;
242
+ },
243
+ ): ImportPreview => {
244
+ const newKeys: string[] = [];
245
+ const overwriteKeys: string[] = [];
246
+ const skippedKeys: { key: string; reason: 'blacklist' }[] = [];
247
+ const unsupportedTypes: { key: string; type: StorageEntryType }[] = [];
248
+
249
+ for (const entry of snapshot.entries) {
250
+ if (!supportsType(current.capabilities, entry.type)) {
251
+ unsupportedTypes.push({ key: entry.key, type: entry.type });
252
+ continue;
253
+ }
254
+ if (current.isBlacklisted(entry.key)) {
255
+ skippedKeys.push({ key: entry.key, reason: 'blacklist' });
256
+ continue;
257
+ }
258
+ if (current.entryKeys.has(entry.key)) {
259
+ overwriteKeys.push(entry.key);
260
+ } else {
261
+ newKeys.push(entry.key);
262
+ }
263
+ }
264
+
265
+ const metadataMismatch =
266
+ snapshot.storage.adapterId !== current.target.adapterId ||
267
+ snapshot.storage.storageId !== current.target.storageId;
268
+
269
+ return {
270
+ newKeys,
271
+ overwriteKeys,
272
+ skippedKeys,
273
+ unsupportedTypes,
274
+ metadataMismatch,
275
+ };
276
+ };
@@ -37,6 +37,7 @@ export type StorageNode = {
37
37
  storage: SyncStorage | AsyncStorage;
38
38
  capabilities: StorageCapabilities;
39
39
  blacklist?: RegExp;
40
+ shouldFilterKey?: (key: string) => boolean;
40
41
  };
41
42
 
42
43
  export type StorageAdapter = {
@@ -0,0 +1,199 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ initialState,
4
+ reduce,
5
+ validate,
6
+ type EditorState,
7
+ } from '../binary-value-editor-state';
8
+
9
+ const hexState = (bytes?: number[]): EditorState =>
10
+ initialState({ initialBytes: bytes, mode: 'hex' });
11
+
12
+ describe('initialState', () => {
13
+ it('defaults to hex mode and empty text when no initialBytes', () => {
14
+ expect(initialState({})).toEqual({
15
+ mode: 'hex',
16
+ text: '',
17
+ bytes: null,
18
+ error: null,
19
+ });
20
+ });
21
+
22
+ it('encodes initial bytes as grouped hex when mode is hex', () => {
23
+ expect(initialState({ initialBytes: [0x89, 0x50, 0x4e, 0x47] })).toEqual({
24
+ mode: 'hex',
25
+ text: '89 50 4E 47',
26
+ bytes: [0x89, 0x50, 0x4e, 0x47],
27
+ error: null,
28
+ });
29
+ });
30
+
31
+ it('encodes initial bytes as base64 when mode is base64', () => {
32
+ expect(
33
+ initialState({ initialBytes: [0x48, 0x69], mode: 'base64' }),
34
+ ).toEqual({
35
+ mode: 'base64',
36
+ text: 'SGk=',
37
+ bytes: [0x48, 0x69],
38
+ error: null,
39
+ });
40
+ });
41
+
42
+ it('treats empty initial bytes the same as no bytes', () => {
43
+ expect(initialState({ initialBytes: [] })).toEqual({
44
+ mode: 'hex',
45
+ text: '',
46
+ bytes: null,
47
+ error: null,
48
+ });
49
+ });
50
+ });
51
+
52
+ describe('reduce: set-text', () => {
53
+ it('parses valid hex into bytes', () => {
54
+ const next = reduce(hexState(), { type: 'set-text', text: '89 50 4E 47' });
55
+ expect(next).toEqual({
56
+ mode: 'hex',
57
+ text: '89 50 4E 47',
58
+ bytes: [0x89, 0x50, 0x4e, 0x47],
59
+ error: null,
60
+ });
61
+ });
62
+
63
+ it('keeps invalid hex text but clears bytes and surfaces the parse error', () => {
64
+ const next = reduce(hexState(), { type: 'set-text', text: 'gg' });
65
+ expect(next).toEqual({
66
+ mode: 'hex',
67
+ text: 'gg',
68
+ bytes: null,
69
+ error: 'Hex input contains invalid characters.',
70
+ });
71
+ });
72
+
73
+ it('keeps trailing-nibble text but reports incomplete bytes', () => {
74
+ const next = reduce(hexState(), { type: 'set-text', text: '89 5' });
75
+ expect(next).toEqual({
76
+ mode: 'hex',
77
+ text: '89 5',
78
+ bytes: null,
79
+ error: 'Hex input must contain complete bytes.',
80
+ });
81
+ });
82
+
83
+ it('surfaces the empty-input error for cleared text', () => {
84
+ const next = reduce(hexState([0x89]), { type: 'set-text', text: '' });
85
+ expect(next).toEqual({
86
+ mode: 'hex',
87
+ text: '',
88
+ bytes: null,
89
+ error: 'Enter at least one byte.',
90
+ });
91
+ });
92
+
93
+ it('does not rewrite text (paste filter is responsible for that)', () => {
94
+ // Even though "DEAD" would canonicalise to "DE AD", set-text must
95
+ // preserve the user's exact text. Only normalize-paste rewrites it.
96
+ const next = reduce(hexState(), { type: 'set-text', text: 'DEAD' });
97
+ expect(next.text).toBe('DEAD');
98
+ expect(next.bytes).toEqual([0xde, 0xad]);
99
+ });
100
+ });
101
+
102
+ describe('reduce: normalize-paste', () => {
103
+ it('rewrites pasted hexdump into canonical grouped hex', () => {
104
+ const pasted =
105
+ '00000000 89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52 |.PNG........IHDR|';
106
+ const next = reduce(hexState(), { type: 'normalize-paste', text: pasted });
107
+ expect(next.text).toBe('89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52');
108
+ expect(next.bytes).toEqual([
109
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
110
+ 0x49, 0x48, 0x44, 0x52,
111
+ ]);
112
+ expect(next.error).toBeNull();
113
+ });
114
+
115
+ it('rewrites pasted base64 into canonical base64', () => {
116
+ const state = initialState({ mode: 'base64' });
117
+ const next = reduce(state, {
118
+ type: 'normalize-paste',
119
+ text: ' SGVs\nbG8= ',
120
+ });
121
+ expect(next.text).toBe('SGVsbG8=');
122
+ expect(next.bytes).toEqual([0x48, 0x65, 0x6c, 0x6c, 0x6f]);
123
+ });
124
+
125
+ it('keeps the raw pasted text and reports the error when invalid', () => {
126
+ const next = reduce(hexState(), {
127
+ type: 'normalize-paste',
128
+ text: 'oh no',
129
+ });
130
+ expect(next.text).toBe('oh no');
131
+ expect(next.bytes).toBeNull();
132
+ expect(next.error).toBe('Hex input contains invalid characters.');
133
+ });
134
+ });
135
+
136
+ describe('reduce: switch-mode', () => {
137
+ it('converts valid bytes in place when switching hex -> base64', () => {
138
+ const start = reduce(hexState(), { type: 'set-text', text: '48 69' });
139
+ const next = reduce(start, { type: 'switch-mode', mode: 'base64' });
140
+ expect(next).toEqual({
141
+ mode: 'base64',
142
+ text: 'SGk=',
143
+ bytes: [0x48, 0x69],
144
+ error: null,
145
+ });
146
+ });
147
+
148
+ it('converts valid bytes in place when switching base64 -> hex', () => {
149
+ const start = reduce(initialState({ mode: 'base64' }), {
150
+ type: 'set-text',
151
+ text: 'SGk=',
152
+ });
153
+ const next = reduce(start, { type: 'switch-mode', mode: 'hex' });
154
+ expect(next).toEqual({
155
+ mode: 'hex',
156
+ text: '48 69',
157
+ bytes: [0x48, 0x69],
158
+ error: null,
159
+ });
160
+ });
161
+
162
+ it('clears text and error when current input is invalid', () => {
163
+ const invalid = reduce(hexState(), { type: 'set-text', text: '89 5' });
164
+ const next = reduce(invalid, { type: 'switch-mode', mode: 'base64' });
165
+ expect(next).toEqual({
166
+ mode: 'base64',
167
+ text: '',
168
+ bytes: null,
169
+ error: null,
170
+ });
171
+ });
172
+
173
+ it('is a no-op when the mode is unchanged', () => {
174
+ const state = reduce(hexState(), { type: 'set-text', text: '89 50' });
175
+ expect(reduce(state, { type: 'switch-mode', mode: 'hex' })).toBe(state);
176
+ });
177
+ });
178
+
179
+ describe('validate', () => {
180
+ it('passes when bytes are valid and non-empty', () => {
181
+ const state = reduce(hexState(), { type: 'set-text', text: '89 50' });
182
+ expect(validate(state)).toEqual({ ok: true, bytes: [0x89, 0x50] });
183
+ });
184
+
185
+ it('fails with the parse error when input is invalid', () => {
186
+ const state = reduce(hexState(), { type: 'set-text', text: 'gg' });
187
+ expect(validate(state)).toEqual({
188
+ ok: false,
189
+ reason: 'Hex input contains invalid characters.',
190
+ });
191
+ });
192
+
193
+ it('fails with the empty-input message when nothing is entered', () => {
194
+ expect(validate(hexState())).toEqual({
195
+ ok: false,
196
+ reason: 'Enter at least one byte.',
197
+ });
198
+ });
199
+ });