@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
@@ -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 { StorageEntry, StorageEntryType, StorageEntryValue } from '../shared/types';
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
- export const useStorageAgentTools = (views: StorageView[]) => {
46
- const resolveStorage = useCallback(
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
- if (adapterId !== undefined || storageId !== undefined) {
53
- const selected = views.find(
54
- (view) =>
55
- (adapterId === undefined || view.target.adapterId === adapterId) &&
56
- (storageId === undefined || view.target.storageId === storageId)
57
- );
50
+ type ListEntriesCursor = {
51
+ version: 1;
52
+ target: StorageTarget;
53
+ offset: number;
54
+ };
58
55
 
59
- if (!selected) {
60
- const qualifier = [
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
- if (views.length > 1) {
75
- throw new Error(
76
- `Multiple storages detected. Provide adapterId and/or storageId. Available: ${views.map(formatViewRef).join(', ')}`
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
- return views[0];
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
- [views]
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: async () => ({
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: async ({ adapterId, storageId, offset = 0, limit = 100 }) => {
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: async ({ adapterId, storageId, key }) => {
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
- buildSnapshot,
4
- computePreview,
5
- parseSnapshot,
6
- type StorageSnapshotV1,
7
- } from '../snapshot';
8
- import type {
9
- StorageCapabilities,
10
- StorageEntry,
11
- StorageTarget,
12
- } from '../types';
13
-
14
- const validSnapshot = (
15
- overrides: Partial<StorageSnapshotV1> = {},
16
- ): StorageSnapshotV1 => ({
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
- { key: '__blocked', type: 'buffer' },
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
  });