@jeanharo98/typed-storage 0.1.1 → 0.1.3

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 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
- ## ⚙️ Options
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
- const appStorage = createStorage(schema, options);
81
- ```
90
+ // Version 1 what users had stored:
91
+ // localStorage['app:theme'] = '"dark"'
92
+ // localStorage['app:fontSize'] = '16'
82
93
 
83
- | Option | Type | Default | Description |
84
- |--------|------|---------|-------------|
85
- | `prefix` | `string` | — | Prepends `prefix:` to every key in localStorage |
86
- | `storage` | `'local' \| 'session'` | `'local'` | Use `sessionStorage` instead of `localStorage` |
87
- | `ttl` | `number` | — | Time to live in milliseconds — key expires after this time |
88
- | `sync` | `boolean` | `false` | Sync values across browser tabs via `StorageEvent` |
89
- | `encrypt` | `boolean` | `false` | Shows a security warning — see note below |
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
- ### Prefix
115
+ // Old data is automatically migrated on first load
116
+ console.log(appStorage.preferences()); // { fontSize: 16, language: 'es' }
117
+ ```
92
118
 
93
- ```typescript
94
- const appStorage = createStorage(
95
- { theme: 'dark' },
96
- { prefix: 'myapp' }
97
- );
119
+ ### Chained migrations (v1 → v2 → v3)
98
120
 
99
- appStorage.theme.set('light');
100
- // Stored as: localStorage['myapp:theme'] = '"light"'
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
- ### TTL
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
- ### Cross-tab sync
150
+ 2. If no version saved → new install, saves current version and continues
113
151
 
114
- ```typescript
115
- const appStorage = createStorage(
116
- { theme: 'dark' },
117
- { sync: true }
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
- // When another tab calls appStorage.theme.set('light'),
121
- // this tab updates automatically
158
+ 4. If saved version === current version → nothing to do
122
159
  ```
123
160
 
124
- ### sessionStorage
161
+ ---
162
+
163
+ ## ⚙️ Options
125
164
 
126
165
  ```typescript
127
- const sessionData = createStorage(
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
- typed-storage is framework-agnostic. For Angular, wrap it in a service with native Signals:
224
+ ```bash
225
+ pnpm add @jeanharo98/typed-storage @jeanharo98/typed-storage-angular
226
+ ```
179
227
 
180
- ```typescript
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
- private _storage = createStorage({
187
- theme: 'dark' as 'dark' | 'light',
188
- language: 'es' as 'es' | 'en',
189
- fontSize: 16,
190
- }, {
191
- prefix: 'app',
192
- sync: true,
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
- ```typescript
223
- import { useState, useEffect } from 'react';
224
- import { createStorage } from 'typed-storage';
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
- useEffect(() => {
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
- const setTheme = (value: 'dark' | 'light') => {
238
- appStorage.theme.set(value);
239
- setThemeState(value);
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
- return { theme, setTheme };
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
@@ -1,5 +1,11 @@
1
1
  import { createStorageSignal } from './storage-signal.js';
2
+ import { applyMigrations } from './migrations.js';
2
3
  export function createStorage(schema, options) {
4
+ if (options?.version && options.migrations) {
5
+ const sto = options.storage === 'session' ? sessionStorage : localStorage;
6
+ const prefix = options.prefix ?? '';
7
+ applyMigrations(prefix, options.version, options.migrations, sto);
8
+ }
3
9
  if (options?.encrypt) {
4
10
  console.warn(`
5
11
  ⚠️ 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
@@ -4,6 +4,8 @@ export interface StorageSignalOptions {
4
4
  ttl?: number;
5
5
  sync?: boolean;
6
6
  encrypt?: boolean;
7
+ version?: number;
8
+ migrations?: Record<number, (data: any) => any>;
7
9
  }
8
10
  export interface StorageSignal<T> {
9
11
  (): T;
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
- fontSize: 16,
13
- sidebarOpen: true,
14
- }, {
15
- prefix: 'myapp',
16
- ttl: 5000, // expira en 5 segundos — para probar
17
- sync: true
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('fontSize:', appStorage.fontSize());
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
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@jeanharo98/typed-storage",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Type-safe localStorage with reactive signals",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "keywords": [],
8
8
  "author": "",
9
- "license": "ISC",
9
+ "license": "MIT",
10
10
  "devEngines": {
11
11
  "packageManager": {
12
12
  "name": "pnpm",
@@ -1,10 +1,28 @@
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
+
4
10
  export function createStorage<T extends StorageSchema>(
5
11
  schema: T,
6
12
  options?: StorageSignalOptions
7
13
  ): StorageResult<T> {
14
+ if ( options?.version && options.migrations ) {
15
+ const sto = options.storage === 'session' ? sessionStorage : localStorage;
16
+ const prefix = options.prefix ?? '';
17
+
18
+ applyMigrations(
19
+ prefix,
20
+ options.version,
21
+ options.migrations,
22
+ sto
23
+ );
24
+ }
25
+
8
26
  if ( options?.encrypt ) {
9
27
  console.warn(`
10
28
  ⚠️ 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
+ }
package/src/types.ts CHANGED
@@ -5,6 +5,8 @@ export interface StorageSignalOptions {
5
5
  ttl?: number;
6
6
  sync?: boolean;
7
7
  encrypt?: boolean;
8
+ version?: number;
9
+ migrations?: Record<number, ( data: any ) => any>;
8
10
  }
9
11
 
10
12
  // Objeto reactivo con getter, setter y reset