@jeanharo98/typed-storage 0.1.4 → 0.1.6
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 +86 -1
- package/dist/heavy-storage.d.ts +19 -0
- package/dist/heavy-storage.js +89 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/storage-signal.js +20 -5
- package/dist/types.d.ts +1 -0
- package/index.html +31 -27
- package/package.json +8 -2
- package/src/heavy-storage.test.ts +99 -0
- package/src/heavy-storage.ts +146 -0
- package/src/index.ts +7 -1
- package/src/storage-signal.test.ts +34 -0
- package/src/storage-signal.ts +29 -11
- package/src/types.ts +1 -0
- package/vitest.config.ts +1 -0
- package/vitest.setup.ts +1 -0
package/README.md
CHANGED
|
@@ -160,7 +160,56 @@ createStorage(schema, {
|
|
|
160
160
|
|
|
161
161
|
---
|
|
162
162
|
|
|
163
|
-
##
|
|
163
|
+
## 🗄️ Heavy data with IndexedDB
|
|
164
|
+
|
|
165
|
+
For large datasets that exceed `localStorage`'s ~5MB limit (file lists, extensive history, large collections), use `createHeavyStorage` — a separate async API backed by IndexedDB.
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
import { createHeavyStorage } from 'typed-storage';
|
|
169
|
+
|
|
170
|
+
const heavyStorage = createHeavyStorage({
|
|
171
|
+
documents: [] as Document[],
|
|
172
|
+
userPhotos: [] as Photo[]
|
|
173
|
+
}, {
|
|
174
|
+
dbName: 'myapp-storage',
|
|
175
|
+
ttl: 86400000 // optional — same TTL support as the sync API
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// All operations are async — IndexedDB is asynchronous by nature
|
|
179
|
+
await heavyStorage.documents.set([...manyDocuments]);
|
|
180
|
+
const docs = await heavyStorage.documents.get();
|
|
181
|
+
await heavyStorage.documents.remove();
|
|
182
|
+
|
|
183
|
+
heavyStorage.documents.onChange((newValue) => {
|
|
184
|
+
console.log('documents changed:', newValue);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await heavyStorage.clear();
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Why a separate API?
|
|
191
|
+
|
|
192
|
+
`createStorage()` uses a synchronous Signal-like API by design — that's the core value of typed-storage. IndexedDB is asynchronous by nature, so mixing it into the same API would break that synchronous contract.
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
createStorage() → sync, Signal-like, for UI preferences and small state
|
|
196
|
+
createHeavyStorage() → async, Promise-based, for large datasets
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
If you only need small values (theme, language, settings), stick with `createStorage()`. Use `createHeavyStorage()` only when you specifically need to store data beyond localStorage's size limits.
|
|
200
|
+
|
|
201
|
+
### `HeavySignal<T>` API
|
|
202
|
+
|
|
203
|
+
| Member | Description |
|
|
204
|
+
|--------|-------------|
|
|
205
|
+
| `signal.get()` | Returns a Promise with the current value |
|
|
206
|
+
| `signal.set(value)` | Stores the value, returns a Promise |
|
|
207
|
+
| `signal.remove()` | Deletes the value, returns a Promise |
|
|
208
|
+
| `signal.onChange(cb)` | Subscribes to value changes (called synchronously after set/remove) |
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
|
|
164
213
|
|
|
165
214
|
```typescript
|
|
166
215
|
const appStorage = createStorage(schema, options);
|
|
@@ -174,10 +223,45 @@ const appStorage = createStorage(schema, options);
|
|
|
174
223
|
| `sync` | `boolean` | `false` | Sync values across browser tabs via `StorageEvent` |
|
|
175
224
|
| `version` | `number` | — | Current schema version — required for migrations |
|
|
176
225
|
| `migrations` | `Record<number, (data) => data>` | — | Migration functions per version |
|
|
226
|
+
| `compress` | `boolean` | `false` | Compresses data with LZ-string before storing |
|
|
177
227
|
| `encrypt` | `boolean` | `false` | Shows a security warning — see note below |
|
|
178
228
|
|
|
179
229
|
---
|
|
180
230
|
|
|
231
|
+
## 📦 Compression
|
|
232
|
+
|
|
233
|
+
For large or repetitive data (lists, history, complex objects), enable compression to reduce the space used in `localStorage`:
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
const appStorage = createStorage({
|
|
237
|
+
cart: { items: [] }
|
|
238
|
+
}, {
|
|
239
|
+
prefix: 'shop',
|
|
240
|
+
compress: true
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
appStorage.cart.set({ items: [...manyProducts] });
|
|
244
|
+
// Data is compressed with LZ-string before saving
|
|
245
|
+
// and decompressed automatically when read
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### When to use it
|
|
249
|
+
|
|
250
|
+
```
|
|
251
|
+
✅ Useful for:
|
|
252
|
+
- Large lists (shopping carts, history)
|
|
253
|
+
- Repetitive JSON structures
|
|
254
|
+
- Data approaching localStorage's ~5MB limit
|
|
255
|
+
|
|
256
|
+
❌ Not needed for:
|
|
257
|
+
- Small values like theme, language, fontSize
|
|
258
|
+
- The compression overhead isn't worth it for tiny data
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Compression only runs when `compress: true` is explicitly set — there's zero overhead for the default use case.
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
181
265
|
## 🔔 onChange
|
|
182
266
|
|
|
183
267
|
Subscribe to changes on any key:
|
|
@@ -297,6 +381,7 @@ Creates a storage object from a schema. Returns a `StorageResult<T>` with one `S
|
|
|
297
381
|
|---------|-------------|
|
|
298
382
|
| [@jeanharo98/typed-storage-angular](https://github.com/JeanHaro/typed-storage-angular) | Angular wrapper with native Signals |
|
|
299
383
|
| [@jeanharo98/typed-storage-react](https://github.com/JeanHaro/typed-storage-react) | React wrapper with useStorage() hook |
|
|
384
|
+
| [typed-storage-devtools](https://github.com/JeanHaro/typed-storage-devtools) | Chrome DevTools extension for real-time inspection |
|
|
300
385
|
|
|
301
386
|
---
|
|
302
387
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
interface HeavyStorageOptions {
|
|
2
|
+
dbName?: string;
|
|
3
|
+
ttl?: number;
|
|
4
|
+
}
|
|
5
|
+
interface HeavySignal<T> {
|
|
6
|
+
get(): Promise<T>;
|
|
7
|
+
set(value: T): Promise<void>;
|
|
8
|
+
remove(): Promise<void>;
|
|
9
|
+
onChange(callback: (value: T) => void): void;
|
|
10
|
+
}
|
|
11
|
+
export declare function createHeavySignal<T>(key: string, initialValue: T, options?: HeavyStorageOptions): HeavySignal<T>;
|
|
12
|
+
type HeavyStorageSchema = Record<string, any>;
|
|
13
|
+
type HeavyStorageResult<T extends HeavyStorageSchema> = {
|
|
14
|
+
[K in keyof T]: HeavySignal<T[K]>;
|
|
15
|
+
} & {
|
|
16
|
+
clear(): Promise<void>;
|
|
17
|
+
};
|
|
18
|
+
export declare function createHeavyStorage<T extends HeavyStorageSchema>(schema: T, options?: HeavyStorageOptions): HeavyStorageResult<T>;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
function openDB(dbName) {
|
|
2
|
+
return new Promise((resolve, reject) => {
|
|
3
|
+
const request = indexedDB.open(dbName, 1);
|
|
4
|
+
request.onupgradeneeded = (event) => {
|
|
5
|
+
const db = event.target.result;
|
|
6
|
+
if (!db.objectStoreNames.contains('storage')) {
|
|
7
|
+
db.createObjectStore('storage');
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
request.onsuccess = () => resolve(request.result);
|
|
11
|
+
request.onerror = () => reject(request.error);
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
function dbGet(db, key) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const transaction = db.transaction('storage', 'readonly');
|
|
17
|
+
const store = transaction.objectStore('storage');
|
|
18
|
+
const request = store.get(key);
|
|
19
|
+
request.onsuccess = () => resolve(request.result);
|
|
20
|
+
request.onerror = () => reject(request.error);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function dbSet(db, key, value) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const transaction = db.transaction('storage', 'readwrite');
|
|
26
|
+
const store = transaction.objectStore('storage');
|
|
27
|
+
const request = store.put(value, key);
|
|
28
|
+
request.onsuccess = () => resolve();
|
|
29
|
+
request.onerror = () => reject(request.error);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function dbDelete(db, key) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const transaction = db.transaction('storage', 'readwrite');
|
|
35
|
+
const store = transaction.objectStore('storage');
|
|
36
|
+
const request = store.delete(key);
|
|
37
|
+
request.onsuccess = () => resolve();
|
|
38
|
+
request.onerror = () => reject(request.error);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export function createHeavySignal(key, initialValue, options) {
|
|
42
|
+
const dbName = options?.dbName ?? 'typed-storage-heavy';
|
|
43
|
+
const listeners = [];
|
|
44
|
+
function notify(value) {
|
|
45
|
+
listeners.forEach(cb => cb(value));
|
|
46
|
+
}
|
|
47
|
+
const signal = {};
|
|
48
|
+
signal.get = async function () {
|
|
49
|
+
const db = await openDB(dbName);
|
|
50
|
+
const stored = await dbGet(db, key);
|
|
51
|
+
if (stored === undefined)
|
|
52
|
+
return initialValue;
|
|
53
|
+
if (stored.expiresAt && Date.now() > stored.expiresAt) {
|
|
54
|
+
await dbDelete(db, key);
|
|
55
|
+
return initialValue;
|
|
56
|
+
}
|
|
57
|
+
return stored.value ?? initialValue;
|
|
58
|
+
};
|
|
59
|
+
signal.set = async function (value) {
|
|
60
|
+
const db = await openDB(dbName);
|
|
61
|
+
await dbSet(db, key, {
|
|
62
|
+
value,
|
|
63
|
+
expiresAt: options?.ttl ? Date.now() + options.ttl : undefined
|
|
64
|
+
});
|
|
65
|
+
notify(value);
|
|
66
|
+
};
|
|
67
|
+
signal.remove = async function () {
|
|
68
|
+
const db = await openDB(dbName);
|
|
69
|
+
await dbDelete(db, key);
|
|
70
|
+
notify(initialValue);
|
|
71
|
+
};
|
|
72
|
+
signal.onChange = function (callback) {
|
|
73
|
+
listeners.push(callback);
|
|
74
|
+
};
|
|
75
|
+
return signal;
|
|
76
|
+
}
|
|
77
|
+
export function createHeavyStorage(schema, options) {
|
|
78
|
+
const result = {};
|
|
79
|
+
const keys = Object.keys(schema);
|
|
80
|
+
for (const key of keys) {
|
|
81
|
+
result[key] = createHeavySignal(key, schema[key], options);
|
|
82
|
+
}
|
|
83
|
+
result.clear = async () => {
|
|
84
|
+
for (const key of keys) {
|
|
85
|
+
await result[key].remove();
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
return result;
|
|
89
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/storage-signal.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import LZString from 'lz-string';
|
|
1
2
|
import { MemoryStorage } from "./memory-storage.js";
|
|
2
3
|
function safeParseJSON(value, fallback) {
|
|
3
4
|
if (!value)
|
|
@@ -31,7 +32,10 @@ export function createStorageSignal(key, initialValue, options) {
|
|
|
31
32
|
if (options?.prefix) {
|
|
32
33
|
key = `${options.prefix}:${key}`;
|
|
33
34
|
}
|
|
34
|
-
const
|
|
35
|
+
const rawData = sto.getItem(key);
|
|
36
|
+
const savedData = options?.compress && rawData
|
|
37
|
+
? LZString.decompress(rawData)
|
|
38
|
+
: rawData;
|
|
35
39
|
let currentValue;
|
|
36
40
|
const listeners = [];
|
|
37
41
|
function notify(value) {
|
|
@@ -60,7 +64,10 @@ export function createStorageSignal(key, initialValue, options) {
|
|
|
60
64
|
notify(initialValue);
|
|
61
65
|
return currentValue = initialValue;
|
|
62
66
|
}
|
|
63
|
-
const
|
|
67
|
+
const rawNewValue = options?.compress
|
|
68
|
+
? LZString.decompress(event.newValue)
|
|
69
|
+
: event.newValue;
|
|
70
|
+
const item = safeParseJSON(rawNewValue, initialValue);
|
|
64
71
|
notify(item.value);
|
|
65
72
|
return currentValue = item.value;
|
|
66
73
|
}
|
|
@@ -69,15 +76,23 @@ export function createStorageSignal(key, initialValue, options) {
|
|
|
69
76
|
signalBase.set = function (newValue) {
|
|
70
77
|
currentValue = newValue;
|
|
71
78
|
notify(currentValue);
|
|
72
|
-
|
|
79
|
+
const dataToStore = JSON.stringify({
|
|
73
80
|
value: newValue,
|
|
74
81
|
expiresAt: options?.ttl ? Date.now() + options.ttl : undefined
|
|
75
|
-
})
|
|
82
|
+
});
|
|
83
|
+
const finalData = options?.compress
|
|
84
|
+
? LZString.compress(dataToStore)
|
|
85
|
+
: dataToStore;
|
|
86
|
+
sto.setItem(key, finalData);
|
|
76
87
|
};
|
|
77
88
|
signalBase.reset = function () {
|
|
78
89
|
currentValue = initialValue;
|
|
79
90
|
notify(currentValue);
|
|
80
|
-
|
|
91
|
+
const dataToStore = JSON.stringify(initialValue);
|
|
92
|
+
const finalData = options?.compress
|
|
93
|
+
? LZString.compress(dataToStore)
|
|
94
|
+
: dataToStore;
|
|
95
|
+
sto.setItem(key, finalData);
|
|
81
96
|
};
|
|
82
97
|
signalBase.has = function () {
|
|
83
98
|
return !!sto.getItem(key);
|
package/dist/types.d.ts
CHANGED
package/index.html
CHANGED
|
@@ -5,37 +5,41 @@
|
|
|
5
5
|
</head>
|
|
6
6
|
<body>
|
|
7
7
|
<script type="module">
|
|
8
|
-
import {
|
|
8
|
+
import { createHeavyStorage } from './dist/index.js';
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
localStorage.setItem('app__version__', '1');
|
|
14
|
-
|
|
15
|
-
// Crea el storage con schema v2 y migración
|
|
16
|
-
const appStorage = createStorage({
|
|
17
|
-
theme: 'dark',
|
|
18
|
-
preferences: {
|
|
19
|
-
fontSize: 16,
|
|
20
|
-
language: 'es'
|
|
21
|
-
}
|
|
10
|
+
const heavyStorage = createHeavyStorage({
|
|
11
|
+
documents: [],
|
|
12
|
+
userPhotos: []
|
|
22
13
|
}, {
|
|
23
|
-
|
|
24
|
-
version: 2,
|
|
25
|
-
migrations: {
|
|
26
|
-
1: (oldData) => ({
|
|
27
|
-
theme: oldData.theme,
|
|
28
|
-
preferences: {
|
|
29
|
-
fontSize: oldData.fontSize,
|
|
30
|
-
language: 'es'
|
|
31
|
-
}
|
|
32
|
-
})
|
|
33
|
-
}
|
|
14
|
+
dbName: 'test-heavy-storage'
|
|
34
15
|
});
|
|
35
16
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
17
|
+
async function test() {
|
|
18
|
+
// Set
|
|
19
|
+
await heavyStorage.documents.set([
|
|
20
|
+
{ id: 1, name: 'Documento 1' },
|
|
21
|
+
{ id: 2, name: 'Documento 2' }
|
|
22
|
+
]);
|
|
23
|
+
console.log('✅ Documentos guardados');
|
|
24
|
+
|
|
25
|
+
// Get
|
|
26
|
+
const docs = await heavyStorage.documents.get();
|
|
27
|
+
console.log('📄 Documentos leídos:', docs);
|
|
28
|
+
|
|
29
|
+
// onChange
|
|
30
|
+
heavyStorage.documents.onChange((newValue) => {
|
|
31
|
+
console.log('🔔 documents cambió a:', newValue);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
await heavyStorage.documents.set([{ id: 3, name: 'Documento nuevo' }]);
|
|
35
|
+
|
|
36
|
+
// Remove
|
|
37
|
+
await heavyStorage.userPhotos.remove();
|
|
38
|
+
const photos = await heavyStorage.userPhotos.get();
|
|
39
|
+
console.log('🗑️ userPhotos después de remove:', photos);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test();
|
|
39
43
|
</script>
|
|
40
44
|
</body>
|
|
41
45
|
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jeanharo98/typed-storage",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Type-safe localStorage with reactive signals",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -16,9 +16,11 @@
|
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
18
|
"@vitest/coverage-v8": "^4.1.8",
|
|
19
|
+
"fake-indexeddb": "^6.2.5",
|
|
19
20
|
"jsdom": "^29.1.1",
|
|
20
21
|
"ts-node": "^10.9.2",
|
|
21
22
|
"typescript": "^6.0.3",
|
|
23
|
+
"vite": "^8.0.16",
|
|
22
24
|
"vitest": "^4.1.8"
|
|
23
25
|
},
|
|
24
26
|
"repository": {
|
|
@@ -29,10 +31,14 @@
|
|
|
29
31
|
"bugs": {
|
|
30
32
|
"url": "https://github.com/JeanHaro/typed-storage/issues"
|
|
31
33
|
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"lz-string": "^1.5.0"
|
|
36
|
+
},
|
|
32
37
|
"scripts": {
|
|
33
38
|
"dev": "ts-node src/index.ts",
|
|
34
39
|
"build": "tsc",
|
|
35
40
|
"test": "vitest",
|
|
36
|
-
"test:coverage": "vitest --coverage"
|
|
41
|
+
"test:coverage": "vitest --coverage",
|
|
42
|
+
"serve": "vite"
|
|
37
43
|
}
|
|
38
44
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { createHeavyStorage } from './heavy-storage.js';
|
|
3
|
+
|
|
4
|
+
describe('createHeavyStorage', () => {
|
|
5
|
+
|
|
6
|
+
it('debe retornar el initialValue si no hay datos guardados', async () => {
|
|
7
|
+
// crea heavyStorage con dbName único para este test
|
|
8
|
+
const heavyStorage = createHeavyStorage({
|
|
9
|
+
documents: [],
|
|
10
|
+
userPhotos: []
|
|
11
|
+
}, {
|
|
12
|
+
dbName: 'test-heavy-storage'
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// verifica que get() retorna el initialValue
|
|
16
|
+
const result = await heavyStorage.documents.get();
|
|
17
|
+
expect(result).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('debe guardar y leer un valor con set/get', async () => {
|
|
21
|
+
// crea heavyStorage
|
|
22
|
+
const heavyStorage = createHeavyStorage({
|
|
23
|
+
documents: [] as any[]
|
|
24
|
+
}, {
|
|
25
|
+
dbName: 'test-db-2'
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
// llama set() con un valor
|
|
30
|
+
const newDocs = [{ id: 1, name: 'Doc 1' }];
|
|
31
|
+
await heavyStorage.documents.set(newDocs);
|
|
32
|
+
|
|
33
|
+
// verifica que get() retorna ese valor
|
|
34
|
+
const result = await heavyStorage.documents.get();
|
|
35
|
+
expect(result).toEqual(newDocs);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('debe eliminar un valor con remove()', async () => {
|
|
39
|
+
// crea heavyStorage, set() un valor
|
|
40
|
+
const heavyStorage = createHeavyStorage({
|
|
41
|
+
documents: [] as any[]
|
|
42
|
+
}, {
|
|
43
|
+
dbName: 'test-db-3'
|
|
44
|
+
});
|
|
45
|
+
await heavyStorage.documents.set([{ id: 1, name: 'Doc 1' }]);
|
|
46
|
+
|
|
47
|
+
// llama remove()
|
|
48
|
+
await heavyStorage.documents.remove();
|
|
49
|
+
|
|
50
|
+
// verifica que get() retorna el initialValue
|
|
51
|
+
const result = await heavyStorage.documents.get();
|
|
52
|
+
expect(result).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('debe notificar a onChange cuando se llama set()', async () => {
|
|
56
|
+
// usa vi.fn() como callback
|
|
57
|
+
const heavyStorage = createHeavyStorage({
|
|
58
|
+
documents: [] as any[]
|
|
59
|
+
}, {
|
|
60
|
+
dbName: 'test-db-4'
|
|
61
|
+
});
|
|
62
|
+
const callback = vi.fn();
|
|
63
|
+
|
|
64
|
+
// registra onChange
|
|
65
|
+
heavyStorage.documents.onChange(callback);
|
|
66
|
+
|
|
67
|
+
// llama set()
|
|
68
|
+
const newDocs = [{ id: 1, name: 'Doc 1' }];
|
|
69
|
+
await heavyStorage.documents.set(newDocs);
|
|
70
|
+
|
|
71
|
+
// verifica que el callback fue llamado con el valor correcto
|
|
72
|
+
expect(callback).toHaveBeenCalledWith(newDocs);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('clear() debe resetear todas las keys del schema', async () => {
|
|
76
|
+
// crea heavyStorage con 2 keys
|
|
77
|
+
const heavyStorage = createHeavyStorage({
|
|
78
|
+
documents: [] as any[],
|
|
79
|
+
userPhotos: [] as any[]
|
|
80
|
+
}, {
|
|
81
|
+
dbName: 'test-db-5'
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// set() en ambas
|
|
85
|
+
await heavyStorage.documents.set([{ id: 1 }]);
|
|
86
|
+
await heavyStorage.userPhotos.set([{ url: 'photo.jpg' }]);
|
|
87
|
+
|
|
88
|
+
// llama clear()
|
|
89
|
+
await heavyStorage.clear();
|
|
90
|
+
|
|
91
|
+
// verifica que ambas vuelven a su initialValue
|
|
92
|
+
const docs = await heavyStorage.documents.get();
|
|
93
|
+
const photos = await heavyStorage.userPhotos.get();
|
|
94
|
+
|
|
95
|
+
expect(docs).toEqual([]);
|
|
96
|
+
expect(photos).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Abrir/crear la base de datos
|
|
2
|
+
function openDB(dbName: string): Promise<IDBDatabase> {
|
|
3
|
+
return new Promise((resolve, reject) => {
|
|
4
|
+
const request = indexedDB.open(dbName, 1);
|
|
5
|
+
|
|
6
|
+
request.onupgradeneeded = (event) => {
|
|
7
|
+
const db = (event.target as IDBOpenDBRequest).result;
|
|
8
|
+
// Crea el "object store" — equivalente a una tabla
|
|
9
|
+
if (!db.objectStoreNames.contains('storage')) {
|
|
10
|
+
db.createObjectStore('storage');
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
request.onsuccess = () => resolve(request.result);
|
|
15
|
+
request.onerror = () => reject(request.error);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function dbGet(db: IDBDatabase, key: string): Promise<any> {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const transaction = db.transaction('storage', 'readonly');
|
|
22
|
+
const store = transaction.objectStore('storage');
|
|
23
|
+
const request = store.get(key);
|
|
24
|
+
|
|
25
|
+
request.onsuccess = () => resolve(request.result);
|
|
26
|
+
request.onerror = () => reject(request.error);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function dbSet(db: IDBDatabase, key: string, value: any): Promise<void> {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const transaction = db.transaction('storage', 'readwrite');
|
|
33
|
+
const store = transaction.objectStore('storage');
|
|
34
|
+
const request = store.put(value, key);
|
|
35
|
+
|
|
36
|
+
request.onsuccess = () => resolve();
|
|
37
|
+
request.onerror = () => reject(request.error);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function dbDelete(db: IDBDatabase, key: string): Promise<void> {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const transaction = db.transaction('storage', 'readwrite');
|
|
44
|
+
const store = transaction.objectStore('storage');
|
|
45
|
+
const request = store.delete(key);
|
|
46
|
+
|
|
47
|
+
request.onsuccess = () => resolve();
|
|
48
|
+
request.onerror = () => reject(request.error);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ===========================================
|
|
53
|
+
|
|
54
|
+
interface HeavyStorageOptions {
|
|
55
|
+
dbName?: string;
|
|
56
|
+
ttl?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface HeavySignal<T> {
|
|
60
|
+
get(): Promise<T>;
|
|
61
|
+
set(value: T): Promise<void>;
|
|
62
|
+
remove(): Promise<void>;
|
|
63
|
+
onChange(callback: (value: T) => void): void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createHeavySignal<T>(
|
|
67
|
+
key: string,
|
|
68
|
+
initialValue: T,
|
|
69
|
+
options?: HeavyStorageOptions
|
|
70
|
+
): HeavySignal<T> {
|
|
71
|
+
const dbName = options?.dbName ?? 'typed-storage-heavy';
|
|
72
|
+
const listeners: Array<(value: T) => void> = [];
|
|
73
|
+
|
|
74
|
+
function notify(value: T): void {
|
|
75
|
+
listeners.forEach(cb => cb(value));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const signal: any = {};
|
|
79
|
+
|
|
80
|
+
signal.get = async function(): Promise<T> {
|
|
81
|
+
const db = await openDB(dbName);
|
|
82
|
+
const stored = await dbGet(db, key);
|
|
83
|
+
|
|
84
|
+
if (stored === undefined) return initialValue;
|
|
85
|
+
|
|
86
|
+
// Verifica TTL si existe
|
|
87
|
+
if (stored.expiresAt && Date.now() > stored.expiresAt) {
|
|
88
|
+
await dbDelete(db, key);
|
|
89
|
+
return initialValue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return stored.value ?? initialValue;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
signal.set = async function(value: T): Promise<void> {
|
|
96
|
+
const db = await openDB(dbName);
|
|
97
|
+
await dbSet(db, key, {
|
|
98
|
+
value,
|
|
99
|
+
expiresAt: options?.ttl ? Date.now() + options.ttl : undefined
|
|
100
|
+
});
|
|
101
|
+
notify(value);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
signal.remove = async function(): Promise<void> {
|
|
105
|
+
const db = await openDB(dbName);
|
|
106
|
+
await dbDelete(db, key);
|
|
107
|
+
notify(initialValue);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
signal.onChange = function(callback: (value: T) => void): void {
|
|
111
|
+
listeners.push(callback);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return signal as HeavySignal<T>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ===========================================
|
|
118
|
+
// Iteramos el schema completo
|
|
119
|
+
|
|
120
|
+
type HeavyStorageSchema = Record<string, any>;
|
|
121
|
+
|
|
122
|
+
type HeavyStorageResult<T extends HeavyStorageSchema> = {
|
|
123
|
+
[K in keyof T]: HeavySignal<T[K]>;
|
|
124
|
+
} & {
|
|
125
|
+
clear(): Promise<void>;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export function createHeavyStorage<T extends HeavyStorageSchema>(
|
|
129
|
+
schema: T,
|
|
130
|
+
options?: HeavyStorageOptions
|
|
131
|
+
): HeavyStorageResult<T> {
|
|
132
|
+
const result: any = {};
|
|
133
|
+
|
|
134
|
+
const keys = Object.keys(schema);
|
|
135
|
+
for (const key of keys) {
|
|
136
|
+
result[key] = createHeavySignal(key, schema[key], options);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
result.clear = async () => {
|
|
140
|
+
for (const key of keys) {
|
|
141
|
+
await result[key].remove();
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return result as HeavyStorageResult<T>;
|
|
146
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,8 @@
|
|
|
1
1
|
export { createStorage } from './create-storage.js';
|
|
2
|
-
export
|
|
2
|
+
export { createHeavyStorage } from './heavy-storage.js';
|
|
3
|
+
export type {
|
|
4
|
+
StorageSignal,
|
|
5
|
+
StorageSchema,
|
|
6
|
+
StorageResult,
|
|
7
|
+
StorageSignalOptions
|
|
8
|
+
} from './types.js';
|
|
@@ -84,4 +84,38 @@ describe('onChange', () => {
|
|
|
84
84
|
theme.reset();
|
|
85
85
|
expect(callback).toHaveBeenCalledWith('dark');
|
|
86
86
|
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('compress option', () => {
|
|
90
|
+
beforeEach(() => localStorage.clear());
|
|
91
|
+
|
|
92
|
+
it('debe comprimir y descomprimir correctamente', () => {
|
|
93
|
+
const signal = createStorageSignal<{ items: any[] }>(
|
|
94
|
+
'data',
|
|
95
|
+
{ items: [] },
|
|
96
|
+
{ compress: true }
|
|
97
|
+
);
|
|
98
|
+
const bigData = {
|
|
99
|
+
items: Array.from({ length: 50 },
|
|
100
|
+
(_, i) => ({ id: i }))
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
signal.set(bigData);
|
|
104
|
+
|
|
105
|
+
expect(signal()).toEqual(bigData);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('el dato comprimido debe ocupar menos espacio que sin comprimir', () => {
|
|
109
|
+
const compressed = createStorageSignal('data1', '', { compress: true, prefix: 'c1' });
|
|
110
|
+
const uncompressed = createStorageSignal('data2', '', { prefix: 'c2' });
|
|
111
|
+
|
|
112
|
+
const repetitive = 'a'.repeat(1000);
|
|
113
|
+
compressed.set(repetitive);
|
|
114
|
+
uncompressed.set(repetitive);
|
|
115
|
+
|
|
116
|
+
const compressedSize = localStorage.getItem('c1:data1')!.length;
|
|
117
|
+
const uncompressedSize = localStorage.getItem('c2:data2')!.length;
|
|
118
|
+
|
|
119
|
+
expect(compressedSize).toBeLessThan(uncompressedSize);
|
|
120
|
+
});
|
|
87
121
|
});
|
package/src/storage-signal.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import LZString from 'lz-string';
|
|
2
|
+
|
|
1
3
|
// Tipos
|
|
2
4
|
import { StorageSignal, StorageSignalOptions } from "./types.js";
|
|
3
5
|
|
|
@@ -33,7 +35,7 @@ function safeParseJSON<T>(value: string, fallback: T): StoredValue<T> {
|
|
|
33
35
|
}
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
// Obtenemos el valor del localStorage o SessionStorage,
|
|
38
|
+
// Obtenemos el valor del localStorage o SessionStorage, sino MemoryStorage
|
|
37
39
|
function getStorage(type: 'local' | 'session'): Storage | MemoryStorage {
|
|
38
40
|
try {
|
|
39
41
|
const sto = type === 'session' ? sessionStorage : localStorage;
|
|
@@ -63,7 +65,10 @@ export function createStorageSignal<T>(
|
|
|
63
65
|
key = `${options.prefix}:${key}`;
|
|
64
66
|
}
|
|
65
67
|
|
|
66
|
-
const
|
|
68
|
+
const rawData = sto.getItem(key);
|
|
69
|
+
const savedData = options?.compress && rawData
|
|
70
|
+
? LZString.decompress(rawData)
|
|
71
|
+
: rawData;
|
|
67
72
|
let currentValue: T;
|
|
68
73
|
|
|
69
74
|
const listeners: Array<(value: T) => void> = [];
|
|
@@ -101,7 +106,10 @@ export function createStorageSignal<T>(
|
|
|
101
106
|
}
|
|
102
107
|
|
|
103
108
|
// Parseamos el nuevo valor
|
|
104
|
-
const
|
|
109
|
+
const rawNewValue = options?.compress
|
|
110
|
+
? LZString.decompress(event.newValue)
|
|
111
|
+
: event.newValue;
|
|
112
|
+
const item = safeParseJSON(rawNewValue, initialValue);
|
|
105
113
|
|
|
106
114
|
notify(item.value as T);
|
|
107
115
|
return currentValue = item.value as T;
|
|
@@ -112,19 +120,29 @@ export function createStorageSignal<T>(
|
|
|
112
120
|
signalBase.set = function ( newValue: T ): void {
|
|
113
121
|
currentValue = newValue;
|
|
114
122
|
notify(currentValue);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
|
|
124
|
+
const dataToStore = JSON.stringify({
|
|
125
|
+
value: newValue,
|
|
126
|
+
expiresAt: options?.ttl ? Date.now() + options.ttl : undefined
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const finalData = options?.compress
|
|
130
|
+
? LZString.compress(dataToStore)
|
|
131
|
+
: dataToStore;
|
|
132
|
+
|
|
133
|
+
sto.setItem( key, finalData );
|
|
122
134
|
}
|
|
123
135
|
|
|
124
136
|
signalBase.reset = function(): void {
|
|
125
137
|
currentValue = initialValue;
|
|
126
138
|
notify(currentValue);
|
|
127
|
-
|
|
139
|
+
|
|
140
|
+
const dataToStore = JSON.stringify(initialValue);
|
|
141
|
+
const finalData = options?.compress
|
|
142
|
+
? LZString.compress(dataToStore)
|
|
143
|
+
: dataToStore;
|
|
144
|
+
|
|
145
|
+
sto.setItem(key, finalData);
|
|
128
146
|
}
|
|
129
147
|
|
|
130
148
|
signalBase.has = function(): boolean {
|
package/src/types.ts
CHANGED
package/vitest.config.ts
CHANGED
package/vitest.setup.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import 'fake-indexeddb/auto';
|