pulse-js-framework 1.9.3 → 1.10.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.
@@ -0,0 +1,492 @@
1
+ /**
2
+ * Pulse Persistence Adapters
3
+ * Strategy pattern for storage backends: localStorage, sessionStorage, IndexedDB, memory.
4
+ *
5
+ * @module pulse-js-framework/runtime/persistence
6
+ */
7
+
8
+ import { pulse, effect } from './pulse.js';
9
+ import { loggers } from './logger.js';
10
+ import { RuntimeError } from './errors.js';
11
+ import { DANGEROUS_KEYS } from './security.js';
12
+
13
+ const log = loggers.store;
14
+
15
+ // =============================================================================
16
+ // CONSTANTS
17
+ // =============================================================================
18
+
19
+ const MAX_NESTING_DEPTH = 10;
20
+
21
+ const DEFAULT_PERSISTENCE_OPTIONS = {
22
+ key: 'pulse-store',
23
+ debounce: 100,
24
+ include: null,
25
+ exclude: null,
26
+ serialize: JSON.stringify,
27
+ deserialize: JSON.parse,
28
+ maxDepth: MAX_NESTING_DEPTH,
29
+ onError: null,
30
+ };
31
+
32
+ // =============================================================================
33
+ // PERSISTENCE ERROR
34
+ // =============================================================================
35
+
36
+ export class PersistenceError extends RuntimeError {
37
+ constructor(message, options = {}) {
38
+ super(message, { code: 'PERSISTENCE_ERROR', ...options });
39
+ this.name = 'PersistenceError';
40
+ this.adapterName = options.adapterName ?? null;
41
+ }
42
+
43
+ static isPersistenceError(error) {
44
+ return error instanceof PersistenceError;
45
+ }
46
+ }
47
+
48
+ // =============================================================================
49
+ // INTERNAL: SECURITY HELPERS
50
+ // =============================================================================
51
+
52
+ function _sanitizeValue(value, depth = 0, maxDepth = MAX_NESTING_DEPTH) {
53
+ if (depth > maxDepth) {
54
+ log.warn('Maximum nesting depth exceeded in persisted data');
55
+ return null;
56
+ }
57
+
58
+ if (value === null || value === undefined) return value;
59
+ if (typeof value !== 'object') return value;
60
+
61
+ if (Array.isArray(value)) {
62
+ return value.map(item => _sanitizeValue(item, depth + 1, maxDepth));
63
+ }
64
+
65
+ const sanitized = {};
66
+ for (const [key, val] of Object.entries(value)) {
67
+ if (DANGEROUS_KEYS.has(key)) {
68
+ log.warn(`Blocked dangerous key in persisted data: "${key}"`);
69
+ continue;
70
+ }
71
+ sanitized[key] = _sanitizeValue(val, depth + 1, maxDepth);
72
+ }
73
+ return sanitized;
74
+ }
75
+
76
+ function _filterKeys(data, include, exclude) {
77
+ if (!include && !exclude) return data;
78
+
79
+ const filtered = {};
80
+ for (const [key, value] of Object.entries(data)) {
81
+ if (include && !include.includes(key)) continue;
82
+ if (exclude && exclude.includes(key)) continue;
83
+ filtered[key] = value;
84
+ }
85
+ return filtered;
86
+ }
87
+
88
+ // =============================================================================
89
+ // WEB STORAGE ADAPTER (localStorage / sessionStorage)
90
+ // =============================================================================
91
+
92
+ function _createWebStorageAdapter(storage, name) {
93
+ return {
94
+ name,
95
+
96
+ async getItem(key) {
97
+ try {
98
+ const raw = storage.getItem(key);
99
+ return raw !== null ? JSON.parse(raw) : null;
100
+ } catch (e) {
101
+ log.warn(`${name}: Failed to read key "${key}":`, e.message);
102
+ return null;
103
+ }
104
+ },
105
+
106
+ async setItem(key, value) {
107
+ try {
108
+ storage.setItem(key, JSON.stringify(value));
109
+ } catch (e) {
110
+ throw new PersistenceError(`${name}: Failed to write key "${key}": ${e.message}`, {
111
+ adapterName: name,
112
+ suggestion: name === 'localStorage'
113
+ ? 'Storage may be full. Try clearing old data or using IndexedDB for large datasets.'
114
+ : 'SessionStorage may be full or disabled.',
115
+ });
116
+ }
117
+ },
118
+
119
+ async removeItem(key) {
120
+ storage.removeItem(key);
121
+ },
122
+
123
+ async clear() {
124
+ storage.clear();
125
+ },
126
+
127
+ async keys() {
128
+ const result = [];
129
+ for (let i = 0; i < storage.length; i++) {
130
+ result.push(storage.key(i));
131
+ }
132
+ return result;
133
+ },
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Create a localStorage persistence adapter
139
+ * @returns {Object} PersistenceAdapter
140
+ */
141
+ export function createLocalStorageAdapter() {
142
+ if (typeof localStorage === 'undefined') {
143
+ log.warn('localStorage not available, falling back to memory adapter');
144
+ return createMemoryAdapter();
145
+ }
146
+ return _createWebStorageAdapter(localStorage, 'localStorage');
147
+ }
148
+
149
+ /**
150
+ * Create a sessionStorage persistence adapter
151
+ * @returns {Object} PersistenceAdapter
152
+ */
153
+ export function createSessionStorageAdapter() {
154
+ if (typeof sessionStorage === 'undefined') {
155
+ log.warn('sessionStorage not available, falling back to memory adapter');
156
+ return createMemoryAdapter();
157
+ }
158
+ return _createWebStorageAdapter(sessionStorage, 'sessionStorage');
159
+ }
160
+
161
+ // =============================================================================
162
+ // INDEXEDDB ADAPTER
163
+ // =============================================================================
164
+
165
+ /**
166
+ * Create an IndexedDB persistence adapter for large datasets
167
+ *
168
+ * @param {Object} [options]
169
+ * @param {string} [options.dbName='pulse-store'] - Database name
170
+ * @param {string} [options.storeName='state'] - Object store name
171
+ * @param {number} [options.version=1] - Database version
172
+ * @returns {Object} PersistenceAdapter
173
+ */
174
+ export function createIndexedDBAdapter(options = {}) {
175
+ const {
176
+ dbName = 'pulse-store',
177
+ storeName = 'state',
178
+ version = 1,
179
+ } = options;
180
+
181
+ if (typeof indexedDB === 'undefined') {
182
+ log.warn('IndexedDB not available, falling back to memory adapter');
183
+ return createMemoryAdapter();
184
+ }
185
+
186
+ let dbPromise = null;
187
+
188
+ function _openDB() {
189
+ if (dbPromise) return dbPromise;
190
+
191
+ dbPromise = new Promise((resolve, reject) => {
192
+ const request = indexedDB.open(dbName, version);
193
+
194
+ request.onupgradeneeded = () => {
195
+ const db = request.result;
196
+ if (!db.objectStoreNames.contains(storeName)) {
197
+ db.createObjectStore(storeName);
198
+ }
199
+ };
200
+
201
+ request.onsuccess = () => resolve(request.result);
202
+ request.onerror = () => {
203
+ dbPromise = null;
204
+ reject(new PersistenceError(
205
+ `IndexedDB: Failed to open database "${dbName}": ${request.error?.message}`,
206
+ { adapterName: 'IndexedDB' }
207
+ ));
208
+ };
209
+ });
210
+
211
+ return dbPromise;
212
+ }
213
+
214
+ function _transaction(mode) {
215
+ return _openDB().then(db => {
216
+ const tx = db.transaction(storeName, mode);
217
+ return tx.objectStore(storeName);
218
+ });
219
+ }
220
+
221
+ function _request(store, method, ...args) {
222
+ return new Promise((resolve, reject) => {
223
+ const request = store[method](...args);
224
+ request.onsuccess = () => resolve(request.result);
225
+ request.onerror = () => reject(new PersistenceError(
226
+ `IndexedDB: ${method} failed: ${request.error?.message}`,
227
+ { adapterName: 'IndexedDB' }
228
+ ));
229
+ });
230
+ }
231
+
232
+ return {
233
+ name: 'IndexedDB',
234
+
235
+ async getItem(key) {
236
+ const store = await _transaction('readonly');
237
+ const result = await _request(store, 'get', key);
238
+ return result !== undefined ? result : null;
239
+ },
240
+
241
+ async setItem(key, value) {
242
+ const store = await _transaction('readwrite');
243
+ await _request(store, 'put', value, key);
244
+ },
245
+
246
+ async removeItem(key) {
247
+ const store = await _transaction('readwrite');
248
+ await _request(store, 'delete', key);
249
+ },
250
+
251
+ async clear() {
252
+ const store = await _transaction('readwrite');
253
+ await _request(store, 'clear');
254
+ },
255
+
256
+ async keys() {
257
+ const store = await _transaction('readonly');
258
+ return _request(store, 'getAllKeys');
259
+ },
260
+ };
261
+ }
262
+
263
+ // =============================================================================
264
+ // MEMORY ADAPTER (for testing)
265
+ // =============================================================================
266
+
267
+ /**
268
+ * Create an in-memory persistence adapter (for testing or SSR)
269
+ * @returns {Object} PersistenceAdapter
270
+ */
271
+ export function createMemoryAdapter() {
272
+ const store = new Map();
273
+
274
+ return {
275
+ name: 'Memory',
276
+
277
+ async getItem(key) {
278
+ const value = store.get(key);
279
+ return value !== undefined ? value : null;
280
+ },
281
+
282
+ async setItem(key, value) {
283
+ store.set(key, value);
284
+ },
285
+
286
+ async removeItem(key) {
287
+ store.delete(key);
288
+ },
289
+
290
+ async clear() {
291
+ store.clear();
292
+ },
293
+
294
+ async keys() {
295
+ return Array.from(store.keys());
296
+ },
297
+ };
298
+ }
299
+
300
+ // =============================================================================
301
+ // FACTORY: createPersistenceAdapter
302
+ // =============================================================================
303
+
304
+ /**
305
+ * Create a persistence adapter by type name
306
+ *
307
+ * @param {string} type - 'localStorage' | 'sessionStorage' | 'indexedDB' | 'memory'
308
+ * @param {Object} [options] - Adapter-specific options
309
+ * @returns {Object} PersistenceAdapter
310
+ */
311
+ export function createPersistenceAdapter(type, options = {}) {
312
+ switch (type) {
313
+ case 'localStorage':
314
+ return createLocalStorageAdapter();
315
+ case 'sessionStorage':
316
+ return createSessionStorageAdapter();
317
+ case 'indexedDB':
318
+ return createIndexedDBAdapter(options);
319
+ case 'memory':
320
+ return createMemoryAdapter();
321
+ default:
322
+ throw new PersistenceError(
323
+ `Unknown persistence adapter type: "${type}"`,
324
+ { suggestion: 'Use one of: localStorage, sessionStorage, indexedDB, memory' }
325
+ );
326
+ }
327
+ }
328
+
329
+ // =============================================================================
330
+ // STORE INTEGRATION: withPersistence
331
+ // =============================================================================
332
+
333
+ /**
334
+ * Attach persistence to an existing Pulse store.
335
+ * Auto-saves store changes to the adapter with debouncing.
336
+ *
337
+ * @param {Object} store - A Pulse store created by createStore()
338
+ * @param {Object} adapter - A PersistenceAdapter instance
339
+ * @param {Object} [options] - Persistence options
340
+ * @returns {Object} { restore, clear, flush, dispose }
341
+ */
342
+ export function withPersistence(store, adapter, options = {}) {
343
+ const config = { ...DEFAULT_PERSISTENCE_OPTIONS, ...options };
344
+
345
+ let debounceTimer = null;
346
+ let disposed = false;
347
+ let pendingWrite = null;
348
+
349
+ function _getStoreSnapshot() {
350
+ if (typeof store.$getState === 'function') {
351
+ return _filterKeys(store.$getState(), config.include, config.exclude);
352
+ }
353
+
354
+ // Fallback: iterate store pulses
355
+ const snapshot = {};
356
+ const pulses = store.$pulses || store;
357
+ for (const [key, p] of Object.entries(pulses)) {
358
+ if (key.startsWith('$')) continue;
359
+ if (config.include && !config.include.includes(key)) continue;
360
+ if (config.exclude && config.exclude.includes(key)) continue;
361
+ snapshot[key] = typeof p.get === 'function' ? p.get() : p;
362
+ }
363
+ return snapshot;
364
+ }
365
+
366
+ function _scheduleSave() {
367
+ if (disposed) return;
368
+
369
+ if (debounceTimer !== null) {
370
+ clearTimeout(debounceTimer);
371
+ }
372
+
373
+ debounceTimer = setTimeout(() => {
374
+ debounceTimer = null;
375
+ _save();
376
+ }, config.debounce);
377
+ }
378
+
379
+ async function _save() {
380
+ if (disposed) return;
381
+
382
+ try {
383
+ const snapshot = _getStoreSnapshot();
384
+ const serialized = config.serialize(snapshot);
385
+ pendingWrite = adapter.setItem(config.key, config.deserialize(serialized));
386
+ await pendingWrite;
387
+ pendingWrite = null;
388
+ } catch (e) {
389
+ log.warn('Persistence save failed:', e.message);
390
+ config.onError?.(e);
391
+ }
392
+ }
393
+
394
+ // Set up auto-save effect
395
+ const disposeEffect = effect(() => {
396
+ // Read all store values to track dependencies
397
+ _getStoreSnapshot();
398
+ // Schedule debounced save
399
+ _scheduleSave();
400
+ });
401
+
402
+ /**
403
+ * Restore persisted state into the store
404
+ * @returns {Promise<boolean>} True if state was restored
405
+ */
406
+ async function restore() {
407
+ try {
408
+ const raw = await adapter.getItem(config.key);
409
+ if (raw === null) return false;
410
+
411
+ const data = typeof raw === 'string' ? config.deserialize(raw) : raw;
412
+ const sanitized = _sanitizeValue(data, 0, config.maxDepth);
413
+
414
+ if (sanitized && typeof sanitized === 'object' && !Array.isArray(sanitized)) {
415
+ const filtered = _filterKeys(sanitized, config.include, config.exclude);
416
+
417
+ if (typeof store.$setState === 'function') {
418
+ store.$setState(filtered);
419
+ } else {
420
+ const pulses = store.$pulses || store;
421
+ for (const [key, value] of Object.entries(filtered)) {
422
+ if (pulses[key] && typeof pulses[key].set === 'function') {
423
+ pulses[key].set(value);
424
+ }
425
+ }
426
+ }
427
+
428
+ log.info(`Persistence restored from ${adapter.name}`);
429
+ return true;
430
+ }
431
+ return false;
432
+ } catch (e) {
433
+ log.warn('Persistence restore failed:', e.message);
434
+ config.onError?.(e);
435
+ return false;
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Clear persisted data
441
+ * @returns {Promise<void>}
442
+ */
443
+ async function clear() {
444
+ try {
445
+ await adapter.removeItem(config.key);
446
+ log.info(`Persistence cleared from ${adapter.name}`);
447
+ } catch (e) {
448
+ log.warn('Persistence clear failed:', e.message);
449
+ config.onError?.(e);
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Force an immediate save (bypassing debounce)
455
+ * @returns {Promise<void>}
456
+ */
457
+ async function flush() {
458
+ if (debounceTimer !== null) {
459
+ clearTimeout(debounceTimer);
460
+ debounceTimer = null;
461
+ }
462
+ await _save();
463
+ }
464
+
465
+ /**
466
+ * Dispose of persistence (stop auto-saving)
467
+ */
468
+ function dispose() {
469
+ disposed = true;
470
+ if (debounceTimer !== null) {
471
+ clearTimeout(debounceTimer);
472
+ debounceTimer = null;
473
+ }
474
+ disposeEffect();
475
+ }
476
+
477
+ return { restore, clear, flush, dispose };
478
+ }
479
+
480
+ // =============================================================================
481
+ // DEFAULT EXPORT
482
+ // =============================================================================
483
+
484
+ export default {
485
+ createPersistenceAdapter,
486
+ createLocalStorageAdapter,
487
+ createSessionStorageAdapter,
488
+ createIndexedDBAdapter,
489
+ createMemoryAdapter,
490
+ withPersistence,
491
+ PersistenceError,
492
+ };