@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.
Files changed (84) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +6 -1
  3. package/dist/devtools/assets/panel-BlDRXgVW.js +32 -0
  4. package/dist/devtools/assets/panel-DBweZnW5.css +1 -0
  5. package/dist/devtools/panel.html +2 -2
  6. package/dist/react-native/chunks/async-storage.require.cjs +1 -1
  7. package/dist/react-native/chunks/async-storage.require.js +8 -7
  8. package/dist/react-native/chunks/index.require.cjs +1 -1
  9. package/dist/react-native/chunks/index.require.js +70 -66
  10. package/dist/react-native/chunks/secure-storage.require.cjs +1 -1
  11. package/dist/react-native/chunks/secure-storage.require.js +17 -13
  12. package/dist/react-native/chunks/useRozeniteStoragePlugin.require.cjs +1 -1
  13. package/dist/react-native/chunks/useRozeniteStoragePlugin.require.js +863 -344
  14. package/dist/react-native/index.d.ts +166 -18
  15. package/dist/rozenite.json +1 -1
  16. package/dist/sdk/index.cjs +1 -1
  17. package/dist/sdk/index.d.ts +13 -5
  18. package/dist/sdk/index.js +21 -12
  19. package/package.json +35 -37
  20. package/react-native.ts +12 -22
  21. package/rozenite.config.ts +74 -0
  22. package/sdk.ts +1 -5
  23. package/src/react-native/__tests__/entry-preview-pagination.test.ts +234 -0
  24. package/src/react-native/__tests__/export-snapshot.test.ts +138 -0
  25. package/src/react-native/__tests__/full-entry-request.test.ts +143 -0
  26. package/src/react-native/__tests__/import.test.ts +174 -11
  27. package/src/react-native/__tests__/storage-discovery.test.ts +144 -0
  28. package/src/react-native/__tests__/storage-view.test.ts +172 -0
  29. package/src/react-native/__tests__/stress-verification.test.ts +140 -0
  30. package/src/react-native/__tests__/use-storage-agent-tools.test.ts +221 -0
  31. package/src/react-native/adapters/__tests__/mmkv.test.ts +20 -30
  32. package/src/react-native/adapters/async-storage.ts +8 -9
  33. package/src/react-native/adapters/index.ts +2 -8
  34. package/src/react-native/adapters/mmkv.ts +12 -24
  35. package/src/react-native/adapters/secure-storage.ts +4 -0
  36. package/src/react-native/entry-preview-pagination.ts +261 -0
  37. package/src/react-native/export-snapshot.ts +88 -0
  38. package/src/react-native/full-entry-request.ts +91 -0
  39. package/src/react-native/import.ts +179 -16
  40. package/src/react-native/storage-discovery.ts +92 -0
  41. package/src/react-native/storage-view.ts +47 -109
  42. package/src/react-native/useRozeniteStoragePlugin.ts +153 -129
  43. package/src/react-native/useStorageAgentTools.ts +196 -91
  44. package/src/shared/__tests__/entry-preview.test.ts +99 -0
  45. package/src/shared/__tests__/snapshot.test.ts +31 -32
  46. package/src/shared/agent-tools.ts +33 -38
  47. package/src/shared/entry-preview.ts +54 -0
  48. package/src/shared/messaging.ts +140 -21
  49. package/src/shared/snapshot.ts +12 -18
  50. package/src/shared/types.ts +21 -4
  51. package/src/ui/__tests__/binary-value-editor-state.test.ts +16 -13
  52. package/src/ui/__tests__/binary.test.ts +31 -33
  53. package/src/ui/__tests__/large-value-viewer-state.test.ts +21 -0
  54. package/src/ui/__tests__/large-value-viewer.test.tsx +54 -0
  55. package/src/ui/__tests__/panel.test.tsx +382 -0
  56. package/src/ui/__tests__/storage-groups.test.ts +88 -0
  57. package/src/ui/__tests__/type-conversion.test.ts +8 -24
  58. package/src/ui/__tests__/use-storage-entries.test.tsx +337 -0
  59. package/src/ui/add-entry-dialog.tsx +66 -117
  60. package/src/ui/binary-value-editor-state.ts +16 -25
  61. package/src/ui/binary-value-editor.tsx +59 -47
  62. package/src/ui/binary.ts +54 -15
  63. package/src/ui/edit-entry-dialog.tsx +68 -104
  64. package/src/ui/editor-switcher.tsx +9 -16
  65. package/src/ui/entry-detail-dialog.tsx +62 -181
  66. package/src/ui/format-value.tsx +25 -0
  67. package/src/ui/globals.css +1 -123
  68. package/src/ui/import-dialog.tsx +72 -109
  69. package/src/ui/large-value-viewer-state.ts +25 -0
  70. package/src/ui/large-value-viewer.tsx +65 -0
  71. package/src/ui/panel.tsx +649 -509
  72. package/src/ui/query-client.tsx +23 -0
  73. package/src/ui/storage-groups.ts +66 -0
  74. package/src/ui/type-conversion.ts +2 -5
  75. package/src/ui/typed-value-editor.tsx +15 -20
  76. package/src/ui/use-storage-entries.ts +192 -0
  77. package/src/ui/utils.ts +1 -4
  78. package/tsconfig.json +5 -1
  79. package/dist/devtools/assets/panel-Bm-SWF7d.js +0 -33
  80. package/dist/devtools/assets/panel-DIqI4WSp.css +0 -1
  81. package/postcss.config.js +0 -6
  82. package/src/ui/confirm-dialog.tsx +0 -100
  83. package/src/ui/editable-table.tsx +0 -300
  84. package/tailwind.config.ts +0 -94
@@ -0,0 +1,261 @@
1
+ import { toStorageEntryPreview } from '../shared/entry-preview';
2
+ import type {
3
+ StorageListEntryPreviewsRequestEvent,
4
+ StorageListEntryPreviewsResponseEvent,
5
+ StorageRequestErrorEvent,
6
+ } from '../shared/messaging';
7
+ import type { StorageEntryPreview, StorageTarget } from '../shared/types';
8
+ import type { StorageView } from './storage-view';
9
+
10
+ export const MAX_ENTRY_PREVIEW_PAGE_SIZE = 100;
11
+ export const ENTRY_PREVIEW_READ_CONCURRENCY = 4;
12
+
13
+ type PaginationResult = StorageListEntryPreviewsResponseEvent | StorageRequestErrorEvent;
14
+
15
+ type Cursor = {
16
+ version: 1;
17
+ target: StorageTarget;
18
+ search: string;
19
+ keySortDirection: 'ascending' | 'descending';
20
+ boundaryKey: string;
21
+ direction: 'next' | 'previous';
22
+ };
23
+
24
+ type ValidRequest = Omit<
25
+ StorageListEntryPreviewsRequestEvent,
26
+ 'search' | 'keySortDirection' | 'limit'
27
+ > & {
28
+ search: string;
29
+ keySortDirection: 'ascending' | 'descending';
30
+ limit: number;
31
+ };
32
+
33
+ const getRequestId = (payload: unknown) =>
34
+ typeof payload === 'object' &&
35
+ payload != null &&
36
+ !Array.isArray(payload) &&
37
+ typeof (payload as { requestId?: unknown }).requestId === 'string'
38
+ ? (payload as { requestId: string }).requestId
39
+ : '';
40
+
41
+ const isTarget = (value: unknown): value is StorageTarget =>
42
+ typeof value === 'object' &&
43
+ value != null &&
44
+ !Array.isArray(value) &&
45
+ typeof (value as { adapterId?: unknown }).adapterId === 'string' &&
46
+ (value as { adapterId: string }).adapterId.trim().length > 0 &&
47
+ typeof (value as { storageId?: unknown }).storageId === 'string' &&
48
+ (value as { storageId: string }).storageId.trim().length > 0;
49
+
50
+ const normalizeSearch = (search: string | undefined) => (search ?? '').trim().toLowerCase();
51
+
52
+ const validateRequest = (payload: unknown): ValidRequest | undefined => {
53
+ if (
54
+ typeof payload !== 'object' ||
55
+ payload == null ||
56
+ Array.isArray(payload) ||
57
+ (payload as { type?: unknown }).type !== 'list-entry-previews' ||
58
+ typeof (payload as { requestId?: unknown }).requestId !== 'string' ||
59
+ (payload as { requestId: string }).requestId.trim().length === 0 ||
60
+ !isTarget((payload as { target?: unknown }).target) ||
61
+ ((payload as { search?: unknown }).search !== undefined &&
62
+ typeof (payload as { search?: unknown }).search !== 'string') ||
63
+ ((payload as { cursor?: unknown }).cursor !== undefined &&
64
+ typeof (payload as { cursor?: unknown }).cursor !== 'string') ||
65
+ !Number.isFinite((payload as { limit?: unknown }).limit) ||
66
+ !Number.isInteger((payload as { limit: number }).limit) ||
67
+ (payload as { limit: number }).limit < 1
68
+ ) {
69
+ return undefined;
70
+ }
71
+
72
+ const keySortDirection = (payload as { keySortDirection?: unknown }).keySortDirection;
73
+ if (
74
+ keySortDirection !== undefined &&
75
+ keySortDirection !== 'ascending' &&
76
+ keySortDirection !== 'descending'
77
+ ) {
78
+ return undefined;
79
+ }
80
+
81
+ const request = payload as StorageListEntryPreviewsRequestEvent;
82
+ return {
83
+ ...request,
84
+ search: normalizeSearch(request.search),
85
+ keySortDirection: keySortDirection ?? 'ascending',
86
+ limit: Math.min(request.limit, MAX_ENTRY_PREVIEW_PAGE_SIZE),
87
+ };
88
+ };
89
+
90
+ // Cursor contents are intentionally private to the device protocol. Callers only
91
+ // receive and return this opaque token; its representation may change freely.
92
+ const encodeCursor = (cursor: Cursor) => btoa(encodeURIComponent(JSON.stringify(cursor)));
93
+
94
+ const decodeCursor = (value: string): Cursor | undefined => {
95
+ try {
96
+ const parsed: unknown = JSON.parse(decodeURIComponent(atob(value)));
97
+ if (
98
+ typeof parsed !== 'object' ||
99
+ parsed == null ||
100
+ Array.isArray(parsed) ||
101
+ (parsed as { version?: unknown }).version !== 1 ||
102
+ !isTarget((parsed as { target?: unknown }).target) ||
103
+ typeof (parsed as { search?: unknown }).search !== 'string' ||
104
+ ((parsed as { keySortDirection?: unknown }).keySortDirection !== 'ascending' &&
105
+ (parsed as { keySortDirection?: unknown }).keySortDirection !== 'descending') ||
106
+ typeof (parsed as { boundaryKey?: unknown }).boundaryKey !== 'string' ||
107
+ ((parsed as { direction?: unknown }).direction !== 'next' &&
108
+ (parsed as { direction?: unknown }).direction !== 'previous')
109
+ ) {
110
+ return undefined;
111
+ }
112
+
113
+ return parsed as Cursor;
114
+ } catch {
115
+ return undefined;
116
+ }
117
+ };
118
+
119
+ const isSameTarget = (left: StorageTarget, right: StorageTarget) =>
120
+ left.adapterId === right.adapterId && left.storageId === right.storageId;
121
+
122
+ const compareKeys = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0);
123
+
124
+ const readPreviews = async (
125
+ view: StorageView,
126
+ keys: readonly string[],
127
+ ): Promise<StorageEntryPreview[]> => {
128
+ const previews: StorageEntryPreview[] = new Array(keys.length);
129
+ let nextIndex = 0;
130
+
131
+ const worker = async () => {
132
+ while (nextIndex < keys.length) {
133
+ const index = nextIndex;
134
+ nextIndex += 1;
135
+ const entry = await view.get(keys[index]);
136
+ if (entry) {
137
+ previews[index] = toStorageEntryPreview(entry);
138
+ }
139
+ }
140
+ };
141
+
142
+ await Promise.all(
143
+ Array.from({ length: Math.min(ENTRY_PREVIEW_READ_CONCURRENCY, keys.length) }, worker),
144
+ );
145
+ return previews.filter((preview): preview is StorageEntryPreview => preview != null);
146
+ };
147
+
148
+ export const handleListEntryPreviewsRequest = async (
149
+ views: readonly StorageView[],
150
+ payload: unknown,
151
+ ): Promise<PaginationResult> => {
152
+ const requestId = getRequestId(payload);
153
+ const request = validateRequest(payload);
154
+ if (!request) {
155
+ return {
156
+ type: 'storage-request-error',
157
+ requestId,
158
+ code: 'INVALID_REQUEST',
159
+ message:
160
+ 'Entry preview requests require a valid target, request ID, and positive integer limit.',
161
+ };
162
+ }
163
+
164
+ const view = views.find((candidate) => isSameTarget(candidate.target, request.target));
165
+ if (!view) {
166
+ return {
167
+ type: 'storage-request-error',
168
+ requestId,
169
+ code: 'TARGET_NOT_FOUND',
170
+ message: 'The requested storage target was not found.',
171
+ };
172
+ }
173
+
174
+ let cursor: Cursor | undefined;
175
+ if (request.cursor !== undefined) {
176
+ cursor = decodeCursor(request.cursor);
177
+ if (
178
+ !cursor ||
179
+ !isSameTarget(cursor.target, request.target) ||
180
+ cursor.search !== request.search ||
181
+ cursor.keySortDirection !== request.keySortDirection
182
+ ) {
183
+ return {
184
+ type: 'storage-request-error',
185
+ requestId,
186
+ code: 'INVALID_CURSOR',
187
+ message: 'The entry preview cursor is invalid or belongs to a different query.',
188
+ resetPagination: true,
189
+ };
190
+ }
191
+ }
192
+
193
+ try {
194
+ const keys = (await view.getAllKeys())
195
+ .filter((key) => key.toLowerCase().includes(request.search))
196
+ .sort(compareKeys);
197
+ if (request.keySortDirection === 'descending') {
198
+ keys.reverse();
199
+ }
200
+
201
+ let start = 0;
202
+ if (cursor) {
203
+ const boundaryIndex = keys.indexOf(cursor.boundaryKey);
204
+ if (boundaryIndex === -1) {
205
+ return {
206
+ type: 'storage-request-error',
207
+ requestId,
208
+ code: 'INVALID_CURSOR',
209
+ message: 'The entry preview cursor is stale after storage changes.',
210
+ resetPagination: true,
211
+ };
212
+ }
213
+ start =
214
+ cursor.direction === 'next'
215
+ ? boundaryIndex + 1
216
+ : Math.max(0, boundaryIndex - request.limit);
217
+ }
218
+
219
+ const pageKeys = keys.slice(start, start + request.limit);
220
+ const items = await readPreviews(view, pageKeys);
221
+ const end = start + pageKeys.length;
222
+ const cursorBase = {
223
+ version: 1 as const,
224
+ target: request.target,
225
+ search: request.search,
226
+ keySortDirection: request.keySortDirection,
227
+ };
228
+
229
+ return {
230
+ type: 'entry-previews',
231
+ requestId,
232
+ target: request.target,
233
+ items,
234
+ ...(end < keys.length && pageKeys.length > 0
235
+ ? {
236
+ nextCursor: encodeCursor({
237
+ ...cursorBase,
238
+ boundaryKey: pageKeys.at(-1)!,
239
+ direction: 'next',
240
+ }),
241
+ }
242
+ : {}),
243
+ ...(start > 0 && pageKeys.length > 0
244
+ ? {
245
+ previousCursor: encodeCursor({
246
+ ...cursorBase,
247
+ boundaryKey: pageKeys[0],
248
+ direction: 'previous',
249
+ }),
250
+ }
251
+ : {}),
252
+ };
253
+ } catch {
254
+ return {
255
+ type: 'storage-request-error',
256
+ requestId,
257
+ code: 'READ_FAILED',
258
+ message: 'Failed to read entry previews from the requested storage.',
259
+ };
260
+ }
261
+ };
@@ -0,0 +1,88 @@
1
+ import { buildSnapshot } from '../shared/snapshot';
2
+ import type {
3
+ StorageExportSnapshotRequestEvent,
4
+ StorageExportSnapshotResponseEvent,
5
+ StorageRequestErrorEvent,
6
+ } from '../shared/messaging';
7
+ import type { StorageTarget } from '../shared/types';
8
+ import type { StorageView } from './storage-view';
9
+
10
+ type ExportSnapshotResult = StorageExportSnapshotResponseEvent | StorageRequestErrorEvent;
11
+
12
+ const getRequestId = (payload: unknown) =>
13
+ typeof payload === 'object' &&
14
+ payload != null &&
15
+ !Array.isArray(payload) &&
16
+ typeof (payload as { requestId?: unknown }).requestId === 'string'
17
+ ? (payload as { requestId: string }).requestId
18
+ : '';
19
+
20
+ const isTarget = (value: unknown): value is StorageTarget =>
21
+ typeof value === 'object' &&
22
+ value != null &&
23
+ !Array.isArray(value) &&
24
+ typeof (value as { adapterId?: unknown }).adapterId === 'string' &&
25
+ (value as { adapterId: string }).adapterId.trim().length > 0 &&
26
+ typeof (value as { storageId?: unknown }).storageId === 'string' &&
27
+ (value as { storageId: string }).storageId.trim().length > 0;
28
+
29
+ const isSameTarget = (left: StorageTarget, right: StorageTarget) =>
30
+ left.adapterId === right.adapterId && left.storageId === right.storageId;
31
+
32
+ const validateRequest = (payload: unknown): payload is StorageExportSnapshotRequestEvent =>
33
+ typeof payload === 'object' &&
34
+ payload != null &&
35
+ !Array.isArray(payload) &&
36
+ (payload as { type?: unknown }).type === 'export-snapshot' &&
37
+ typeof (payload as { requestId?: unknown }).requestId === 'string' &&
38
+ (payload as { requestId: string }).requestId.trim().length > 0 &&
39
+ isTarget((payload as { target?: unknown }).target);
40
+
41
+ export const handleExportSnapshotRequest = async (
42
+ views: readonly StorageView[],
43
+ payload: unknown,
44
+ ): Promise<ExportSnapshotResult> => {
45
+ const requestId = getRequestId(payload);
46
+ if (!validateRequest(payload)) {
47
+ return {
48
+ type: 'storage-request-error',
49
+ requestId,
50
+ code: 'INVALID_REQUEST',
51
+ message: 'Export requests require a valid target and request ID.',
52
+ };
53
+ }
54
+
55
+ const view = views.find((candidate) => isSameTarget(candidate.target, payload.target));
56
+ if (!view) {
57
+ return {
58
+ type: 'storage-request-error',
59
+ requestId,
60
+ code: 'TARGET_NOT_FOUND',
61
+ message: 'The requested storage target was not found.',
62
+ };
63
+ }
64
+
65
+ try {
66
+ // getAllEntries applies the storage view's blacklist before reading values.
67
+ const entries = await view.getAllEntries();
68
+ return {
69
+ type: 'export-snapshot-result',
70
+ requestId,
71
+ target: view.target,
72
+ snapshot: buildSnapshot({
73
+ target: view.target,
74
+ adapterName: view.adapterName,
75
+ storageName: view.storageName,
76
+ capabilities: view.capabilities,
77
+ entries,
78
+ }),
79
+ };
80
+ } catch {
81
+ return {
82
+ type: 'storage-request-error',
83
+ requestId,
84
+ code: 'READ_FAILED',
85
+ message: 'Failed to export the requested storage.',
86
+ };
87
+ }
88
+ };
@@ -0,0 +1,91 @@
1
+ import type {
2
+ StorageGetEntryRequestEvent,
3
+ StorageGetEntryResponseEvent,
4
+ StorageRequestErrorEvent,
5
+ } from '../shared/messaging';
6
+ import type { StorageTarget } from '../shared/types';
7
+ import type { StorageView } from './storage-view';
8
+
9
+ type FullEntryResult = StorageGetEntryResponseEvent | StorageRequestErrorEvent;
10
+
11
+ const getRequestId = (payload: unknown) =>
12
+ typeof payload === 'object' &&
13
+ payload != null &&
14
+ !Array.isArray(payload) &&
15
+ typeof (payload as { requestId?: unknown }).requestId === 'string'
16
+ ? (payload as { requestId: string }).requestId
17
+ : '';
18
+
19
+ const isTarget = (value: unknown): value is StorageTarget =>
20
+ typeof value === 'object' &&
21
+ value != null &&
22
+ !Array.isArray(value) &&
23
+ typeof (value as { adapterId?: unknown }).adapterId === 'string' &&
24
+ (value as { adapterId: string }).adapterId.trim().length > 0 &&
25
+ typeof (value as { storageId?: unknown }).storageId === 'string' &&
26
+ (value as { storageId: string }).storageId.trim().length > 0;
27
+
28
+ const isSameTarget = (left: StorageTarget, right: StorageTarget) =>
29
+ left.adapterId === right.adapterId && left.storageId === right.storageId;
30
+
31
+ const validateRequest = (payload: unknown): payload is StorageGetEntryRequestEvent =>
32
+ typeof payload === 'object' &&
33
+ payload != null &&
34
+ !Array.isArray(payload) &&
35
+ (payload as { type?: unknown }).type === 'get-entry' &&
36
+ typeof (payload as { requestId?: unknown }).requestId === 'string' &&
37
+ (payload as { requestId: string }).requestId.trim().length > 0 &&
38
+ isTarget((payload as { target?: unknown }).target) &&
39
+ typeof (payload as { key?: unknown }).key === 'string';
40
+
41
+ export const handleFullEntryRequest = async (
42
+ views: readonly StorageView[],
43
+ payload: unknown,
44
+ ): Promise<FullEntryResult> => {
45
+ const requestId = getRequestId(payload);
46
+ if (!validateRequest(payload)) {
47
+ return {
48
+ type: 'storage-request-error',
49
+ requestId,
50
+ code: 'INVALID_REQUEST',
51
+ message: 'Full entry requests require a valid target, request ID, and string key.',
52
+ };
53
+ }
54
+
55
+ const view = views.find((candidate) => isSameTarget(candidate.target, payload.target));
56
+ if (!view) {
57
+ return {
58
+ type: 'storage-request-error',
59
+ requestId,
60
+ code: 'TARGET_NOT_FOUND',
61
+ message: 'The requested storage target was not found.',
62
+ };
63
+ }
64
+
65
+ try {
66
+ // StorageView applies the same blacklist policy used by preview requests.
67
+ const entry = await view.get(payload.key);
68
+ if (!entry) {
69
+ return {
70
+ type: 'storage-request-error',
71
+ requestId,
72
+ code: 'ENTRY_NOT_FOUND',
73
+ message: 'The requested storage entry was not found.',
74
+ };
75
+ }
76
+
77
+ return {
78
+ type: 'entry',
79
+ requestId,
80
+ target: view.target,
81
+ entry,
82
+ };
83
+ } catch {
84
+ return {
85
+ type: 'storage-request-error',
86
+ requestId,
87
+ code: 'READ_FAILED',
88
+ message: 'Failed to read the requested storage entry.',
89
+ };
90
+ }
91
+ };
@@ -1,28 +1,163 @@
1
1
  import type {
2
2
  StorageImportEntriesEvent,
3
+ StorageImportPreviewRequestEvent,
4
+ StorageImportPreviewResponseEvent,
5
+ StorageImportProgressEvent,
3
6
  StorageImportResultEvent,
4
- StorageSetEntryEvent,
7
+ StorageInvalidatedEvent,
8
+ StorageRequestErrorEvent,
5
9
  } from '../shared/messaging';
10
+ import { computePreview, parseSnapshot } from '../shared/snapshot';
11
+ import { supportsType, type StorageEntry, type StorageTarget } from '../shared/types';
6
12
  import type { StorageView } from './storage-view';
7
13
 
8
14
  export type ImportEmittedEvent =
9
- | StorageSetEntryEvent
10
- | StorageImportResultEvent;
15
+ | StorageImportProgressEvent
16
+ | StorageImportResultEvent
17
+ | StorageInvalidatedEvent;
18
+
19
+ type ImportPreviewResult = StorageImportPreviewResponseEvent | StorageRequestErrorEvent;
20
+
21
+ const isSameTarget = (left: StorageTarget, right: StorageTarget) =>
22
+ left.adapterId === right.adapterId && left.storageId === right.storageId;
23
+
24
+ const isTarget = (value: unknown): value is StorageTarget =>
25
+ typeof value === 'object' &&
26
+ value != null &&
27
+ !Array.isArray(value) &&
28
+ typeof (value as { adapterId?: unknown }).adapterId === 'string' &&
29
+ (value as { adapterId: string }).adapterId.trim().length > 0 &&
30
+ typeof (value as { storageId?: unknown }).storageId === 'string' &&
31
+ (value as { storageId: string }).storageId.trim().length > 0;
32
+
33
+ const getRequestId = (payload: unknown) =>
34
+ typeof payload === 'object' &&
35
+ payload != null &&
36
+ !Array.isArray(payload) &&
37
+ typeof (payload as { requestId?: unknown }).requestId === 'string'
38
+ ? (payload as { requestId: string }).requestId
39
+ : '';
40
+
41
+ const isEntry = (value: unknown): value is StorageEntry => {
42
+ if (typeof value !== 'object' || value == null || Array.isArray(value)) {
43
+ return false;
44
+ }
45
+
46
+ const entry = value as { key?: unknown; type?: unknown; value?: unknown };
47
+ if (typeof entry.key !== 'string') return false;
48
+ if (entry.type === 'string') return typeof entry.value === 'string';
49
+ if (entry.type === 'number') return typeof entry.value === 'number';
50
+ if (entry.type === 'boolean') return typeof entry.value === 'boolean';
51
+ return (
52
+ entry.type === 'buffer' &&
53
+ Array.isArray(entry.value) &&
54
+ entry.value.every(
55
+ (byte) => typeof byte === 'number' && Number.isInteger(byte) && byte >= 0 && byte <= 255,
56
+ )
57
+ );
58
+ };
59
+
60
+ const isBlacklisted = (view: StorageView, key: string) => {
61
+ if (!view.blacklist) return false;
62
+ view.blacklist.lastIndex = 0;
63
+ return view.blacklist.test(key);
64
+ };
65
+
66
+ const findView = (views: readonly StorageView[], target: StorageTarget) =>
67
+ views.find((candidate) => isSameTarget(candidate.target, target));
68
+
69
+ export const handleImportPreviewRequest = async (
70
+ views: readonly StorageView[],
71
+ payload: unknown,
72
+ ): Promise<ImportPreviewResult> => {
73
+ const requestId = getRequestId(payload);
74
+ const request = payload as Partial<StorageImportPreviewRequestEvent>;
75
+ const parsedSnapshot = parseSnapshot(request.snapshot);
76
+ if (
77
+ typeof request !== 'object' ||
78
+ request == null ||
79
+ request.type !== 'preview-import' ||
80
+ typeof request.requestId !== 'string' ||
81
+ request.requestId.trim().length === 0 ||
82
+ !isTarget(request.target) ||
83
+ !parsedSnapshot.ok
84
+ ) {
85
+ return {
86
+ type: 'storage-request-error',
87
+ requestId,
88
+ code: 'INVALID_REQUEST',
89
+ message: 'Import preview requests require a valid target, request ID, and snapshot.',
90
+ };
91
+ }
92
+
93
+ const view = findView(views, request.target);
94
+ if (!view) {
95
+ return {
96
+ type: 'storage-request-error',
97
+ requestId,
98
+ code: 'TARGET_NOT_FOUND',
99
+ message: 'The requested storage target was not found.',
100
+ };
101
+ }
102
+
103
+ try {
104
+ const entryKeys = new Set(await view.getAllKeys());
105
+ return {
106
+ type: 'import-preview',
107
+ requestId,
108
+ target: view.target,
109
+ preview: computePreview(parsedSnapshot.snapshot, {
110
+ target: view.target,
111
+ capabilities: view.capabilities,
112
+ entryKeys,
113
+ isBlacklisted: (key) => isBlacklisted(view, key),
114
+ }),
115
+ };
116
+ } catch {
117
+ return {
118
+ type: 'storage-request-error',
119
+ requestId,
120
+ code: 'READ_FAILED',
121
+ message: 'Failed to inspect the requested storage for import.',
122
+ };
123
+ }
124
+ };
11
125
 
12
126
  export const handleImportEntries = async (
13
127
  views: StorageView[],
14
- event: StorageImportEntriesEvent,
128
+ payload: unknown,
15
129
  emit: (out: ImportEmittedEvent) => void,
16
130
  ): Promise<void> => {
17
- const view = views.find(
18
- (candidate) =>
19
- candidate.target.adapterId === event.target.adapterId &&
20
- candidate.target.storageId === event.target.storageId,
21
- );
131
+ const requestId = getRequestId(payload);
132
+ const event = payload as Partial<StorageImportEntriesEvent>;
133
+ if (
134
+ typeof event !== 'object' ||
135
+ event == null ||
136
+ event.type !== 'import-entries' ||
137
+ typeof event.requestId !== 'string' ||
138
+ event.requestId.trim().length === 0 ||
139
+ !isTarget(event.target) ||
140
+ !Array.isArray(event.entries) ||
141
+ !event.entries.every(isEntry)
142
+ ) {
143
+ emit({
144
+ type: 'import-result',
145
+ requestId,
146
+ target: isTarget(event.target) ? event.target : { adapterId: '', storageId: '' },
147
+ ok: false,
148
+ written: 0,
149
+ total: 0,
150
+ error: 'Import requests require a valid target, request ID, and entries.',
151
+ });
152
+ return;
153
+ }
154
+
155
+ const view = findView(views, event.target);
22
156
 
23
157
  if (!view) {
24
158
  emit({
25
159
  type: 'import-result',
160
+ requestId: event.requestId,
26
161
  target: event.target,
27
162
  ok: false,
28
163
  written: 0,
@@ -32,18 +167,33 @@ export const handleImportEntries = async (
32
167
  return;
33
168
  }
34
169
 
35
- for (let i = 0; i < event.entries.length; i++) {
36
- const entry = event.entries[i];
170
+ // Recheck policy at apply time so a preview cannot bypass a changed target.
171
+ const entries = event.entries.filter(
172
+ (entry) => supportsType(view.capabilities, entry.type) && !isBlacklisted(view, entry.key),
173
+ );
174
+
175
+ for (let i = 0; i < entries.length; i++) {
176
+ const entry = entries[i];
37
177
 
38
178
  try {
39
179
  await view.set(entry);
40
180
  } catch (error) {
181
+ if (i > 0) {
182
+ const entryCount = (await view.getAllKeys()).length;
183
+ emit({
184
+ type: 'storage-invalidated',
185
+ target: event.target,
186
+ operation: 'import',
187
+ entryCount,
188
+ });
189
+ }
41
190
  emit({
42
191
  type: 'import-result',
192
+ requestId: event.requestId,
43
193
  target: event.target,
44
194
  ok: false,
45
195
  written: i,
46
- total: event.entries.length,
196
+ total: entries.length,
47
197
  failedKey: entry.key,
48
198
  error: error instanceof Error ? error.message : String(error),
49
199
  });
@@ -51,17 +201,30 @@ export const handleImportEntries = async (
51
201
  }
52
202
 
53
203
  emit({
54
- type: 'set-entry',
204
+ type: 'import-progress',
205
+ requestId: event.requestId,
206
+ target: event.target,
207
+ written: i + 1,
208
+ total: entries.length,
209
+ });
210
+ }
211
+
212
+ if (entries.length > 0) {
213
+ const entryCount = (await view.getAllKeys()).length;
214
+ emit({
215
+ type: 'storage-invalidated',
55
216
  target: event.target,
56
- entry,
217
+ operation: 'import',
218
+ entryCount,
57
219
  });
58
220
  }
59
221
 
60
222
  emit({
61
223
  type: 'import-result',
224
+ requestId: event.requestId,
62
225
  target: event.target,
63
226
  ok: true,
64
- written: event.entries.length,
65
- total: event.entries.length,
227
+ written: entries.length,
228
+ total: entries.length,
66
229
  });
67
230
  };