@rozenite/storage-plugin 1.13.0 → 2.0.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 +28 -0
- package/README.md +6 -1
- package/dist/devtools/assets/panel-B6Wp0eie.css +1 -0
- package/dist/devtools/assets/panel-D7MFc4HG.js +32 -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 -64
- package/dist/react-native/chunks/secure-storage.require.cjs +1 -1
- package/dist/react-native/chunks/secure-storage.require.js +22 -14
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.cjs +1 -1
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.js +863 -315
- package/dist/react-native/index.d.ts +154 -18
- package/dist/react-native/index.js +10 -2
- 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 +13 -15
- package/react-native.ts +29 -14
- package/rozenite.config.ts +78 -0
- package/src/react-native/__tests__/entry-preview-pagination.test.ts +262 -0
- package/src/react-native/__tests__/export-snapshot.test.ts +142 -0
- package/src/react-native/__tests__/full-entry-request.test.ts +155 -0
- package/src/react-native/__tests__/import.test.ts +172 -10
- package/src/react-native/__tests__/storage-discovery.test.ts +155 -0
- package/src/react-native/__tests__/storage-view.test.ts +172 -0
- package/src/react-native/__tests__/stress-verification.test.ts +153 -0
- package/src/react-native/__tests__/use-storage-agent-tools.test.ts +231 -0
- package/src/react-native/adapters/async-storage.ts +9 -7
- package/src/react-native/adapters/mmkv.ts +5 -0
- package/src/react-native/adapters/secure-storage.ts +9 -1
- package/src/react-native/entry-preview-pagination.ts +277 -0
- package/src/react-native/export-snapshot.ts +94 -0
- package/src/react-native/full-entry-request.ts +96 -0
- package/src/react-native/import.ts +190 -16
- package/src/react-native/storage-discovery.ts +96 -0
- package/src/react-native/storage-view.ts +45 -103
- package/src/react-native/useRozeniteStoragePlugin.ts +108 -72
- package/src/react-native/useStorageAgentTools.ts +207 -86
- package/src/shared/__tests__/entry-preview.test.ts +117 -0
- package/src/shared/__tests__/snapshot.test.ts +21 -0
- package/src/shared/agent-tools.ts +27 -11
- package/src/shared/entry-preview.ts +59 -0
- package/src/shared/messaging.ts +136 -21
- package/src/shared/snapshot.ts +8 -1
- package/src/shared/types.ts +20 -1
- package/src/ui/__tests__/binary-value-editor-state.test.ts +13 -0
- package/src/ui/__tests__/binary.test.ts +18 -0
- package/src/ui/__tests__/large-value-viewer-state.test.ts +21 -0
- package/src/ui/__tests__/large-value-viewer.test.tsx +63 -0
- package/src/ui/__tests__/panel.test.tsx +409 -0
- package/src/ui/__tests__/storage-groups.test.ts +91 -0
- package/src/ui/__tests__/use-storage-entries.test.tsx +353 -0
- package/src/ui/add-entry-dialog.tsx +65 -105
- package/src/ui/binary-value-editor-state.ts +10 -1
- package/src/ui/binary-value-editor.tsx +57 -33
- package/src/ui/binary.ts +56 -4
- package/src/ui/edit-entry-dialog.tsx +71 -99
- package/src/ui/editor-switcher.tsx +7 -5
- package/src/ui/entry-detail-dialog.tsx +69 -179
- package/src/ui/format-value.tsx +32 -0
- package/src/ui/globals.css +1 -123
- package/src/ui/import-dialog.tsx +66 -91
- package/src/ui/large-value-viewer-state.ts +32 -0
- package/src/ui/large-value-viewer.tsx +71 -0
- package/src/ui/panel.tsx +724 -496
- package/src/ui/query-client.tsx +34 -0
- package/src/ui/storage-groups.ts +66 -0
- package/src/ui/typed-value-editor.tsx +14 -15
- package/src/ui/use-storage-entries.ts +207 -0
- 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
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { StorageAdapter, StorageEntry } from '../../shared/types';
|
|
3
|
+
import {
|
|
4
|
+
ENTRY_PREVIEW_READ_CONCURRENCY,
|
|
5
|
+
MAX_ENTRY_PREVIEW_PAGE_SIZE,
|
|
6
|
+
handleListEntryPreviewsRequest,
|
|
7
|
+
} from '../entry-preview-pagination';
|
|
8
|
+
import { createStorageViews } from '../storage-view';
|
|
9
|
+
|
|
10
|
+
const target = { adapterId: 'adapter', storageId: 'storage' };
|
|
11
|
+
|
|
12
|
+
const createView = (
|
|
13
|
+
values: Record<string, StorageEntry>,
|
|
14
|
+
options?: {
|
|
15
|
+
blacklist?: RegExp;
|
|
16
|
+
get?: (
|
|
17
|
+
key: string,
|
|
18
|
+
) => StorageEntry | undefined | Promise<StorageEntry | undefined>;
|
|
19
|
+
},
|
|
20
|
+
) => {
|
|
21
|
+
const get = vi.fn(options?.get ?? ((key: string) => values[key]));
|
|
22
|
+
const getAllKeys = vi.fn(() => Object.keys(values));
|
|
23
|
+
const adapter: StorageAdapter = {
|
|
24
|
+
id: target.adapterId,
|
|
25
|
+
name: 'Adapter',
|
|
26
|
+
storages: [
|
|
27
|
+
{
|
|
28
|
+
id: target.storageId,
|
|
29
|
+
name: 'Storage',
|
|
30
|
+
capabilities: { supportedTypes: ['string'] },
|
|
31
|
+
blacklist: options?.blacklist,
|
|
32
|
+
storage: {
|
|
33
|
+
kind: 'sync',
|
|
34
|
+
getAllKeys,
|
|
35
|
+
get: get as (key: string) => StorageEntry | undefined,
|
|
36
|
+
set: vi.fn(),
|
|
37
|
+
delete: vi.fn(),
|
|
38
|
+
clear: vi.fn(),
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return { view: createStorageViews([adapter])[0], get, getAllKeys };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const entries = (...keys: string[]): Record<string, StorageEntry> =>
|
|
48
|
+
Object.fromEntries(
|
|
49
|
+
keys.map((key) => [key, { key, type: 'string', value: `value:${key}` }]),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const request = (overrides: Record<string, unknown> = {}) => ({
|
|
53
|
+
type: 'list-entry-previews' as const,
|
|
54
|
+
requestId: 'request-1',
|
|
55
|
+
target,
|
|
56
|
+
limit: 2,
|
|
57
|
+
...overrides,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('handleListEntryPreviewsRequest', () => {
|
|
61
|
+
it('returns sorted first, next, and previous pages without reading outside each page', async () => {
|
|
62
|
+
const { view, get } = createView(
|
|
63
|
+
entries('delta', 'bravo', 'alpha', 'charlie', 'echo'),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const first = await handleListEntryPreviewsRequest([view], request());
|
|
67
|
+
expect(first).toMatchObject({
|
|
68
|
+
type: 'entry-previews',
|
|
69
|
+
items: [{ key: 'alpha' }, { key: 'bravo' }],
|
|
70
|
+
});
|
|
71
|
+
expect(get).toHaveBeenCalledTimes(2);
|
|
72
|
+
|
|
73
|
+
const next = await handleListEntryPreviewsRequest(
|
|
74
|
+
[view],
|
|
75
|
+
request({
|
|
76
|
+
cursor: first.type === 'entry-previews' ? first.nextCursor : undefined,
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
expect(next).toMatchObject({
|
|
80
|
+
type: 'entry-previews',
|
|
81
|
+
items: [{ key: 'charlie' }, { key: 'delta' }],
|
|
82
|
+
});
|
|
83
|
+
expect(get).toHaveBeenCalledTimes(4);
|
|
84
|
+
|
|
85
|
+
const previous = await handleListEntryPreviewsRequest(
|
|
86
|
+
[view],
|
|
87
|
+
request({
|
|
88
|
+
cursor:
|
|
89
|
+
next.type === 'entry-previews' ? next.previousCursor : undefined,
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
expect(previous).toMatchObject({
|
|
93
|
+
type: 'entry-previews',
|
|
94
|
+
items: [{ key: 'alpha' }, { key: 'bravo' }],
|
|
95
|
+
});
|
|
96
|
+
expect(get).toHaveBeenCalledTimes(6);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('filters normalized key search and sorts before pagination without inspecting non-page values', async () => {
|
|
100
|
+
const { view, get } = createView(
|
|
101
|
+
entries('Alpha', 'alphabet', 'beta', 'ALPINE'),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const result = await handleListEntryPreviewsRequest(
|
|
105
|
+
[view],
|
|
106
|
+
request({ search: ' ALP ', keySortDirection: 'descending', limit: 1 }),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(result).toMatchObject({
|
|
110
|
+
type: 'entry-previews',
|
|
111
|
+
items: [{ key: 'alphabet' }],
|
|
112
|
+
});
|
|
113
|
+
expect(get).toHaveBeenCalledWith('alphabet');
|
|
114
|
+
expect(get).toHaveBeenCalledTimes(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('returns empty and final pages without cursors', async () => {
|
|
118
|
+
const { view, get } = createView(entries('only'));
|
|
119
|
+
|
|
120
|
+
await expect(
|
|
121
|
+
handleListEntryPreviewsRequest([view], request({ search: 'missing' })),
|
|
122
|
+
).resolves.toEqual({
|
|
123
|
+
type: 'entry-previews',
|
|
124
|
+
requestId: 'request-1',
|
|
125
|
+
target,
|
|
126
|
+
items: [],
|
|
127
|
+
});
|
|
128
|
+
await expect(
|
|
129
|
+
handleListEntryPreviewsRequest([view], request()),
|
|
130
|
+
).resolves.toEqual({
|
|
131
|
+
type: 'entry-previews',
|
|
132
|
+
requestId: 'request-1',
|
|
133
|
+
target,
|
|
134
|
+
items: [
|
|
135
|
+
{
|
|
136
|
+
key: 'only',
|
|
137
|
+
type: 'string',
|
|
138
|
+
preview: 'value:only',
|
|
139
|
+
valueSize: 10,
|
|
140
|
+
isTruncated: false,
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
expect(get).toHaveBeenCalledTimes(1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('rejects malformed, mismatched, and stale cursors with a reset signal', async () => {
|
|
148
|
+
const { view } = createView(entries('alpha', 'bravo', 'charlie'));
|
|
149
|
+
|
|
150
|
+
await expect(
|
|
151
|
+
handleListEntryPreviewsRequest(
|
|
152
|
+
[view],
|
|
153
|
+
request({ cursor: 'not-a-cursor' }),
|
|
154
|
+
),
|
|
155
|
+
).resolves.toMatchObject({ code: 'INVALID_CURSOR', resetPagination: true });
|
|
156
|
+
|
|
157
|
+
const first = await handleListEntryPreviewsRequest([view], request());
|
|
158
|
+
const cursor = first.type === 'entry-previews' ? first.nextCursor! : '';
|
|
159
|
+
await expect(
|
|
160
|
+
handleListEntryPreviewsRequest(
|
|
161
|
+
[view],
|
|
162
|
+
request({ search: 'alpha', cursor }),
|
|
163
|
+
),
|
|
164
|
+
).resolves.toMatchObject({ code: 'INVALID_CURSOR', resetPagination: true });
|
|
165
|
+
|
|
166
|
+
const stale = await handleListEntryPreviewsRequest(
|
|
167
|
+
[view],
|
|
168
|
+
request({ cursor }),
|
|
169
|
+
);
|
|
170
|
+
expect(stale).toMatchObject({ type: 'entry-previews' });
|
|
171
|
+
const noBoundary = createView(entries('alpha', 'charlie')).view;
|
|
172
|
+
await expect(
|
|
173
|
+
handleListEntryPreviewsRequest([noBoundary], request({ cursor })),
|
|
174
|
+
).resolves.toMatchObject({ code: 'INVALID_CURSOR', resetPagination: true });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('applies blacklist rules before pagination and caps oversized page limits', async () => {
|
|
178
|
+
const { view, get } = createView(
|
|
179
|
+
entries(
|
|
180
|
+
...Array.from(
|
|
181
|
+
{ length: MAX_ENTRY_PREVIEW_PAGE_SIZE + 2 },
|
|
182
|
+
(_, index) => `key-${index}`,
|
|
183
|
+
),
|
|
184
|
+
'secret',
|
|
185
|
+
),
|
|
186
|
+
{ blacklist: /^secret$/ },
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const result = await handleListEntryPreviewsRequest(
|
|
190
|
+
[view],
|
|
191
|
+
request({ limit: MAX_ENTRY_PREVIEW_PAGE_SIZE + 50 }),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
expect(result).toMatchObject({ type: 'entry-previews' });
|
|
195
|
+
expect(get).toHaveBeenCalledTimes(MAX_ENTRY_PREVIEW_PAGE_SIZE);
|
|
196
|
+
expect(get).not.toHaveBeenCalledWith('secret');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('bounds concurrent page reads and converts values to previews before returning', async () => {
|
|
200
|
+
let activeReads = 0;
|
|
201
|
+
let maxActiveReads = 0;
|
|
202
|
+
const { view } = createView(entries('a', 'b', 'c', 'd', 'e'), {
|
|
203
|
+
get: async (key) => {
|
|
204
|
+
activeReads += 1;
|
|
205
|
+
maxActiveReads = Math.max(maxActiveReads, activeReads);
|
|
206
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
207
|
+
activeReads -= 1;
|
|
208
|
+
return { key, type: 'string', value: 'x'.repeat(300) };
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const result = await handleListEntryPreviewsRequest(
|
|
213
|
+
[view],
|
|
214
|
+
request({ limit: 5 }),
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
expect(maxActiveReads).toBeLessThanOrEqual(ENTRY_PREVIEW_READ_CONCURRENCY);
|
|
218
|
+
expect(result).toMatchObject({
|
|
219
|
+
type: 'entry-previews',
|
|
220
|
+
items: expect.arrayContaining([
|
|
221
|
+
expect.objectContaining({
|
|
222
|
+
preview: 'x'.repeat(256),
|
|
223
|
+
isTruncated: true,
|
|
224
|
+
}),
|
|
225
|
+
]),
|
|
226
|
+
});
|
|
227
|
+
if (result.type === 'entry-previews') {
|
|
228
|
+
expect(result.items.every((item) => !('value' in item))).toBe(true);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('contains page-read failures in a structured response', async () => {
|
|
233
|
+
const { view } = createView(entries('a', 'b'), {
|
|
234
|
+
get: (key) => {
|
|
235
|
+
if (key === 'a') {
|
|
236
|
+
throw new Error('adapter internals');
|
|
237
|
+
}
|
|
238
|
+
return { key, type: 'string', value: 'value' };
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
await expect(
|
|
243
|
+
handleListEntryPreviewsRequest([view], request()),
|
|
244
|
+
).resolves.toEqual({
|
|
245
|
+
type: 'storage-request-error',
|
|
246
|
+
requestId: 'request-1',
|
|
247
|
+
code: 'READ_FAILED',
|
|
248
|
+
message: 'Failed to read entry previews from the requested storage.',
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('rejects non-finite and non-positive limits without reading storage', async () => {
|
|
253
|
+
const { view, getAllKeys } = createView(entries('key'));
|
|
254
|
+
|
|
255
|
+
for (const limit of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
|
256
|
+
await expect(
|
|
257
|
+
handleListEntryPreviewsRequest([view], request({ limit })),
|
|
258
|
+
).resolves.toMatchObject({ code: 'INVALID_REQUEST' });
|
|
259
|
+
}
|
|
260
|
+
expect(getAllKeys).not.toHaveBeenCalled();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { StorageAdapter, StorageEntry } from '../../shared/types';
|
|
3
|
+
import { handleExportSnapshotRequest } from '../export-snapshot';
|
|
4
|
+
import { createStorageViews } from '../storage-view';
|
|
5
|
+
|
|
6
|
+
const target = { adapterId: 'adapter', storageId: 'storage' };
|
|
7
|
+
|
|
8
|
+
const request = (overrides: Record<string, unknown> = {}) => ({
|
|
9
|
+
type: 'export-snapshot' as const,
|
|
10
|
+
requestId: 'request-1',
|
|
11
|
+
target,
|
|
12
|
+
...overrides,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const createView = (
|
|
16
|
+
values: Record<string, StorageEntry>,
|
|
17
|
+
options?: { blacklist?: RegExp; getAllKeys?: () => string[] },
|
|
18
|
+
) => {
|
|
19
|
+
const get = vi.fn((key: string) => values[key]);
|
|
20
|
+
const getAllKeys = vi.fn(options?.getAllKeys ?? (() => Object.keys(values)));
|
|
21
|
+
const adapter: StorageAdapter = {
|
|
22
|
+
id: target.adapterId,
|
|
23
|
+
name: 'Adapter',
|
|
24
|
+
storages: [
|
|
25
|
+
{
|
|
26
|
+
id: target.storageId,
|
|
27
|
+
name: 'Storage',
|
|
28
|
+
capabilities: { supportedTypes: ['string', 'buffer'] },
|
|
29
|
+
blacklist: options?.blacklist,
|
|
30
|
+
storage: {
|
|
31
|
+
kind: 'sync',
|
|
32
|
+
getAllKeys,
|
|
33
|
+
get: get as (key: string) => StorageEntry | undefined,
|
|
34
|
+
set: vi.fn(),
|
|
35
|
+
delete: vi.fn(),
|
|
36
|
+
clear: vi.fn(),
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return { view: createStorageViews([adapter])[0], get, getAllKeys };
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
describe('handleExportSnapshotRequest', () => {
|
|
46
|
+
it('builds a schema-compatible snapshot from current device entries only when requested', async () => {
|
|
47
|
+
const { view, get, getAllKeys } = createView(
|
|
48
|
+
{
|
|
49
|
+
retained: { key: 'retained', type: 'string', value: 'old UI page' },
|
|
50
|
+
unseen: {
|
|
51
|
+
key: 'unseen',
|
|
52
|
+
type: 'string',
|
|
53
|
+
value: 'current device value',
|
|
54
|
+
},
|
|
55
|
+
secret: { key: 'secret', type: 'string', value: 'excluded' },
|
|
56
|
+
},
|
|
57
|
+
{ blacklist: /^secret$/ },
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
expect(getAllKeys).not.toHaveBeenCalled();
|
|
61
|
+
expect(get).not.toHaveBeenCalled();
|
|
62
|
+
|
|
63
|
+
const result = await handleExportSnapshotRequest([view], request());
|
|
64
|
+
|
|
65
|
+
expect(result).toMatchObject({
|
|
66
|
+
type: 'export-snapshot-result',
|
|
67
|
+
requestId: 'request-1',
|
|
68
|
+
target,
|
|
69
|
+
snapshot: {
|
|
70
|
+
version: 1,
|
|
71
|
+
plugin: '@rozenite/storage-plugin',
|
|
72
|
+
storage: {
|
|
73
|
+
adapterId: 'adapter',
|
|
74
|
+
storageId: 'storage',
|
|
75
|
+
adapterName: 'Adapter',
|
|
76
|
+
storageName: 'Storage',
|
|
77
|
+
},
|
|
78
|
+
entries: [
|
|
79
|
+
{ key: 'retained', type: 'string', value: 'old UI page' },
|
|
80
|
+
{ key: 'unseen', type: 'string', value: 'current device value' },
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
expect(getAllKeys).toHaveBeenCalledTimes(1);
|
|
85
|
+
expect(get).toHaveBeenCalledTimes(2);
|
|
86
|
+
expect(get).not.toHaveBeenCalledWith('secret');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('exports an empty storage as a valid empty snapshot', async () => {
|
|
90
|
+
const { view } = createView({});
|
|
91
|
+
|
|
92
|
+
await expect(
|
|
93
|
+
handleExportSnapshotRequest([view], request()),
|
|
94
|
+
).resolves.toMatchObject({
|
|
95
|
+
type: 'export-snapshot-result',
|
|
96
|
+
snapshot: { entries: [] },
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('returns predictable invalid and missing-target errors', async () => {
|
|
101
|
+
const { view } = createView({});
|
|
102
|
+
|
|
103
|
+
await expect(
|
|
104
|
+
handleExportSnapshotRequest(
|
|
105
|
+
[view],
|
|
106
|
+
request({ target: { adapterId: '', storageId: 'storage' } }),
|
|
107
|
+
),
|
|
108
|
+
).resolves.toMatchObject({
|
|
109
|
+
code: 'INVALID_REQUEST',
|
|
110
|
+
requestId: 'request-1',
|
|
111
|
+
});
|
|
112
|
+
await expect(
|
|
113
|
+
handleExportSnapshotRequest(
|
|
114
|
+
[view],
|
|
115
|
+
request({ target: { adapterId: 'missing', storageId: 'storage' } }),
|
|
116
|
+
),
|
|
117
|
+
).resolves.toMatchObject({
|
|
118
|
+
code: 'TARGET_NOT_FOUND',
|
|
119
|
+
requestId: 'request-1',
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('contains device read failures without exposing adapter errors', async () => {
|
|
124
|
+
const { view } = createView(
|
|
125
|
+
{},
|
|
126
|
+
{
|
|
127
|
+
getAllKeys: () => {
|
|
128
|
+
throw new Error('adapter internals');
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
await expect(
|
|
134
|
+
handleExportSnapshotRequest([view], request()),
|
|
135
|
+
).resolves.toEqual({
|
|
136
|
+
type: 'storage-request-error',
|
|
137
|
+
requestId: 'request-1',
|
|
138
|
+
code: 'READ_FAILED',
|
|
139
|
+
message: 'Failed to export the requested storage.',
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { StorageAdapter, StorageEntry } from '../../shared/types';
|
|
3
|
+
import { handleFullEntryRequest } from '../full-entry-request';
|
|
4
|
+
import { createStorageViews } from '../storage-view';
|
|
5
|
+
|
|
6
|
+
const target = { adapterId: 'adapter', storageId: 'storage' };
|
|
7
|
+
|
|
8
|
+
const request = (overrides: Record<string, unknown> = {}) => ({
|
|
9
|
+
type: 'get-entry' as const,
|
|
10
|
+
requestId: 'request-1',
|
|
11
|
+
target,
|
|
12
|
+
key: 'key',
|
|
13
|
+
...overrides,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const createView = (
|
|
17
|
+
values: Record<string, StorageEntry>,
|
|
18
|
+
options?: {
|
|
19
|
+
blacklist?: RegExp;
|
|
20
|
+
get?: (
|
|
21
|
+
key: string,
|
|
22
|
+
) => StorageEntry | undefined | Promise<StorageEntry | undefined>;
|
|
23
|
+
},
|
|
24
|
+
) => {
|
|
25
|
+
const get = vi.fn(options?.get ?? ((key: string) => values[key]));
|
|
26
|
+
const getAllKeys = vi.fn(() => Object.keys(values));
|
|
27
|
+
const adapter: StorageAdapter = {
|
|
28
|
+
id: target.adapterId,
|
|
29
|
+
name: 'Adapter',
|
|
30
|
+
storages: [
|
|
31
|
+
{
|
|
32
|
+
id: target.storageId,
|
|
33
|
+
name: 'Storage',
|
|
34
|
+
capabilities: { supportedTypes: ['string', 'buffer'] },
|
|
35
|
+
blacklist: options?.blacklist,
|
|
36
|
+
storage: {
|
|
37
|
+
kind: 'sync',
|
|
38
|
+
getAllKeys,
|
|
39
|
+
get: get as (key: string) => StorageEntry | undefined,
|
|
40
|
+
set: vi.fn(),
|
|
41
|
+
delete: vi.fn(),
|
|
42
|
+
clear: vi.fn(),
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return { view: createStorageViews([adapter])[0], get, getAllKeys };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe('handleFullEntryRequest', () => {
|
|
52
|
+
it('reads exactly the requested key and returns its complete typed value', async () => {
|
|
53
|
+
const buffer = Array.from({ length: 4_096 }, (_, index) => index % 256);
|
|
54
|
+
const { view, get, getAllKeys } = createView({
|
|
55
|
+
key: { key: 'key', type: 'buffer', value: buffer },
|
|
56
|
+
other: { key: 'other', type: 'string', value: 'not read' },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await expect(handleFullEntryRequest([view], request())).resolves.toEqual({
|
|
60
|
+
type: 'entry',
|
|
61
|
+
requestId: 'request-1',
|
|
62
|
+
target,
|
|
63
|
+
entry: { key: 'key', type: 'buffer', value: buffer },
|
|
64
|
+
});
|
|
65
|
+
expect(get).toHaveBeenCalledTimes(1);
|
|
66
|
+
expect(get).toHaveBeenCalledWith('key');
|
|
67
|
+
expect(getAllKeys).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('returns structured errors for missing, blacklisted, invalid, and unknown targets', async () => {
|
|
71
|
+
const missing = createView({});
|
|
72
|
+
await expect(
|
|
73
|
+
handleFullEntryRequest([missing.view], request()),
|
|
74
|
+
).resolves.toMatchObject({
|
|
75
|
+
type: 'storage-request-error',
|
|
76
|
+
requestId: 'request-1',
|
|
77
|
+
code: 'ENTRY_NOT_FOUND',
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const blacklisted = createView(
|
|
81
|
+
{ key: { key: 'key', type: 'string', value: 'secret' } },
|
|
82
|
+
{ blacklist: /^key$/ },
|
|
83
|
+
);
|
|
84
|
+
await expect(
|
|
85
|
+
handleFullEntryRequest([blacklisted.view], request()),
|
|
86
|
+
).resolves.toMatchObject({
|
|
87
|
+
code: 'ENTRY_NOT_FOUND',
|
|
88
|
+
});
|
|
89
|
+
expect(blacklisted.get).not.toHaveBeenCalled();
|
|
90
|
+
|
|
91
|
+
await expect(
|
|
92
|
+
handleFullEntryRequest([missing.view], request({ key: 42 })),
|
|
93
|
+
).resolves.toMatchObject({ code: 'INVALID_REQUEST' });
|
|
94
|
+
await expect(
|
|
95
|
+
handleFullEntryRequest(
|
|
96
|
+
[missing.view],
|
|
97
|
+
request({ target: { adapterId: 'missing', storageId: 'storage' } }),
|
|
98
|
+
),
|
|
99
|
+
).resolves.toMatchObject({ code: 'TARGET_NOT_FOUND' });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('contains adapter read failures without exposing adapter errors', async () => {
|
|
103
|
+
const { view } = createView(
|
|
104
|
+
{},
|
|
105
|
+
{
|
|
106
|
+
get: () => {
|
|
107
|
+
throw new Error('adapter internals');
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
await expect(handleFullEntryRequest([view], request())).resolves.toEqual({
|
|
113
|
+
type: 'storage-request-error',
|
|
114
|
+
requestId: 'request-1',
|
|
115
|
+
code: 'READ_FAILED',
|
|
116
|
+
message: 'Failed to read the requested storage entry.',
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('keeps concurrent requests correlated', async () => {
|
|
121
|
+
const resolvers = new Map<string, (entry: StorageEntry) => void>();
|
|
122
|
+
const { view, get } = createView(
|
|
123
|
+
{},
|
|
124
|
+
{
|
|
125
|
+
get: (key) =>
|
|
126
|
+
new Promise<StorageEntry>((resolve) => {
|
|
127
|
+
resolvers.set(key, resolve);
|
|
128
|
+
}),
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
const first = handleFullEntryRequest(
|
|
132
|
+
[view],
|
|
133
|
+
request({ requestId: 'first', key: 'first' }),
|
|
134
|
+
);
|
|
135
|
+
const second = handleFullEntryRequest(
|
|
136
|
+
[view],
|
|
137
|
+
request({ requestId: 'second', key: 'second' }),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
await vi.waitFor(() => expect(get).toHaveBeenCalledTimes(2));
|
|
141
|
+
resolvers.get('second')?.({ key: 'second', type: 'string', value: 'two' });
|
|
142
|
+
resolvers.get('first')?.({ key: 'first', type: 'string', value: 'one' });
|
|
143
|
+
|
|
144
|
+
await expect(first).resolves.toMatchObject({
|
|
145
|
+
type: 'entry',
|
|
146
|
+
requestId: 'first',
|
|
147
|
+
entry: { key: 'first', value: 'one' },
|
|
148
|
+
});
|
|
149
|
+
await expect(second).resolves.toMatchObject({
|
|
150
|
+
type: 'entry',
|
|
151
|
+
requestId: 'second',
|
|
152
|
+
entry: { key: 'second', value: 'two' },
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|