@jeanharo98/typed-storage 0.1.2 → 0.1.4
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/README.md +118 -89
- package/dist/create-storage.js +17 -0
- package/dist/migrations.d.ts +1 -0
- package/dist/migrations.js +34 -0
- package/dist/types.d.ts +2 -0
- package/index.html +24 -44
- package/package.json +1 -1
- package/src/create-storage.ts +37 -0
- package/src/migrations.test.ts +101 -0
- package/src/migrations.ts +60 -0
- package/src/types.ts +2 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# typed-storage
|
|
2
2
|
|
|
3
|
-
Type-safe `localStorage` and `sessionStorage` with a signal-like API, TTL support, cross-tab sync, and automatic fallback to memory when storage is unavailable.
|
|
3
|
+
Type-safe `localStorage` and `sessionStorage` with a signal-like API, TTL support, cross-tab sync, schema migrations, and automatic fallback to memory when storage is unavailable.
|
|
4
4
|
|
|
5
5
|
```typescript
|
|
6
6
|
const appStorage = createStorage({
|
|
@@ -14,12 +14,15 @@ appStorage.theme.set('purple'); // ❌ TypeScript error at compile time
|
|
|
14
14
|
console.log(appStorage.theme()); // 'light' — persisted across reloads
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
---
|
|
18
|
+
|
|
17
19
|
## ✨ Features
|
|
18
20
|
|
|
19
21
|
- **Type-safe** — TypeScript infers types from your schema automatically
|
|
20
22
|
- **Signal-like API** — read with `signal()`, write with `signal.set(value)`
|
|
21
23
|
- **TTL / expiration** — keys expire automatically after a defined time
|
|
22
24
|
- **Cross-tab sync** — changes in one tab reflect in others via `StorageEvent`
|
|
25
|
+
- **Schema migrations** — safely transform data when your schema changes
|
|
23
26
|
- **Memory fallback** — works even when `localStorage` is unavailable (Safari private mode, quota exceeded)
|
|
24
27
|
- **Prefix namespacing** — avoid key collisions across apps or modules
|
|
25
28
|
- **sessionStorage support** — opt in per schema
|
|
@@ -68,68 +71,111 @@ appStorage.theme.has(); // true
|
|
|
68
71
|
appStorage.theme.remove();
|
|
69
72
|
appStorage.theme.has(); // false
|
|
70
73
|
|
|
74
|
+
// Subscribe to changes
|
|
75
|
+
appStorage.theme.onChange((newValue) => {
|
|
76
|
+
console.log('theme changed to:', newValue);
|
|
77
|
+
});
|
|
78
|
+
|
|
71
79
|
// Clear all keys in the schema
|
|
72
80
|
appStorage.clear();
|
|
73
81
|
```
|
|
74
82
|
|
|
75
83
|
---
|
|
76
84
|
|
|
77
|
-
##
|
|
85
|
+
## 🔄 Schema Migrations
|
|
86
|
+
|
|
87
|
+
When your schema changes between versions, migrations ensure users don't lose their data.
|
|
78
88
|
|
|
79
89
|
```typescript
|
|
80
|
-
|
|
81
|
-
|
|
90
|
+
// Version 1 — what users had stored:
|
|
91
|
+
// localStorage['app:theme'] = '"dark"'
|
|
92
|
+
// localStorage['app:fontSize'] = '16'
|
|
82
93
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
94
|
+
// Version 2 — your new schema:
|
|
95
|
+
const appStorage = createStorage({
|
|
96
|
+
theme: 'dark' as 'dark' | 'light',
|
|
97
|
+
preferences: { // ← new nested object
|
|
98
|
+
fontSize: 16,
|
|
99
|
+
language: 'es'
|
|
100
|
+
}
|
|
101
|
+
}, {
|
|
102
|
+
prefix: 'app',
|
|
103
|
+
version: 2, // ← current schema version
|
|
104
|
+
migrations: {
|
|
105
|
+
1: (oldData) => ({ // ← transforms v1 data to v2
|
|
106
|
+
theme: oldData.theme,
|
|
107
|
+
preferences: {
|
|
108
|
+
fontSize: oldData.fontSize, // moves fontSize inside preferences
|
|
109
|
+
language: 'es' // adds new field with default
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
});
|
|
90
114
|
|
|
91
|
-
|
|
115
|
+
// Old data is automatically migrated on first load
|
|
116
|
+
console.log(appStorage.preferences()); // { fontSize: 16, language: 'es' }
|
|
117
|
+
```
|
|
92
118
|
|
|
93
|
-
|
|
94
|
-
const appStorage = createStorage(
|
|
95
|
-
{ theme: 'dark' },
|
|
96
|
-
{ prefix: 'myapp' }
|
|
97
|
-
);
|
|
119
|
+
### Chained migrations (v1 → v2 → v3)
|
|
98
120
|
|
|
99
|
-
|
|
100
|
-
|
|
121
|
+
```typescript
|
|
122
|
+
createStorage(schema, {
|
|
123
|
+
prefix: 'app',
|
|
124
|
+
version: 3,
|
|
125
|
+
migrations: {
|
|
126
|
+
1: (data) => ({ // v1 → v2
|
|
127
|
+
theme: data.theme,
|
|
128
|
+
preferences: {
|
|
129
|
+
fontSize: data.fontSize,
|
|
130
|
+
language: 'es'
|
|
131
|
+
}
|
|
132
|
+
}),
|
|
133
|
+
2: (data) => ({ // v2 → v3
|
|
134
|
+
...data,
|
|
135
|
+
preferences: {
|
|
136
|
+
...data.preferences,
|
|
137
|
+
sidebarOpen: true // adds new field in v3
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
});
|
|
101
142
|
```
|
|
102
143
|
|
|
103
|
-
###
|
|
144
|
+
### How migrations work
|
|
104
145
|
|
|
105
|
-
```typescript
|
|
106
|
-
const appStorage = createStorage(
|
|
107
|
-
{ authToken: '' },
|
|
108
|
-
{ ttl: 3600000 } // expires in 1 hour
|
|
109
|
-
);
|
|
110
146
|
```
|
|
147
|
+
1. On createStorage(), reads the saved version from localStorage
|
|
148
|
+
key: 'prefix__version__'
|
|
111
149
|
|
|
112
|
-
|
|
150
|
+
2. If no version saved → new install, saves current version and continues
|
|
113
151
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
152
|
+
3. If saved version < current version:
|
|
153
|
+
→ reads all current data from localStorage
|
|
154
|
+
→ applies each migration in order (v1→v2, v2→v3, etc.)
|
|
155
|
+
→ saves migrated data back to localStorage
|
|
156
|
+
→ updates the version key
|
|
119
157
|
|
|
120
|
-
|
|
121
|
-
// this tab updates automatically
|
|
158
|
+
4. If saved version === current version → nothing to do
|
|
122
159
|
```
|
|
123
160
|
|
|
124
|
-
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## ⚙️ Options
|
|
125
164
|
|
|
126
165
|
```typescript
|
|
127
|
-
const
|
|
128
|
-
{ step: 1 },
|
|
129
|
-
{ storage: 'session' }
|
|
130
|
-
);
|
|
166
|
+
const appStorage = createStorage(schema, options);
|
|
131
167
|
```
|
|
132
168
|
|
|
169
|
+
| Option | Type | Default | Description |
|
|
170
|
+
|--------|------|---------|-------------|
|
|
171
|
+
| `prefix` | `string` | — | Prepends `prefix:` to every key in localStorage |
|
|
172
|
+
| `storage` | `'local' \| 'session'` | `'local'` | Use `sessionStorage` instead of `localStorage` |
|
|
173
|
+
| `ttl` | `number` | — | Time to live in milliseconds — key expires after this time |
|
|
174
|
+
| `sync` | `boolean` | `false` | Sync values across browser tabs via `StorageEvent` |
|
|
175
|
+
| `version` | `number` | — | Current schema version — required for migrations |
|
|
176
|
+
| `migrations` | `Record<number, (data) => data>` | — | Migration functions per version |
|
|
177
|
+
| `encrypt` | `boolean` | `false` | Shows a security warning — see note below |
|
|
178
|
+
|
|
133
179
|
---
|
|
134
180
|
|
|
135
181
|
## 🔔 onChange
|
|
@@ -175,71 +221,45 @@ typed-storage is designed for:
|
|
|
175
221
|
|
|
176
222
|
## 🅰️ Usage with Angular
|
|
177
223
|
|
|
178
|
-
|
|
224
|
+
```bash
|
|
225
|
+
pnpm add @jeanharo98/typed-storage @jeanharo98/typed-storage-angular
|
|
226
|
+
```
|
|
179
227
|
|
|
180
|
-
|
|
181
|
-
import { Service, signal } from '@angular/core';
|
|
182
|
-
import { createStorage } from 'typed-storage';
|
|
228
|
+
See [@jeanharo98/typed-storage-angular](https://github.com/JeanHaro/typed-storage-angular) for full documentation.
|
|
183
229
|
|
|
230
|
+
```typescript
|
|
184
231
|
@Service()
|
|
185
232
|
export class StorageService {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
// Native Angular Signals — reactive in any scenario including zoneless
|
|
196
|
-
theme = signal(this._storage.theme());
|
|
197
|
-
language = signal(this._storage.language());
|
|
198
|
-
fontSize = signal(this._storage.fontSize());
|
|
199
|
-
|
|
200
|
-
setTheme(value: 'dark' | 'light') {
|
|
201
|
-
this._storage.theme.set(value);
|
|
202
|
-
this.theme.set(value);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
setLanguage(value: 'es' | 'en') {
|
|
206
|
-
this._storage.language.set(value);
|
|
207
|
-
this.language.set(value);
|
|
208
|
-
}
|
|
233
|
+
storage: AppStorage;
|
|
234
|
+
|
|
235
|
+
constructor() {
|
|
236
|
+
const ts = new TypedStorageService();
|
|
237
|
+
this.storage = ts.initialize({
|
|
238
|
+
theme: 'dark' as 'dark' | 'light',
|
|
239
|
+
language: 'es' as 'es' | 'en',
|
|
240
|
+
}, { prefix: 'app', sync: true }) as unknown as AppStorage;
|
|
241
|
+
}
|
|
209
242
|
}
|
|
210
243
|
```
|
|
211
244
|
|
|
212
|
-
```html
|
|
213
|
-
<p>Theme: {{ storageService.theme() }}</p>
|
|
214
|
-
<button (click)="storageService.setTheme('light')">Light</button>
|
|
215
|
-
<button (click)="storageService.setTheme('dark')">Dark</button>
|
|
216
|
-
```
|
|
217
|
-
|
|
218
245
|
---
|
|
219
246
|
|
|
220
247
|
## ⚛️ Usage with React
|
|
221
248
|
|
|
222
|
-
```
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const appStorage = createStorage({
|
|
227
|
-
theme: 'dark' as 'dark' | 'light',
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
export function useTheme() {
|
|
231
|
-
const [theme, setThemeState] = useState(appStorage.theme());
|
|
249
|
+
```bash
|
|
250
|
+
pnpm add @jeanharo98/typed-storage @jeanharo98/typed-storage-react
|
|
251
|
+
```
|
|
232
252
|
|
|
233
|
-
|
|
234
|
-
appStorage.theme.onChange(setThemeState);
|
|
235
|
-
}, []);
|
|
253
|
+
See [@jeanharo98/typed-storage-react](https://github.com/JeanHaro/typed-storage-react) for full documentation.
|
|
236
254
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
255
|
+
```typescript
|
|
256
|
+
function App() {
|
|
257
|
+
const storage = useStorage({
|
|
258
|
+
theme: 'dark' as 'dark' | 'light',
|
|
259
|
+
language: 'es' as 'es' | 'en',
|
|
260
|
+
}, { prefix: 'app', sync: true });
|
|
241
261
|
|
|
242
|
-
|
|
262
|
+
return <p>Theme: {storage.theme}</p>;
|
|
243
263
|
}
|
|
244
264
|
```
|
|
245
265
|
|
|
@@ -271,6 +291,15 @@ Creates a storage object from a schema. Returns a `StorageResult<T>` with one `S
|
|
|
271
291
|
|
|
272
292
|
---
|
|
273
293
|
|
|
294
|
+
## 🔗 Related packages
|
|
295
|
+
|
|
296
|
+
| Package | Description |
|
|
297
|
+
|---------|-------------|
|
|
298
|
+
| [@jeanharo98/typed-storage-angular](https://github.com/JeanHaro/typed-storage-angular) | Angular wrapper with native Signals |
|
|
299
|
+
| [@jeanharo98/typed-storage-react](https://github.com/JeanHaro/typed-storage-react) | React wrapper with useStorage() hook |
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
274
303
|
## 📄 License
|
|
275
304
|
|
|
276
305
|
MIT
|
package/dist/create-storage.js
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
import { createStorageSignal } from './storage-signal.js';
|
|
2
|
+
import { applyMigrations } from './migrations.js';
|
|
3
|
+
function registerPrefix(prefix, sto) {
|
|
4
|
+
const registryKey = '__typed-storage__';
|
|
5
|
+
const existing = sto.getItem(registryKey);
|
|
6
|
+
const prefixes = existing ? JSON.parse(existing) : [];
|
|
7
|
+
if (prefix && !prefixes.includes(prefix)) {
|
|
8
|
+
prefixes.push(prefix);
|
|
9
|
+
sto.setItem(registryKey, JSON.stringify(prefixes));
|
|
10
|
+
}
|
|
11
|
+
}
|
|
2
12
|
export function createStorage(schema, options) {
|
|
13
|
+
if (options?.version && options.migrations) {
|
|
14
|
+
const sto = options.storage === 'session' ? sessionStorage : localStorage;
|
|
15
|
+
const prefix = options.prefix ?? '';
|
|
16
|
+
applyMigrations(prefix, options.version, options.migrations, sto);
|
|
17
|
+
}
|
|
18
|
+
const sto = options?.storage === 'session' ? sessionStorage : localStorage;
|
|
19
|
+
registerPrefix(options?.prefix ?? '', sto);
|
|
3
20
|
if (options?.encrypt) {
|
|
4
21
|
console.warn(`
|
|
5
22
|
⚠️ typed-storage: la opción encrypt está activada.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function applyMigrations(prefix: string, currentVersion: number, migrations: Record<number, (data: any) => any>, storage: Storage): void;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function applyMigrations(prefix, currentVersion, migrations, storage) {
|
|
2
|
+
const versionKey = `${prefix}__version__`;
|
|
3
|
+
const savedVersion = storage.getItem(versionKey);
|
|
4
|
+
if (!savedVersion) {
|
|
5
|
+
storage.setItem(versionKey, String(currentVersion));
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
let version = parseInt(savedVersion);
|
|
9
|
+
if (version >= currentVersion)
|
|
10
|
+
return;
|
|
11
|
+
const currentData = {};
|
|
12
|
+
for (let i = 0; i < storage.length; i++) {
|
|
13
|
+
const key = storage.key(i);
|
|
14
|
+
if (key && key.startsWith(prefix) && key !== versionKey) {
|
|
15
|
+
const value = storage.getItem(key);
|
|
16
|
+
if (value) {
|
|
17
|
+
const cleanKey = key.replace(`${prefix}:`, '');
|
|
18
|
+
currentData[cleanKey] = JSON.parse(value);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
while (version < currentVersion) {
|
|
23
|
+
const migration = migrations[version];
|
|
24
|
+
if (migration) {
|
|
25
|
+
const migrated = migration(currentData);
|
|
26
|
+
Object.assign(currentData, migrated);
|
|
27
|
+
}
|
|
28
|
+
version++;
|
|
29
|
+
}
|
|
30
|
+
for (const [key, value] of Object.entries(currentData)) {
|
|
31
|
+
storage.setItem(`${prefix}:${key}`, JSON.stringify(value));
|
|
32
|
+
}
|
|
33
|
+
storage.setItem(versionKey, String(currentVersion));
|
|
34
|
+
}
|
package/dist/types.d.ts
CHANGED
package/index.html
CHANGED
|
@@ -7,55 +7,35 @@
|
|
|
7
7
|
<script type="module">
|
|
8
8
|
import { createStorage } from './dist/index.js';
|
|
9
9
|
|
|
10
|
+
// Simula datos viejos en localStorage (schema v1)
|
|
11
|
+
localStorage.setItem('app:theme', '"dark"');
|
|
12
|
+
localStorage.setItem('app:fontSize', '16');
|
|
13
|
+
localStorage.setItem('app__version__', '1');
|
|
14
|
+
|
|
15
|
+
// Crea el storage con schema v2 y migración
|
|
10
16
|
const appStorage = createStorage({
|
|
11
17
|
theme: 'dark',
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
preferences: {
|
|
19
|
+
fontSize: 16,
|
|
20
|
+
language: 'es'
|
|
21
|
+
}
|
|
22
|
+
}, {
|
|
23
|
+
prefix: 'app',
|
|
24
|
+
version: 2,
|
|
25
|
+
migrations: {
|
|
26
|
+
1: (oldData) => ({
|
|
27
|
+
theme: oldData.theme,
|
|
28
|
+
preferences: {
|
|
29
|
+
fontSize: oldData.fontSize,
|
|
30
|
+
language: 'es'
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
}
|
|
18
34
|
});
|
|
19
35
|
|
|
20
|
-
// Prueba 1 — valores iniciales
|
|
21
36
|
console.log('theme:', appStorage.theme());
|
|
22
|
-
console.log('
|
|
23
|
-
|
|
24
|
-
// Prueba 2 — set
|
|
25
|
-
appStorage.theme.set('light');
|
|
26
|
-
console.log('theme después de set:', appStorage.theme());
|
|
27
|
-
|
|
28
|
-
// Prueba 3 — reset
|
|
29
|
-
appStorage.theme.reset();
|
|
30
|
-
console.log('theme después de reset:', appStorage.theme());
|
|
31
|
-
|
|
32
|
-
// Prueba 4 — clear
|
|
33
|
-
appStorage.clear();
|
|
34
|
-
console.log('theme después de clear:', appStorage.theme());
|
|
35
|
-
|
|
36
|
-
// Prueba 5 — warning encrypt
|
|
37
|
-
const sensitiveStorage = createStorage({
|
|
38
|
-
token: '',
|
|
39
|
-
}, { encrypt: true });
|
|
40
|
-
|
|
41
|
-
// Prueba has()
|
|
42
|
-
console.log('¿tiene theme?', appStorage.theme.has());
|
|
43
|
-
|
|
44
|
-
// Prueba onChange()
|
|
45
|
-
appStorage.theme.onChange((newValue) => {
|
|
46
|
-
console.log('theme cambió a:', newValue);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
// Prueba set — debería disparar onChange
|
|
50
|
-
appStorage.theme.set('light');
|
|
51
|
-
appStorage.theme.set('dark');
|
|
52
|
-
|
|
53
|
-
// Prueba reset — debería disparar onChange
|
|
54
|
-
appStorage.theme.reset();
|
|
55
|
-
|
|
56
|
-
// Prueba remove — debería disparar onChange
|
|
57
|
-
appStorage.theme.remove();
|
|
58
|
-
console.log('¿tiene theme después de remove?', appStorage.theme.has());
|
|
37
|
+
console.log('preferences:', appStorage.preferences());
|
|
38
|
+
console.log('version en localStorage:', localStorage.getItem('app__version__'));
|
|
59
39
|
</script>
|
|
60
40
|
</body>
|
|
61
41
|
</html>
|
package/package.json
CHANGED
package/src/create-storage.ts
CHANGED
|
@@ -1,10 +1,47 @@
|
|
|
1
|
+
// Types
|
|
1
2
|
import { StorageSchema, StorageResult, StorageSignalOptions } from './types.js';
|
|
3
|
+
|
|
4
|
+
// Storage Signal
|
|
2
5
|
import { createStorageSignal } from './storage-signal.js';
|
|
3
6
|
|
|
7
|
+
// Migraciones
|
|
8
|
+
import { applyMigrations } from './migrations.js';
|
|
9
|
+
|
|
10
|
+
function registerPrefix (
|
|
11
|
+
prefix: string,
|
|
12
|
+
sto: Storage
|
|
13
|
+
): void {
|
|
14
|
+
const registryKey = '__typed-storage__';
|
|
15
|
+
const existing = sto.getItem(registryKey);
|
|
16
|
+
const prefixes: string[] = existing ? JSON.parse(existing) : [];
|
|
17
|
+
|
|
18
|
+
if ( prefix && !prefixes.includes(prefix) ) {
|
|
19
|
+
prefixes.push(prefix);
|
|
20
|
+
sto.setItem(registryKey, JSON.stringify(prefixes));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
4
24
|
export function createStorage<T extends StorageSchema>(
|
|
5
25
|
schema: T,
|
|
6
26
|
options?: StorageSignalOptions
|
|
7
27
|
): StorageResult<T> {
|
|
28
|
+
// Migraciones
|
|
29
|
+
if ( options?.version && options.migrations ) {
|
|
30
|
+
const sto = options.storage === 'session' ? sessionStorage : localStorage;
|
|
31
|
+
const prefix = options.prefix ?? '';
|
|
32
|
+
|
|
33
|
+
applyMigrations(
|
|
34
|
+
prefix,
|
|
35
|
+
options.version,
|
|
36
|
+
options.migrations,
|
|
37
|
+
sto
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Registramos el prefix en localStorage
|
|
42
|
+
const sto = options?.storage === 'session' ? sessionStorage : localStorage;
|
|
43
|
+
registerPrefix(options?.prefix ?? '', sto);
|
|
44
|
+
|
|
8
45
|
if ( options?.encrypt ) {
|
|
9
46
|
console.warn(`
|
|
10
47
|
⚠️ typed-storage: la opción encrypt está activada.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { applyMigrations } from './migrations';
|
|
3
|
+
|
|
4
|
+
describe('applyMigrations', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
localStorage.clear();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('debe guardar la versión inicial si no hay datos previos', () => {
|
|
10
|
+
applyMigrations('app', 2, {
|
|
11
|
+
1: (oldData) => ({
|
|
12
|
+
theme: oldData.theme,
|
|
13
|
+
preferences: {
|
|
14
|
+
fontSize: oldData.fontSize,
|
|
15
|
+
language: 'es'
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
}, localStorage);
|
|
19
|
+
|
|
20
|
+
const valor = localStorage.getItem('app__version__');
|
|
21
|
+
|
|
22
|
+
expect(valor).toBe('2');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('no debe hacer nada si la versión es igual', () => {
|
|
26
|
+
// Guarda datos con versión 2 ya guardada
|
|
27
|
+
localStorage.setItem('app__version__', '2');
|
|
28
|
+
localStorage.setItem('app:theme', '"dark"');
|
|
29
|
+
|
|
30
|
+
applyMigrations('app', 2, {
|
|
31
|
+
1: (oldData) => ({
|
|
32
|
+
theme: 'light'
|
|
33
|
+
})
|
|
34
|
+
}, localStorage);
|
|
35
|
+
|
|
36
|
+
// El theme no debe haber cambiado - la migración no se ejecutó
|
|
37
|
+
expect(localStorage.getItem('app:theme')).toBe('"dark"');
|
|
38
|
+
expect(localStorage.getItem('app__version__')).toBe('2');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('debe aplicar migración de v1 a v2', () => {
|
|
42
|
+
// Guarda datos v1 en localStorage
|
|
43
|
+
localStorage.setItem('app__version__', '1');
|
|
44
|
+
localStorage.setItem('app:theme', '"dark"');
|
|
45
|
+
localStorage.setItem('app:fontSize', '16');
|
|
46
|
+
|
|
47
|
+
applyMigrations('app', 2, {
|
|
48
|
+
1: (oldData) => ({
|
|
49
|
+
theme: oldData.theme,
|
|
50
|
+
preferences: {
|
|
51
|
+
fontSize: oldData.fontSize,
|
|
52
|
+
language: 'es'
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
}, localStorage);
|
|
56
|
+
|
|
57
|
+
// Verifica que los datos fueron migrados correctamente
|
|
58
|
+
expect(localStorage.getItem('app__version__')).toBe('2');
|
|
59
|
+
expect(localStorage.getItem('app:theme')).toBe('"dark"');
|
|
60
|
+
|
|
61
|
+
const preferences = JSON.parse(localStorage.getItem('app:preferences')!);
|
|
62
|
+
expect(preferences).toEqual({ fontSize: 16, language: 'es' });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('debe aplicar migraciones encadenadas v1 -> v2 -> v3', () => {
|
|
66
|
+
// Guarda datos v1
|
|
67
|
+
localStorage.setItem('app__version__', '1');
|
|
68
|
+
localStorage.setItem('app:theme', '"dark"');
|
|
69
|
+
localStorage.setItem('app:fontSize', '16');
|
|
70
|
+
|
|
71
|
+
applyMigrations('app', 3, {
|
|
72
|
+
// v1 -> v2: mueve fontSize a preferences
|
|
73
|
+
1: (oldData) => ({
|
|
74
|
+
theme: oldData.theme,
|
|
75
|
+
preferences: {
|
|
76
|
+
fontSize: oldData.fontSize,
|
|
77
|
+
language: 'es'
|
|
78
|
+
}
|
|
79
|
+
}),
|
|
80
|
+
// v2 -> v3: añade sidebarOpen a preferences
|
|
81
|
+
2: (oldData) => ({
|
|
82
|
+
...oldData,
|
|
83
|
+
preferences: {
|
|
84
|
+
...oldData.preferences,
|
|
85
|
+
sidebarOpen: true
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
}, localStorage);
|
|
89
|
+
|
|
90
|
+
// Verifica versión final
|
|
91
|
+
expect(localStorage.getItem('app__version__')).toBe('3');
|
|
92
|
+
|
|
93
|
+
// Verifica datos finales
|
|
94
|
+
const preferences = JSON.parse(localStorage.getItem('app:preferences')!);
|
|
95
|
+
expect(preferences).toEqual({
|
|
96
|
+
fontSize: 16,
|
|
97
|
+
language: 'es',
|
|
98
|
+
sidebarOpen: true
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export function applyMigrations(
|
|
2
|
+
prefix: string,
|
|
3
|
+
currentVersion: number,
|
|
4
|
+
migrations: Record<number, ( data: any ) => any>,
|
|
5
|
+
storage: Storage
|
|
6
|
+
): void {
|
|
7
|
+
const versionKey = `${prefix}__version__`;
|
|
8
|
+
const savedVersion = storage.getItem(versionKey);
|
|
9
|
+
|
|
10
|
+
// Si no hay versión, colocamos datos nuevos, guardamos versión actual y salimos
|
|
11
|
+
if ( !savedVersion ) {
|
|
12
|
+
storage.setItem(versionKey, String(currentVersion));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let version = parseInt(savedVersion);
|
|
17
|
+
|
|
18
|
+
// Si ya esta en la versión actual, no hacemos nada
|
|
19
|
+
if ( version >= currentVersion ) return;
|
|
20
|
+
|
|
21
|
+
// Leemos todos los datos actuales
|
|
22
|
+
const currentData: any = {};
|
|
23
|
+
|
|
24
|
+
// Iteramos las keys del storage que empiecen con el prefix
|
|
25
|
+
for ( let i = 0; i < storage.length; i++ ) {
|
|
26
|
+
const key = storage.key(i);
|
|
27
|
+
|
|
28
|
+
if (key && key.startsWith(prefix) && key !== versionKey ) {
|
|
29
|
+
const value = storage.getItem(key);
|
|
30
|
+
|
|
31
|
+
if ( value ) {
|
|
32
|
+
// quitamos el prefix para obtener el nombre real de la key
|
|
33
|
+
const cleanKey = key.replace(`${prefix}:`, '');
|
|
34
|
+
currentData[cleanKey] = JSON.parse(value);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Aplica migraciones en orden
|
|
40
|
+
while ( version < currentVersion ) {
|
|
41
|
+
const migration = migrations[version];
|
|
42
|
+
|
|
43
|
+
if ( migration ) {
|
|
44
|
+
const migrated = migration(currentData);
|
|
45
|
+
|
|
46
|
+
// Actualizamos currentData con los datos migrados
|
|
47
|
+
Object.assign(currentData, migrated);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
version++;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Guardamos los datos migrados
|
|
54
|
+
for ( const [key, value] of Object.entries(currentData) ) {
|
|
55
|
+
storage.setItem(`${prefix}:${key}`, JSON.stringify(value));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Actualizamos la versión
|
|
59
|
+
storage.setItem(versionKey, String(currentVersion));
|
|
60
|
+
}
|