@rozenite/storage-plugin 1.13.0 → 2.1.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 +46 -0
- package/README.md +6 -1
- package/dist/devtools/assets/panel-BlDRXgVW.js +32 -0
- package/dist/devtools/assets/panel-DBweZnW5.css +1 -0
- package/dist/devtools/panel.html +2 -2
- package/dist/react-native/chunks/async-storage.require.cjs +1 -1
- package/dist/react-native/chunks/async-storage.require.js +8 -7
- package/dist/react-native/chunks/index.require.cjs +1 -1
- package/dist/react-native/chunks/index.require.js +70 -66
- package/dist/react-native/chunks/secure-storage.require.cjs +1 -1
- package/dist/react-native/chunks/secure-storage.require.js +17 -13
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.cjs +1 -1
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.js +863 -344
- package/dist/react-native/index.d.ts +166 -18
- package/dist/rozenite.json +1 -1
- package/dist/sdk/index.cjs +1 -1
- package/dist/sdk/index.d.ts +13 -5
- package/dist/sdk/index.js +21 -12
- package/package.json +35 -37
- package/react-native.ts +12 -22
- package/rozenite.config.ts +74 -0
- package/sdk.ts +1 -5
- package/src/react-native/__tests__/entry-preview-pagination.test.ts +234 -0
- package/src/react-native/__tests__/export-snapshot.test.ts +138 -0
- package/src/react-native/__tests__/full-entry-request.test.ts +143 -0
- package/src/react-native/__tests__/import.test.ts +174 -11
- package/src/react-native/__tests__/storage-discovery.test.ts +144 -0
- package/src/react-native/__tests__/storage-view.test.ts +172 -0
- package/src/react-native/__tests__/stress-verification.test.ts +140 -0
- package/src/react-native/__tests__/use-storage-agent-tools.test.ts +221 -0
- package/src/react-native/adapters/__tests__/mmkv.test.ts +20 -30
- package/src/react-native/adapters/async-storage.ts +8 -9
- package/src/react-native/adapters/index.ts +2 -8
- package/src/react-native/adapters/mmkv.ts +12 -24
- package/src/react-native/adapters/secure-storage.ts +4 -0
- package/src/react-native/entry-preview-pagination.ts +261 -0
- package/src/react-native/export-snapshot.ts +88 -0
- package/src/react-native/full-entry-request.ts +91 -0
- package/src/react-native/import.ts +179 -16
- package/src/react-native/storage-discovery.ts +92 -0
- package/src/react-native/storage-view.ts +47 -109
- package/src/react-native/useRozeniteStoragePlugin.ts +153 -129
- package/src/react-native/useStorageAgentTools.ts +196 -91
- package/src/shared/__tests__/entry-preview.test.ts +99 -0
- package/src/shared/__tests__/snapshot.test.ts +31 -32
- package/src/shared/agent-tools.ts +33 -38
- package/src/shared/entry-preview.ts +54 -0
- package/src/shared/messaging.ts +140 -21
- package/src/shared/snapshot.ts +12 -18
- package/src/shared/types.ts +21 -4
- package/src/ui/__tests__/binary-value-editor-state.test.ts +16 -13
- package/src/ui/__tests__/binary.test.ts +31 -33
- package/src/ui/__tests__/large-value-viewer-state.test.ts +21 -0
- package/src/ui/__tests__/large-value-viewer.test.tsx +54 -0
- package/src/ui/__tests__/panel.test.tsx +382 -0
- package/src/ui/__tests__/storage-groups.test.ts +88 -0
- package/src/ui/__tests__/type-conversion.test.ts +8 -24
- package/src/ui/__tests__/use-storage-entries.test.tsx +337 -0
- package/src/ui/add-entry-dialog.tsx +66 -117
- package/src/ui/binary-value-editor-state.ts +16 -25
- package/src/ui/binary-value-editor.tsx +59 -47
- package/src/ui/binary.ts +54 -15
- package/src/ui/edit-entry-dialog.tsx +68 -104
- package/src/ui/editor-switcher.tsx +9 -16
- package/src/ui/entry-detail-dialog.tsx +62 -181
- package/src/ui/format-value.tsx +25 -0
- package/src/ui/globals.css +1 -123
- package/src/ui/import-dialog.tsx +72 -109
- package/src/ui/large-value-viewer-state.ts +25 -0
- package/src/ui/large-value-viewer.tsx +65 -0
- package/src/ui/panel.tsx +649 -509
- package/src/ui/query-client.tsx +23 -0
- package/src/ui/storage-groups.ts +66 -0
- package/src/ui/type-conversion.ts +2 -5
- package/src/ui/typed-value-editor.tsx +15 -20
- package/src/ui/use-storage-entries.ts +192 -0
- package/src/ui/utils.ts +1 -4
- package/tsconfig.json +5 -1
- package/dist/devtools/assets/panel-Bm-SWF7d.js +0 -33
- package/dist/devtools/assets/panel-DIqI4WSp.css +0 -1
- package/postcss.config.js +0 -6
- package/src/ui/confirm-dialog.tsx +0 -100
- package/src/ui/editable-table.tsx +0 -300
- package/tailwind.config.ts +0 -94
|
@@ -1,16 +1,19 @@
|
|
|
1
|
-
import { useCallback } from 'react';
|
|
2
1
|
import { useRozenitePluginAgentTool } from '@rozenite/agent-bridge';
|
|
2
|
+
import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT } from '@rozenite/agent-shared';
|
|
3
3
|
import {
|
|
4
4
|
STORAGE_AGENT_PLUGIN_ID,
|
|
5
5
|
storageToolDefinitions,
|
|
6
|
+
type StorageListEntriesArgs,
|
|
6
7
|
} from '../shared/agent-tools';
|
|
7
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
StorageEntry,
|
|
10
|
+
StorageEntryType,
|
|
11
|
+
StorageEntryValue,
|
|
12
|
+
StorageTarget,
|
|
13
|
+
} from '../shared/types';
|
|
8
14
|
import type { StorageView } from './storage-view';
|
|
9
15
|
|
|
10
|
-
const parseValueForType = (
|
|
11
|
-
type: StorageEntryType,
|
|
12
|
-
value: unknown
|
|
13
|
-
): StorageEntryValue => {
|
|
16
|
+
const parseValueForType = (type: StorageEntryType, value: unknown): StorageEntryValue => {
|
|
14
17
|
if (type === 'string') {
|
|
15
18
|
if (typeof value !== 'string') {
|
|
16
19
|
throw new Error('Expected string value for type=string');
|
|
@@ -39,118 +42,220 @@ const parseValueForType = (
|
|
|
39
42
|
return value as number[];
|
|
40
43
|
};
|
|
41
44
|
|
|
42
|
-
const formatViewRef = (view: StorageView) =>
|
|
43
|
-
`${view.target.adapterId}/${view.target.storageId}`;
|
|
45
|
+
const formatViewRef = (view: StorageView) => `${view.target.adapterId}/${view.target.storageId}`;
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
(adapterId?: string, storageId?: string) => {
|
|
48
|
-
if (views.length === 0) {
|
|
49
|
-
throw new Error('No storages are registered.');
|
|
50
|
-
}
|
|
47
|
+
const isSameTarget = (left: StorageTarget, right: StorageTarget) =>
|
|
48
|
+
left.adapterId === right.adapterId && left.storageId === right.storageId;
|
|
51
49
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
);
|
|
50
|
+
type ListEntriesCursor = {
|
|
51
|
+
version: 1;
|
|
52
|
+
target: StorageTarget;
|
|
53
|
+
offset: number;
|
|
54
|
+
};
|
|
58
55
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
adapterId && `adapterId="${adapterId}"`,
|
|
62
|
-
storageId && `storageId="${storageId}"`,
|
|
63
|
-
]
|
|
64
|
-
.filter(Boolean)
|
|
65
|
-
.join(', ');
|
|
66
|
-
throw new Error(
|
|
67
|
-
`No storage matched ${qualifier}. Available: ${views.map(formatViewRef).join(', ')}`
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return selected;
|
|
72
|
-
}
|
|
56
|
+
const encodeCursor = (cursor: ListEntriesCursor) =>
|
|
57
|
+
btoa(encodeURIComponent(JSON.stringify(cursor)));
|
|
73
58
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
59
|
+
const decodeCursor = (value: string): ListEntriesCursor => {
|
|
60
|
+
try {
|
|
61
|
+
const cursor: unknown = JSON.parse(decodeURIComponent(atob(value)));
|
|
62
|
+
if (
|
|
63
|
+
typeof cursor !== 'object' ||
|
|
64
|
+
cursor == null ||
|
|
65
|
+
Array.isArray(cursor) ||
|
|
66
|
+
(cursor as { version?: unknown }).version !== 1 ||
|
|
67
|
+
typeof (cursor as { target?: { adapterId?: unknown } }).target?.adapterId !== 'string' ||
|
|
68
|
+
typeof (cursor as { target?: { storageId?: unknown } }).target?.storageId !== 'string' ||
|
|
69
|
+
!Number.isSafeInteger((cursor as { offset?: unknown }).offset) ||
|
|
70
|
+
(cursor as { offset: number }).offset < 0
|
|
71
|
+
) {
|
|
72
|
+
throw new Error('Invalid cursor');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return cursor as ListEntriesCursor;
|
|
76
|
+
} catch {
|
|
77
|
+
throw new Error(
|
|
78
|
+
'Invalid cursor. Run list-entries again without a cursor to restart pagination.',
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const sanitizeLimit = (limit: number | undefined) => {
|
|
84
|
+
if (
|
|
85
|
+
typeof limit !== 'number' ||
|
|
86
|
+
!Number.isFinite(limit) ||
|
|
87
|
+
!Number.isInteger(limit) ||
|
|
88
|
+
limit < 1
|
|
89
|
+
) {
|
|
90
|
+
return DEFAULT_PAGE_LIMIT;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return Math.min(limit, MAX_PAGE_LIMIT);
|
|
94
|
+
};
|
|
79
95
|
|
|
80
|
-
|
|
96
|
+
const sanitizeOffset = (offset: number | undefined) => {
|
|
97
|
+
if (
|
|
98
|
+
typeof offset !== 'number' ||
|
|
99
|
+
!Number.isFinite(offset) ||
|
|
100
|
+
!Number.isSafeInteger(offset) ||
|
|
101
|
+
offset < 0
|
|
102
|
+
) {
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return offset;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const resolveStorage = (views: readonly StorageView[], adapterId?: string, storageId?: string) => {
|
|
110
|
+
if (views.length === 0) {
|
|
111
|
+
throw new Error('No storages are registered.');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (adapterId !== undefined || storageId !== undefined) {
|
|
115
|
+
const matches = views.filter(
|
|
116
|
+
(view) =>
|
|
117
|
+
(adapterId === undefined || view.target.adapterId === adapterId) &&
|
|
118
|
+
(storageId === undefined || view.target.storageId === storageId),
|
|
119
|
+
);
|
|
120
|
+
const qualifier = [
|
|
121
|
+
adapterId !== undefined && `adapterId="${adapterId}"`,
|
|
122
|
+
storageId !== undefined && `storageId="${storageId}"`,
|
|
123
|
+
]
|
|
124
|
+
.filter(Boolean)
|
|
125
|
+
.join(', ');
|
|
126
|
+
|
|
127
|
+
if (matches.length === 0) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`No storage matched ${qualifier}. Available: ${views.map(formatViewRef).join(', ')}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (matches.length > 1) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Multiple storages matched ${qualifier}. Provide both adapterId and storageId. Available: ${matches.map(formatViewRef).join(', ')}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return matches[0]!;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (views.length > 1) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Multiple storages detected. Provide adapterId and/or storageId. Available: ${views.map(formatViewRef).join(', ')}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return views[0]!;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const listStorageKeys = async (view: StorageView, input: StorageListEntriesArgs) => {
|
|
152
|
+
const limit = sanitizeLimit(input.limit);
|
|
153
|
+
if (input.cursor !== undefined && input.offset !== undefined) {
|
|
154
|
+
throw new Error('Provide either cursor or offset, not both.');
|
|
155
|
+
}
|
|
156
|
+
const cursor = input.cursor === undefined ? undefined : decodeCursor(input.cursor);
|
|
157
|
+
if (cursor && !isSameTarget(cursor.target, view.target)) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
'Cursor does not match the requested storage. Run list-entries again without a cursor to restart pagination.',
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const allKeys = await view.getAllKeys();
|
|
164
|
+
const offset = cursor?.offset ?? sanitizeOffset(input.offset);
|
|
165
|
+
const end = Math.min(offset + limit, allKeys.length);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
adapterId: view.target.adapterId,
|
|
169
|
+
storageId: view.target.storageId,
|
|
170
|
+
total: allKeys.length,
|
|
171
|
+
items: allKeys.slice(offset, end).map((key) => ({ key })),
|
|
172
|
+
page: {
|
|
173
|
+
limit,
|
|
174
|
+
hasMore: end < allKeys.length,
|
|
175
|
+
...(end < allKeys.length
|
|
176
|
+
? {
|
|
177
|
+
nextCursor: encodeCursor({
|
|
178
|
+
version: 1,
|
|
179
|
+
target: view.target,
|
|
180
|
+
offset: end,
|
|
181
|
+
}),
|
|
182
|
+
}
|
|
183
|
+
: {}),
|
|
81
184
|
},
|
|
82
|
-
|
|
83
|
-
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export const createStorageAgentHandlers = (views: readonly StorageView[]) => ({
|
|
189
|
+
listStorages: async () => ({
|
|
190
|
+
storages: views.map((view) => ({
|
|
191
|
+
adapterId: view.target.adapterId,
|
|
192
|
+
storageId: view.target.storageId,
|
|
193
|
+
adapterName: view.adapterName,
|
|
194
|
+
storageName: view.storageName,
|
|
195
|
+
supportedTypes: view.capabilities.supportedTypes,
|
|
196
|
+
})),
|
|
197
|
+
}),
|
|
198
|
+
|
|
199
|
+
listEntries: async (input: StorageListEntriesArgs) => {
|
|
200
|
+
const view = resolveStorage(views, input.adapterId, input.storageId);
|
|
201
|
+
return listStorageKeys(view, input);
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
readEntry: async ({
|
|
205
|
+
adapterId,
|
|
206
|
+
storageId,
|
|
207
|
+
key,
|
|
208
|
+
}: {
|
|
209
|
+
adapterId?: string;
|
|
210
|
+
storageId?: string;
|
|
211
|
+
key: string;
|
|
212
|
+
}) => {
|
|
213
|
+
const view = resolveStorage(views, adapterId, storageId);
|
|
214
|
+
const entry = await view.get(key);
|
|
215
|
+
|
|
216
|
+
if (!entry) {
|
|
217
|
+
throw new Error(`Key "${key}" not found in storage "${formatViewRef(view)}".`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
adapterId: view.target.adapterId,
|
|
222
|
+
storageId: view.target.storageId,
|
|
223
|
+
entry,
|
|
224
|
+
};
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
export const useStorageAgentTools = (views: StorageView[]) => {
|
|
229
|
+
const handlers = createStorageAgentHandlers(views);
|
|
84
230
|
|
|
85
231
|
useRozenitePluginAgentTool({
|
|
86
232
|
pluginId: STORAGE_AGENT_PLUGIN_ID,
|
|
87
233
|
tool: storageToolDefinitions.listStorages,
|
|
88
|
-
handler:
|
|
89
|
-
storages: await Promise.all(
|
|
90
|
-
views.map(async (view) => ({
|
|
91
|
-
adapterId: view.target.adapterId,
|
|
92
|
-
storageId: view.target.storageId,
|
|
93
|
-
adapterName: view.adapterName,
|
|
94
|
-
storageName: view.storageName,
|
|
95
|
-
supportedTypes: view.capabilities.supportedTypes,
|
|
96
|
-
entryCount: (await view.getAllKeys()).length,
|
|
97
|
-
}))
|
|
98
|
-
),
|
|
99
|
-
}),
|
|
234
|
+
handler: handlers.listStorages,
|
|
100
235
|
});
|
|
101
236
|
|
|
102
237
|
useRozenitePluginAgentTool({
|
|
103
238
|
pluginId: STORAGE_AGENT_PLUGIN_ID,
|
|
104
239
|
tool: storageToolDefinitions.listEntries,
|
|
105
|
-
handler:
|
|
106
|
-
const view = resolveStorage(adapterId, storageId);
|
|
107
|
-
const allKeys = await view.getAllKeys();
|
|
108
|
-
const safeOffset = Math.max(0, Math.floor(offset));
|
|
109
|
-
const safeLimit = Math.max(1, Math.floor(limit));
|
|
110
|
-
const keys = allKeys.slice(safeOffset, safeOffset + safeLimit);
|
|
111
|
-
|
|
112
|
-
return {
|
|
113
|
-
adapterId: view.target.adapterId,
|
|
114
|
-
storageId: view.target.storageId,
|
|
115
|
-
total: allKeys.length,
|
|
116
|
-
offset: safeOffset,
|
|
117
|
-
limit: safeLimit,
|
|
118
|
-
keys,
|
|
119
|
-
};
|
|
120
|
-
},
|
|
240
|
+
handler: handlers.listEntries,
|
|
121
241
|
});
|
|
122
242
|
|
|
123
243
|
useRozenitePluginAgentTool({
|
|
124
244
|
pluginId: STORAGE_AGENT_PLUGIN_ID,
|
|
125
245
|
tool: storageToolDefinitions.readEntry,
|
|
126
|
-
handler:
|
|
127
|
-
const view = resolveStorage(adapterId, storageId);
|
|
128
|
-
const entry = await view.get(key);
|
|
129
|
-
|
|
130
|
-
if (!entry) {
|
|
131
|
-
throw new Error(
|
|
132
|
-
`Key "${key}" not found in storage "${formatViewRef(view)}".`
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return {
|
|
137
|
-
adapterId: view.target.adapterId,
|
|
138
|
-
storageId: view.target.storageId,
|
|
139
|
-
entry,
|
|
140
|
-
};
|
|
141
|
-
},
|
|
246
|
+
handler: handlers.readEntry,
|
|
142
247
|
});
|
|
143
248
|
|
|
144
249
|
useRozenitePluginAgentTool({
|
|
145
250
|
pluginId: STORAGE_AGENT_PLUGIN_ID,
|
|
146
251
|
tool: storageToolDefinitions.createEntry,
|
|
147
252
|
handler: async ({ adapterId, storageId, key, type, value }) => {
|
|
148
|
-
const view = resolveStorage(adapterId, storageId);
|
|
253
|
+
const view = resolveStorage(views, adapterId, storageId);
|
|
149
254
|
const existing = await view.get(key);
|
|
150
255
|
|
|
151
256
|
if (existing) {
|
|
152
257
|
throw new Error(
|
|
153
|
-
`Key "${key}" already exists in storage "${formatViewRef(view)}". Use edit-entry instead
|
|
258
|
+
`Key "${key}" already exists in storage "${formatViewRef(view)}". Use edit-entry instead.`,
|
|
154
259
|
);
|
|
155
260
|
}
|
|
156
261
|
|
|
@@ -170,12 +275,12 @@ export const useStorageAgentTools = (views: StorageView[]) => {
|
|
|
170
275
|
pluginId: STORAGE_AGENT_PLUGIN_ID,
|
|
171
276
|
tool: storageToolDefinitions.editEntry,
|
|
172
277
|
handler: async ({ adapterId, storageId, key, type, value }) => {
|
|
173
|
-
const view = resolveStorage(adapterId, storageId);
|
|
278
|
+
const view = resolveStorage(views, adapterId, storageId);
|
|
174
279
|
const existing = await view.get(key);
|
|
175
280
|
|
|
176
281
|
if (!existing) {
|
|
177
282
|
throw new Error(
|
|
178
|
-
`Key "${key}" not found in storage "${formatViewRef(view)}". Use create-entry instead
|
|
283
|
+
`Key "${key}" not found in storage "${formatViewRef(view)}". Use create-entry instead.`,
|
|
179
284
|
);
|
|
180
285
|
}
|
|
181
286
|
|
|
@@ -195,7 +300,7 @@ export const useStorageAgentTools = (views: StorageView[]) => {
|
|
|
195
300
|
pluginId: STORAGE_AGENT_PLUGIN_ID,
|
|
196
301
|
tool: storageToolDefinitions.removeEntry,
|
|
197
302
|
handler: async ({ adapterId, storageId, key }) => {
|
|
198
|
-
const view = resolveStorage(adapterId, storageId);
|
|
303
|
+
const view = resolveStorage(views, adapterId, storageId);
|
|
199
304
|
const existed = !!(await view.get(key));
|
|
200
305
|
await view.delete(key);
|
|
201
306
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
MAX_BUFFER_PREVIEW_BYTES,
|
|
4
|
+
MAX_STRING_PREVIEW_CODE_POINTS,
|
|
5
|
+
toStorageEntryPreview,
|
|
6
|
+
} from '../entry-preview';
|
|
7
|
+
|
|
8
|
+
describe('toStorageEntryPreview', () => {
|
|
9
|
+
it('represents empty and boundary-sized strings completely', () => {
|
|
10
|
+
expect(toStorageEntryPreview({ key: 'empty', type: 'string', value: '' })).toEqual({
|
|
11
|
+
key: 'empty',
|
|
12
|
+
type: 'string',
|
|
13
|
+
preview: '',
|
|
14
|
+
valueSize: 0,
|
|
15
|
+
isTruncated: false,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const value = 'a'.repeat(MAX_STRING_PREVIEW_CODE_POINTS);
|
|
19
|
+
expect(toStorageEntryPreview({ key: 'boundary', type: 'string', value })).toEqual({
|
|
20
|
+
key: 'boundary',
|
|
21
|
+
type: 'string',
|
|
22
|
+
preview: value,
|
|
23
|
+
valueSize: MAX_STRING_PREVIEW_CODE_POINTS,
|
|
24
|
+
isTruncated: false,
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('truncates an oversized string at the fixed code-point boundary', () => {
|
|
29
|
+
const value = `${'a'.repeat(MAX_STRING_PREVIEW_CODE_POINTS)}z`;
|
|
30
|
+
|
|
31
|
+
expect(toStorageEntryPreview({ key: 'long', type: 'string', value })).toEqual({
|
|
32
|
+
key: 'long',
|
|
33
|
+
type: 'string',
|
|
34
|
+
preview: 'a'.repeat(MAX_STRING_PREVIEW_CODE_POINTS),
|
|
35
|
+
valueSize: MAX_STRING_PREVIEW_CODE_POINTS + 1,
|
|
36
|
+
isTruncated: true,
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('does not split Unicode code points while preserving string length', () => {
|
|
41
|
+
const value = `${'😀'.repeat(MAX_STRING_PREVIEW_CODE_POINTS)}!`;
|
|
42
|
+
|
|
43
|
+
expect(toStorageEntryPreview({ key: 'emoji', type: 'string', value })).toEqual({
|
|
44
|
+
key: 'emoji',
|
|
45
|
+
type: 'string',
|
|
46
|
+
preview: '😀'.repeat(MAX_STRING_PREVIEW_CODE_POINTS),
|
|
47
|
+
valueSize: value.length,
|
|
48
|
+
isTruncated: true,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('formats primitive values completely', () => {
|
|
53
|
+
expect(toStorageEntryPreview({ key: 'count', type: 'number', value: 42 })).toEqual({
|
|
54
|
+
key: 'count',
|
|
55
|
+
type: 'number',
|
|
56
|
+
preview: '42',
|
|
57
|
+
valueSize: 2,
|
|
58
|
+
isTruncated: false,
|
|
59
|
+
});
|
|
60
|
+
expect(toStorageEntryPreview({ key: 'enabled', type: 'boolean', value: false })).toEqual({
|
|
61
|
+
key: 'enabled',
|
|
62
|
+
type: 'boolean',
|
|
63
|
+
preview: 'false',
|
|
64
|
+
valueSize: 5,
|
|
65
|
+
isTruncated: false,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('formats empty and boundary-sized buffers as hexadecimal', () => {
|
|
70
|
+
expect(toStorageEntryPreview({ key: 'empty', type: 'buffer', value: [] })).toEqual({
|
|
71
|
+
key: 'empty',
|
|
72
|
+
type: 'buffer',
|
|
73
|
+
preview: '',
|
|
74
|
+
valueSize: 0,
|
|
75
|
+
isTruncated: false,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const value = new Array(MAX_BUFFER_PREVIEW_BYTES).fill(0xab);
|
|
79
|
+
expect(toStorageEntryPreview({ key: 'boundary', type: 'buffer', value })).toEqual({
|
|
80
|
+
key: 'boundary',
|
|
81
|
+
type: 'buffer',
|
|
82
|
+
preview: new Array(MAX_BUFFER_PREVIEW_BYTES).fill('AB').join(' '),
|
|
83
|
+
valueSize: MAX_BUFFER_PREVIEW_BYTES,
|
|
84
|
+
isTruncated: false,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('truncates oversized buffers at the fixed byte boundary', () => {
|
|
89
|
+
const value = [...new Array(MAX_BUFFER_PREVIEW_BYTES).fill(0xab), 0xcd];
|
|
90
|
+
|
|
91
|
+
expect(toStorageEntryPreview({ key: 'long', type: 'buffer', value })).toEqual({
|
|
92
|
+
key: 'long',
|
|
93
|
+
type: 'buffer',
|
|
94
|
+
preview: new Array(MAX_BUFFER_PREVIEW_BYTES).fill('AB').join(' '),
|
|
95
|
+
valueSize: MAX_BUFFER_PREVIEW_BYTES + 1,
|
|
96
|
+
isTruncated: true,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -1,19 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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 => ({
|
|
2
|
+
import { buildSnapshot, computePreview, parseSnapshot, type StorageSnapshotV1 } from '../snapshot';
|
|
3
|
+
import type { StorageCapabilities, StorageEntry, StorageTarget } from '../types';
|
|
4
|
+
|
|
5
|
+
const validSnapshot = (overrides: Partial<StorageSnapshotV1> = {}): StorageSnapshotV1 => ({
|
|
17
6
|
version: 1,
|
|
18
7
|
plugin: '@rozenite/storage-plugin',
|
|
19
8
|
createdAt: '2026-05-11T12:00:00.000Z',
|
|
@@ -125,16 +114,13 @@ describe('parseSnapshot', () => {
|
|
|
125
114
|
const result = parseSnapshot(input);
|
|
126
115
|
expect(result.ok).toBe(false);
|
|
127
116
|
if (!result.ok) {
|
|
128
|
-
expect(result.error.path).toBe(
|
|
129
|
-
'storage.capabilities.supportedTypes[1]',
|
|
130
|
-
);
|
|
117
|
+
expect(result.error.path).toBe('storage.capabilities.supportedTypes[1]');
|
|
131
118
|
}
|
|
132
119
|
});
|
|
133
120
|
});
|
|
134
121
|
|
|
135
122
|
describe('per-entry rejections', () => {
|
|
136
|
-
const withEntry = (entry: unknown) =>
|
|
137
|
-
parseSnapshot({ ...validSnapshot(), entries: [entry] });
|
|
123
|
+
const withEntry = (entry: unknown) => parseSnapshot({ ...validSnapshot(), entries: [entry] });
|
|
138
124
|
|
|
139
125
|
it('rejects entry missing key', () => {
|
|
140
126
|
const result = withEntry({ type: 'string', value: 'x' });
|
|
@@ -217,9 +203,7 @@ describe('buildSnapshot', () => {
|
|
|
217
203
|
const capabilities: StorageCapabilities = {
|
|
218
204
|
supportedTypes: ['string', 'number'],
|
|
219
205
|
};
|
|
220
|
-
const entries: StorageEntry[] = [
|
|
221
|
-
{ key: 'token', type: 'string', value: 'abc' },
|
|
222
|
-
];
|
|
206
|
+
const entries: StorageEntry[] = [{ key: 'token', type: 'string', value: 'abc' }];
|
|
223
207
|
|
|
224
208
|
const result = buildSnapshot({
|
|
225
209
|
target,
|
|
@@ -280,9 +264,7 @@ describe('computePreview', () => {
|
|
|
280
264
|
entryKeys: new Set(),
|
|
281
265
|
isBlacklisted: (key) => key.startsWith('__'),
|
|
282
266
|
});
|
|
283
|
-
expect(preview.skippedKeys).toEqual([
|
|
284
|
-
{ key: '__internal', reason: 'blacklist' },
|
|
285
|
-
]);
|
|
267
|
+
expect(preview.skippedKeys).toEqual([{ key: '__internal', reason: 'blacklist' }]);
|
|
286
268
|
expect(preview.newKeys).toEqual(['visible']);
|
|
287
269
|
});
|
|
288
270
|
|
|
@@ -320,12 +302,8 @@ describe('computePreview', () => {
|
|
|
320
302
|
entryKeys: new Set(['existing']),
|
|
321
303
|
isBlacklisted: (key) => key.startsWith('__'),
|
|
322
304
|
});
|
|
323
|
-
expect(preview.unsupportedTypes).toEqual([
|
|
324
|
-
|
|
325
|
-
]);
|
|
326
|
-
expect(preview.skippedKeys).toEqual([
|
|
327
|
-
{ key: '__skipped', reason: 'blacklist' },
|
|
328
|
-
]);
|
|
305
|
+
expect(preview.unsupportedTypes).toEqual([{ key: '__blocked', type: 'buffer' }]);
|
|
306
|
+
expect(preview.skippedKeys).toEqual([{ key: '__skipped', reason: 'blacklist' }]);
|
|
329
307
|
expect(preview.overwriteKeys).toEqual(['existing']);
|
|
330
308
|
expect(preview.newKeys).toEqual(['fresh']);
|
|
331
309
|
});
|
|
@@ -358,4 +336,25 @@ describe('computePreview', () => {
|
|
|
358
336
|
});
|
|
359
337
|
expect(preview.metadataMismatch).toBe(false);
|
|
360
338
|
});
|
|
339
|
+
|
|
340
|
+
it('treats accepted duplicate keys as sequential overwrites', () => {
|
|
341
|
+
const preview = computePreview(
|
|
342
|
+
validSnapshot({
|
|
343
|
+
entries: [
|
|
344
|
+
{ key: 'duplicate', type: 'string', value: 'first' },
|
|
345
|
+
{ key: 'duplicate', type: 'string', value: 'second' },
|
|
346
|
+
],
|
|
347
|
+
}),
|
|
348
|
+
{
|
|
349
|
+
target,
|
|
350
|
+
capabilities: allTypes,
|
|
351
|
+
entryKeys: new Set(),
|
|
352
|
+
isBlacklisted: noBlacklist,
|
|
353
|
+
},
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
expect(preview.newKeys).toEqual(['duplicate']);
|
|
357
|
+
expect(preview.overwriteKeys).toEqual(['duplicate']);
|
|
358
|
+
expect(preview.acceptedEntryIndexes).toEqual([0, 1]);
|
|
359
|
+
});
|
|
361
360
|
});
|