@esportsplus/web-storage 0.4.0 → 0.5.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.
package/README.md ADDED
@@ -0,0 +1,248 @@
1
+ # @esportsplus/web-storage
2
+
3
+ Typed async storage with multiple backends, TTL, encryption, compression, subscriptions, and migrations.
4
+
5
+ ```typescript
6
+ import storage, { DriverType } from '@esportsplus/web-storage';
7
+
8
+ type UserData = { name: string; preferences: { theme: string } };
9
+
10
+ let store = storage<UserData>({ name: 'app', version: 1 });
11
+
12
+ await store.set('name', 'alice');
13
+ await store.get('name'); // 'alice'
14
+ ```
15
+
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pnpm add @esportsplus/web-storage
21
+ ```
22
+
23
+
24
+ ## Drivers
25
+
26
+ | Driver | Persistence | Compression | Use Case |
27
+ |--------|------------|-------------|----------|
28
+ | IndexedDB | Permanent | No | Default. Large data, no quota pressure |
29
+ | localStorage | Permanent | Yes (LZ) | Small data, 5MB limit |
30
+ | sessionStorage | Per-tab | Yes (LZ) | Tab-scoped state |
31
+ | Memory | None | No | Testing, SSR, fallback |
32
+
33
+ ```typescript
34
+ // IndexedDB (default)
35
+ let store = storage<T>({ name: 'app', version: 1 });
36
+
37
+ // localStorage
38
+ let store = storage<T>({ driver: DriverType.LocalStorage, name: 'app', version: 1 });
39
+
40
+ // sessionStorage
41
+ let store = storage<T>({ driver: DriverType.SessionStorage, name: 'app', version: 1 });
42
+
43
+ // Memory (non-persistent)
44
+ let store = storage<T>({ driver: DriverType.Memory, name: 'app', version: 1 });
45
+ ```
46
+
47
+
48
+ ## API
49
+
50
+ All methods are async and fully typed via `Local<T>`.
51
+
52
+ ### Core CRUD
53
+
54
+ ```typescript
55
+ // Set a value
56
+ await store.set('name', 'alice');
57
+
58
+ // Get a value
59
+ let name = await store.get('name'); // string | undefined
60
+
61
+ // Get with factory (lazy init — never returns undefined)
62
+ let name = await store.get('name', () => 'default');
63
+ let user = await store.get('name', async () => await fetchUser());
64
+
65
+ // Delete keys
66
+ await store.delete('name', 'preferences');
67
+
68
+ // Replace multiple values
69
+ let failed = await store.replace({ name: 'bob', preferences: { theme: 'dark' } });
70
+
71
+ // Get all entries
72
+ let all = await store.all();
73
+
74
+ // Get specific keys
75
+ let subset = await store.only('name', 'preferences');
76
+
77
+ // Count entries
78
+ let count = await store.count();
79
+
80
+ // List keys
81
+ let keys = await store.keys();
82
+
83
+ // Clear everything
84
+ await store.clear();
85
+ ```
86
+
87
+ ### Iteration
88
+
89
+ ```typescript
90
+ // Map over all entries
91
+ await store.map((value, key, i) => {
92
+ console.log(key, value);
93
+ });
94
+
95
+ // Filter entries (with early stop)
96
+ let result = await store.filter(({ key, value, stop }) => {
97
+ if (key === 'name') {
98
+ stop(); // halt iteration
99
+ }
100
+
101
+ return typeof value === 'string';
102
+ });
103
+ ```
104
+
105
+
106
+ ## TTL / Expiration
107
+
108
+ Per-key time-to-live in milliseconds. Expired entries return `undefined` and are lazily deleted.
109
+
110
+ ```typescript
111
+ // Set with 1 hour TTL
112
+ await store.set('session', token, { ttl: 3600000 });
113
+
114
+ // Check remaining time (-1 if no TTL or expired)
115
+ await store.ttl('session'); // ms remaining
116
+
117
+ // Remove TTL (make permanent)
118
+ await store.persist('session');
119
+
120
+ // Proactively sweep all expired entries
121
+ await store.cleanup();
122
+ ```
123
+
124
+
125
+ ## Encryption
126
+
127
+ Optional AES-GCM encryption via a secret string.
128
+
129
+ ```typescript
130
+ let store = storage<T>({ name: 'secure', version: 1 }, 'my-secret-key');
131
+
132
+ await store.set('token', 'sensitive-data'); // encrypted at rest
133
+ await store.get('token'); // 'sensitive-data' (decrypted)
134
+ ```
135
+
136
+
137
+ ## Change Subscriptions
138
+
139
+ Subscribe to value changes. Returns an unsubscribe function.
140
+
141
+ ```typescript
142
+ // Per-key subscription
143
+ let unsubscribe = store.subscribe('name', (newValue, oldValue) => {
144
+ console.log(`name: ${oldValue} -> ${newValue}`);
145
+ });
146
+
147
+ // Global subscription (all keys)
148
+ let unsubscribe = store.subscribe((key, newValue, oldValue) => {
149
+ console.log(`${String(key)} changed`);
150
+ });
151
+
152
+ // Stop listening
153
+ unsubscribe();
154
+ ```
155
+
156
+ Fires after: `set`, `delete`, `replace`, `clear`, `cleanup`.
157
+
158
+
159
+ ## Migrations
160
+
161
+ Run transform functions when the version number changes.
162
+
163
+ ```typescript
164
+ type V1 = { name: string };
165
+ type V2 = { displayName: string; name: string };
166
+
167
+ let store = storage<V2>({
168
+ name: 'app',
169
+ version: 2,
170
+ migrations: {
171
+ 2: async (old) => {
172
+ let data = await old.all();
173
+
174
+ return {
175
+ ...data,
176
+ displayName: (data.name as string) || 'Anonymous'
177
+ };
178
+ }
179
+ }
180
+ });
181
+ ```
182
+
183
+ Migrations run sequentially. Version 1 to 3 runs migration 2 then migration 3. Each migration receives the current store data and returns the transformed data.
184
+
185
+
186
+ ## Compression
187
+
188
+ localStorage and sessionStorage drivers automatically compress values >= 100 bytes using an inlined LZW compressor. No configuration needed.
189
+
190
+ - Values < 100 bytes: stored as JSON (LZ overhead not worth it)
191
+ - Values >= 100 bytes: LZ compressed (2-10x capacity gain on JSON)
192
+ - Backward compatible: existing uncompressed values read normally
193
+ - Runs before encryption on write, after decryption on read
194
+
195
+
196
+ ## Factory Pattern (`get` with default)
197
+
198
+ ```typescript
199
+ // Sync factory
200
+ let count = await store.get('count', () => 0);
201
+
202
+ // Async factory
203
+ let user = await store.get('user', async () => {
204
+ return await fetchUser(id);
205
+ });
206
+ ```
207
+
208
+ The factory is called only when the key is missing or expired. The produced value is persisted via a fire-and-forget `set` (caller isn't blocked by the write).
209
+
210
+
211
+ ## Types
212
+
213
+ ```typescript
214
+ import type { Local } from '@esportsplus/web-storage';
215
+ import { DriverType } from '@esportsplus/web-storage';
216
+
217
+ type Options = {
218
+ driver?: DriverType;
219
+ migrations?: Record<number, MigrationFn>;
220
+ name: string;
221
+ version: number;
222
+ };
223
+
224
+ type SetOptions = {
225
+ ttl?: number;
226
+ };
227
+
228
+ type MigrationFn = (old: {
229
+ all(): Promise<Record<string, unknown>>;
230
+ }) => Promise<Record<string, unknown>>;
231
+
232
+ // Subscription callbacks
233
+ type KeyCallback<T, K extends keyof T> = (
234
+ newValue: T[K] | undefined,
235
+ oldValue: T[K] | undefined
236
+ ) => void;
237
+
238
+ type GlobalCallback<T> = (
239
+ key: keyof T,
240
+ newValue: T[keyof T] | undefined,
241
+ oldValue: T[keyof T] | undefined
242
+ ) => void;
243
+ ```
244
+
245
+
246
+ ## License
247
+
248
+ MIT
@@ -5,6 +5,7 @@ declare class LocalStorageDriver<T> implements Driver<T> {
5
5
  private getKeys;
6
6
  private key;
7
7
  private parse;
8
+ private serialize;
8
9
  all(): Promise<T>;
9
10
  clear(): Promise<void>;
10
11
  count(): Promise<number>;
@@ -1,3 +1,4 @@
1
+ import { compress, decompress } from '../lz.js';
1
2
  class LocalStorageDriver {
2
3
  prefix;
3
4
  constructor(name, version) {
@@ -21,12 +22,19 @@ class LocalStorageDriver {
21
22
  return undefined;
22
23
  }
23
24
  try {
25
+ if (value.charCodeAt(0) === 1) {
26
+ return JSON.parse(decompress(value.slice(1)));
27
+ }
24
28
  return JSON.parse(value);
25
29
  }
26
30
  catch {
27
31
  return undefined;
28
32
  }
29
33
  }
34
+ serialize(value) {
35
+ let json = JSON.stringify(value);
36
+ return json.length >= 100 ? '\x01' + compress(json) : json;
37
+ }
30
38
  async all() {
31
39
  let keys = this.getKeys(), result = {};
32
40
  for (let i = 0, n = keys.length; i < n; i++) {
@@ -78,12 +86,12 @@ class LocalStorageDriver {
78
86
  }
79
87
  async replace(entries) {
80
88
  for (let i = 0, n = entries.length; i < n; i++) {
81
- localStorage.setItem(this.key(entries[i][0]), JSON.stringify(entries[i][1]));
89
+ localStorage.setItem(this.key(entries[i][0]), this.serialize(entries[i][1]));
82
90
  }
83
91
  }
84
92
  async set(key, value) {
85
93
  try {
86
- localStorage.setItem(this.key(key), JSON.stringify(value));
94
+ localStorage.setItem(this.key(key), this.serialize(value));
87
95
  return true;
88
96
  }
89
97
  catch {
@@ -5,6 +5,7 @@ declare class SessionStorageDriver<T> implements Driver<T> {
5
5
  private getKeys;
6
6
  private key;
7
7
  private parse;
8
+ private serialize;
8
9
  all(): Promise<T>;
9
10
  clear(): Promise<void>;
10
11
  count(): Promise<number>;
@@ -1,3 +1,4 @@
1
+ import { compress, decompress } from '../lz.js';
1
2
  class SessionStorageDriver {
2
3
  prefix;
3
4
  constructor(name, version) {
@@ -21,12 +22,19 @@ class SessionStorageDriver {
21
22
  return undefined;
22
23
  }
23
24
  try {
25
+ if (value.charCodeAt(0) === 1) {
26
+ return JSON.parse(decompress(value.slice(1)));
27
+ }
24
28
  return JSON.parse(value);
25
29
  }
26
30
  catch {
27
31
  return undefined;
28
32
  }
29
33
  }
34
+ serialize(value) {
35
+ let json = JSON.stringify(value);
36
+ return json.length >= 100 ? '\x01' + compress(json) : json;
37
+ }
30
38
  async all() {
31
39
  let keys = this.getKeys(), result = {};
32
40
  for (let i = 0, n = keys.length; i < n; i++) {
@@ -78,12 +86,12 @@ class SessionStorageDriver {
78
86
  }
79
87
  async replace(entries) {
80
88
  for (let i = 0, n = entries.length; i < n; i++) {
81
- sessionStorage.setItem(this.key(entries[i][0]), JSON.stringify(entries[i][1]));
89
+ sessionStorage.setItem(this.key(entries[i][0]), this.serialize(entries[i][1]));
82
90
  }
83
91
  }
84
92
  async set(key, value) {
85
93
  try {
86
- sessionStorage.setItem(this.key(key), JSON.stringify(value));
94
+ sessionStorage.setItem(this.key(key), this.serialize(value));
87
95
  return true;
88
96
  }
89
97
  catch {
package/build/lz.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ declare const compress: (input: string) => string;
2
+ declare const decompress: (compressed: string) => string;
3
+ export { compress, decompress };
package/build/lz.js ADDED
@@ -0,0 +1,134 @@
1
+ function emitLiteral(ctx, ch) {
2
+ let code = ch.charCodeAt(0);
3
+ if (code < 256) {
4
+ writeBits(ctx, ctx.numBits, 0);
5
+ writeBits(ctx, 8, code);
6
+ }
7
+ else {
8
+ writeBits(ctx, ctx.numBits, 1);
9
+ writeBits(ctx, 16, code);
10
+ }
11
+ }
12
+ function readBits(ctx, n) {
13
+ let result = 0;
14
+ for (let i = 0; i < n; i++) {
15
+ if (ctx.bitPos > 15) {
16
+ ctx.currentValue = ctx.compressed.charCodeAt(ctx.pos++) - 1;
17
+ ctx.bitPos = 0;
18
+ }
19
+ result = (result << 1) | ((ctx.currentValue >> (15 - ctx.bitPos)) & 1);
20
+ ctx.bitPos++;
21
+ }
22
+ return result;
23
+ }
24
+ function writeBits(ctx, n, value) {
25
+ for (let i = n - 1; i >= 0; i--) {
26
+ ctx.buffer = (ctx.buffer << 1) | ((value >> i) & 1);
27
+ ctx.bitsInBuffer++;
28
+ if (ctx.bitsInBuffer === 16) {
29
+ ctx.output.push(ctx.buffer + 1);
30
+ ctx.buffer = 0;
31
+ ctx.bitsInBuffer = 0;
32
+ }
33
+ }
34
+ }
35
+ const compress = (input) => {
36
+ if (!input) {
37
+ return '';
38
+ }
39
+ let ctx = { bitsInBuffer: 0, buffer: 0, numBits: 2, output: [] }, dictSize = 3, dictionary = new Map(), w = '';
40
+ for (let i = 0, n = input.length; i < n; i++) {
41
+ let c = input[i], wc = w + c;
42
+ if (dictionary.has(wc)) {
43
+ w = wc;
44
+ continue;
45
+ }
46
+ if (w.length > 0) {
47
+ if (dictionary.has(w)) {
48
+ writeBits(ctx, ctx.numBits, dictionary.get(w));
49
+ }
50
+ else {
51
+ emitLiteral(ctx, w);
52
+ }
53
+ dictionary.set(wc, dictSize++);
54
+ if (dictSize > (1 << ctx.numBits)) {
55
+ ctx.numBits++;
56
+ }
57
+ }
58
+ w = c;
59
+ }
60
+ if (w.length > 0) {
61
+ if (dictionary.has(w)) {
62
+ writeBits(ctx, ctx.numBits, dictionary.get(w));
63
+ }
64
+ else {
65
+ emitLiteral(ctx, w);
66
+ }
67
+ }
68
+ dictSize++;
69
+ if (dictSize > (1 << ctx.numBits)) {
70
+ ctx.numBits++;
71
+ }
72
+ writeBits(ctx, ctx.numBits, 2);
73
+ if (ctx.bitsInBuffer > 0) {
74
+ ctx.output.push(((ctx.buffer << (16 - ctx.bitsInBuffer)) & 0xFFFF) + 1);
75
+ }
76
+ ctx.output.push((ctx.bitsInBuffer === 0 ? 16 : ctx.bitsInBuffer) + 1);
77
+ let chars = [];
78
+ for (let i = 0, n = ctx.output.length; i < n; i++) {
79
+ chars.push(String.fromCharCode(ctx.output[i]));
80
+ }
81
+ return chars.join('');
82
+ };
83
+ const decompress = (compressed) => {
84
+ if (!compressed) {
85
+ return '';
86
+ }
87
+ let ctx = { bitPos: 16, compressed: '', currentValue: 0, pos: 0 }, dictSize = 3, dictionary = [], numBits = 2;
88
+ ctx.compressed = compressed.substring(0, compressed.length - 1);
89
+ let code = readBits(ctx, numBits), entry;
90
+ if (code === 0) {
91
+ entry = String.fromCharCode(readBits(ctx, 8));
92
+ }
93
+ else if (code === 1) {
94
+ entry = String.fromCharCode(readBits(ctx, 16));
95
+ }
96
+ else {
97
+ return '';
98
+ }
99
+ let result = [entry], w = entry;
100
+ while (true) {
101
+ let slotIdx = dictionary.length;
102
+ dictionary.push('');
103
+ dictSize++;
104
+ if (dictSize > (1 << numBits)) {
105
+ numBits++;
106
+ }
107
+ code = readBits(ctx, numBits);
108
+ if (code === 2) {
109
+ dictionary.pop();
110
+ break;
111
+ }
112
+ let slotCode = slotIdx + 3;
113
+ if (code === 0) {
114
+ entry = String.fromCharCode(readBits(ctx, 8));
115
+ }
116
+ else if (code === 1) {
117
+ entry = String.fromCharCode(readBits(ctx, 16));
118
+ }
119
+ else if (code === slotCode) {
120
+ entry = w + w[0];
121
+ }
122
+ else if (code >= 3 && code < slotCode) {
123
+ entry = dictionary[code - 3];
124
+ }
125
+ else {
126
+ throw new Error('LZ: invalid decompression code');
127
+ }
128
+ dictionary[slotIdx] = w + entry[0];
129
+ result.push(entry);
130
+ w = entry;
131
+ }
132
+ return result.join('');
133
+ };
134
+ export { compress, decompress };
package/package.json CHANGED
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "type": "module",
21
21
  "types": "build/index.d.ts",
22
- "version": "0.4.0",
22
+ "version": "0.5.0",
23
23
  "scripts": {
24
24
  "build": "tsc && tsc-alias",
25
25
  "test": "vitest run",
@@ -1,3 +1,4 @@
1
+ import { compress, decompress } from '~/lz';
1
2
  import type { Driver } from '~/types';
2
3
 
3
4
 
@@ -35,6 +36,10 @@ class LocalStorageDriver<T> implements Driver<T> {
35
36
  }
36
37
 
37
38
  try {
39
+ if (value.charCodeAt(0) === 1) {
40
+ return JSON.parse(decompress(value.slice(1)));
41
+ }
42
+
38
43
  return JSON.parse(value);
39
44
  }
40
45
  catch {
@@ -42,6 +47,12 @@ class LocalStorageDriver<T> implements Driver<T> {
42
47
  }
43
48
  }
44
49
 
50
+ private serialize(value: T[keyof T]): string {
51
+ let json = JSON.stringify(value);
52
+
53
+ return json.length >= 100 ? '\x01' + compress(json) : json;
54
+ }
55
+
45
56
 
46
57
  async all(): Promise<T> {
47
58
  let keys = this.getKeys(),
@@ -112,13 +123,13 @@ class LocalStorageDriver<T> implements Driver<T> {
112
123
 
113
124
  async replace(entries: [keyof T, T[keyof T]][]): Promise<void> {
114
125
  for (let i = 0, n = entries.length; i < n; i++) {
115
- localStorage.setItem(this.key(entries[i][0]), JSON.stringify(entries[i][1]));
126
+ localStorage.setItem(this.key(entries[i][0]), this.serialize(entries[i][1]));
116
127
  }
117
128
  }
118
129
 
119
130
  async set(key: keyof T, value: T[keyof T]): Promise<boolean> {
120
131
  try {
121
- localStorage.setItem(this.key(key), JSON.stringify(value));
132
+ localStorage.setItem(this.key(key), this.serialize(value));
122
133
  return true;
123
134
  }
124
135
  catch {
@@ -1,3 +1,4 @@
1
+ import { compress, decompress } from '~/lz';
1
2
  import type { Driver } from '~/types';
2
3
 
3
4
 
@@ -35,6 +36,10 @@ class SessionStorageDriver<T> implements Driver<T> {
35
36
  }
36
37
 
37
38
  try {
39
+ if (value.charCodeAt(0) === 1) {
40
+ return JSON.parse(decompress(value.slice(1)));
41
+ }
42
+
38
43
  return JSON.parse(value);
39
44
  }
40
45
  catch {
@@ -42,6 +47,12 @@ class SessionStorageDriver<T> implements Driver<T> {
42
47
  }
43
48
  }
44
49
 
50
+ private serialize(value: T[keyof T]): string {
51
+ let json = JSON.stringify(value);
52
+
53
+ return json.length >= 100 ? '\x01' + compress(json) : json;
54
+ }
55
+
45
56
 
46
57
  async all(): Promise<T> {
47
58
  let keys = this.getKeys(),
@@ -112,13 +123,13 @@ class SessionStorageDriver<T> implements Driver<T> {
112
123
 
113
124
  async replace(entries: [keyof T, T[keyof T]][]): Promise<void> {
114
125
  for (let i = 0, n = entries.length; i < n; i++) {
115
- sessionStorage.setItem(this.key(entries[i][0]), JSON.stringify(entries[i][1]));
126
+ sessionStorage.setItem(this.key(entries[i][0]), this.serialize(entries[i][1]));
116
127
  }
117
128
  }
118
129
 
119
130
  async set(key: keyof T, value: T[keyof T]): Promise<boolean> {
120
131
  try {
121
- sessionStorage.setItem(this.key(key), JSON.stringify(value));
132
+ sessionStorage.setItem(this.key(key), this.serialize(value));
122
133
  return true;
123
134
  }
124
135
  catch {