Files
sigpro/docs/src/api/storage.md
2026-03-17 12:01:25 +01:00

21 KiB

Storage API 💾

SigPro provides persistent signals that automatically synchronize with browser storage APIs. This allows you to create reactive state that survives page reloads and browser sessions with zero additional code.

Core Concepts

What is Persistent Storage?

Persistent signals are special signals that:

  • Initialize from storage (localStorage/sessionStorage) if a saved value exists
  • Auto-save whenever the signal value changes
  • Handle JSON serialization automatically
  • Clean up when set to null or undefined

Storage Types

Storage Persistence Use Case
localStorage Forever (until cleared) User preferences, themes, saved data
sessionStorage Until tab/window closes Form drafts, temporary state

$.storage(key, initialValue, [storage])

Creates a persistent signal that syncs with browser storage.

import { $ } from 'sigpro';

// localStorage (default)
const theme = $.storage('theme', 'light');
const user = $.storage('user', null);
const settings = $.storage('settings', { notifications: true });

// sessionStorage
const draft = $.storage('draft', '', sessionStorage);
const formData = $.storage('form', {}, sessionStorage);

📋 API Reference

Parameters

Parameter Type Default Description
key string required Storage key name
initialValue any required Default value if none stored
storage Storage localStorage Storage type (localStorage or sessionStorage)

Returns

Return Description
Function Signal function (getter/setter) with persistence

🎯 Basic Examples

Theme Preference

import { $, html } from 'sigpro';

// Persistent theme signal
const theme = $.storage('theme', 'light');

// Apply theme to document
$.effect(() => {
  document.body.className = `theme-${theme()}`;
});

// Toggle theme
const toggleTheme = () => {
  theme(t => t === 'light' ? 'dark' : 'light');
};

// Template
html`
  <div>
    <p>Current theme: ${theme}</p>
    <button @click=${toggleTheme}>
      Toggle Theme
    </button>
  </div>
`;

User Preferences

import { $ } from 'sigpro';

// Complex preferences object
const preferences = $.storage('preferences', {
  language: 'en',
  fontSize: 'medium',
  notifications: true,
  compactView: false,
  sidebarOpen: true
});

// Update single preference
const setPreference = (key, value) => {
  preferences({
    ...preferences(),
    [key]: value
  });
};

// Usage
setPreference('language', 'es');
setPreference('fontSize', 'large');
console.log(preferences().language); // 'es'

Form Draft

import { $, html } from 'sigpro';

// Session-based draft (clears when tab closes)
const draft = $.storage('contact-form', {
  name: '',
  email: '',
  message: ''
}, sessionStorage);

// Auto-save on input
const handleInput = (field, value) => {
  draft({
    ...draft(),
    [field]: value
  });
};

// Clear draft after submit
const handleSubmit = async () => {
  await submitForm(draft());
  draft(null); // Clears from storage
};

// Template
html`
  <form @submit=${handleSubmit}>
    <input
      type="text"
      :value=${() => draft().name}
      @input=${(e) => handleInput('name', e.target.value)}
      placeholder="Name"
    />
    <input
      type="email"
      :value=${() => draft().email}
      @input=${(e) => handleInput('email', e.target.value)}
      placeholder="Email"
    />
    <textarea
      :value=${() => draft().message}
      @input=${(e) => handleInput('message', e.target.value)}
      placeholder="Message"
    ></textarea>
    <button type="submit">Send</button>
  </form>
`;

🚀 Advanced Examples

Authentication State

import { $, html } from 'sigpro';

// Persistent auth state
const auth = $.storage('auth', {
  token: null,
  user: null,
  expiresAt: null
});

// Computed helpers
const isAuthenticated = $(() => {
  const { token, expiresAt } = auth();
  if (!token || !expiresAt) return false;
  return new Date(expiresAt) > new Date();
});

const user = $(() => auth().user);

// Login function
const login = async (email, password) => {
  const response = await fetch('/api/login', {
    method: 'POST',
    body: JSON.stringify({ email, password })
  });
  
  if (response.ok) {
    const { token, user, expiresIn } = await response.json();
    auth({
      token,
      user,
      expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString()
    });
    return true;
  }
  return false;
};

// Logout
const logout = () => {
  auth(null); // Clear from storage
};

// Auto-refresh token
$.effect(() => {
  if (!isAuthenticated()) return;
  
  const { expiresAt } = auth();
  const expiresIn = new Date(expiresAt) - new Date();
  const refreshTime = expiresIn - 60000; // 1 minute before expiry
  
  if (refreshTime > 0) {
    const timer = setTimeout(refreshToken, refreshTime);
    return () => clearTimeout(timer);
  }
});

// Navigation guard
$.effect(() => {
  if (!isAuthenticated() && window.location.pathname !== '/login') {
    $.router.go('/login');
  }
});

Multi-tab Synchronization

import { $ } from 'sigpro';

// Storage key for cross-tab communication
const STORAGE_KEY = 'app-state';

// Create persistent signal
const appState = $.storage(STORAGE_KEY, {
  count: 0,
  lastUpdated: null
});

// Listen for storage events (changes from other tabs)
window.addEventListener('storage', (event) => {
  if (event.key === STORAGE_KEY && event.newValue) {
    try {
      // Update signal without triggering save loop
      const newValue = JSON.parse(event.newValue);
      appState(newValue);
    } catch (e) {
      console.error('Failed to parse storage event:', e);
    }
  }
});

// Update state (syncs across all tabs)
const increment = () => {
  appState({
    count: appState().count + 1,
    lastUpdated: new Date().toISOString()
  });
};

// Tab counter
const tabCount = $(1);

// Track number of tabs open
window.addEventListener('storage', (event) => {
  if (event.key === 'tab-heartbeat') {
    tabCount(parseInt(event.newValue) || 1);
  }
});

// Send heartbeat
setInterval(() => {
  localStorage.setItem('tab-heartbeat', tabCount());
}, 1000);

Settings Manager

import { $, html } from 'sigpro';

// Settings schema
const settingsSchema = {
  theme: {
    type: 'select',
    options: ['light', 'dark', 'system'],
    default: 'system'
  },
  fontSize: {
    type: 'range',
    min: 12,
    max: 24,
    default: 16
  },
  notifications: {
    type: 'checkbox',
    default: true
  },
  language: {
    type: 'select',
    options: ['en', 'es', 'fr', 'de'],
    default: 'en'
  }
};

// Persistent settings
const settings = $.storage('app-settings', 
  Object.entries(settingsSchema).reduce((acc, [key, config]) => ({
    ...acc,
    [key]: config.default
  }), {})
);

// Settings component
const SettingsPanel = () => {
  return html`
    <div class="settings-panel">
      <h2>Settings</h2>
      
      ${Object.entries(settingsSchema).map(([key, config]) => {
        switch(config.type) {
          case 'select':
            return html`
              <div class="setting">
                <label>${key}:</label>
                <select 
                  :value=${() => settings()[key]}
                  @change=${(e) => updateSetting(key, e.target.value)}
                >
                  ${config.options.map(opt => html`
                    <option value="${opt}" ?selected=${() => settings()[key] === opt}>
                      ${opt}
                    </option>
                  `)}
                </select>
              </div>
            `;
            
          case 'range':
            return html`
              <div class="setting">
                <label>${key}: ${() => settings()[key]}</label>
                <input
                  type="range"
                  min="${config.min}"
                  max="${config.max}"
                  :value=${() => settings()[key]}
                  @input=${(e) => updateSetting(key, parseInt(e.target.value))}
                />
              </div>
            `;
            
          case 'checkbox':
            return html`
              <div class="setting">
                <label>
                  <input
                    type="checkbox"
                    :checked=${() => settings()[key]}
                    @change=${(e) => updateSetting(key, e.target.checked)}
                  />
                  ${key}
                </label>
              </div>
            `;
        }
      })}
      
      <button @click=${resetDefaults}>Reset to Defaults</button>
    </div>
  `;
};

// Helper functions
const updateSetting = (key, value) => {
  settings({
    ...settings(),
    [key]: value
  });
};

const resetDefaults = () => {
  const defaults = Object.entries(settingsSchema).reduce((acc, [key, config]) => ({
    ...acc,
    [key]: config.default
  }), {});
  settings(defaults);
};

// Apply settings globally
$.effect(() => {
  const { theme, fontSize } = settings();
  
  // Apply theme
  document.documentElement.setAttribute('data-theme', theme);
  
  // Apply font size
  document.documentElement.style.fontSize = `${fontSize}px`;
});

Shopping Cart Persistence

import { $, html } from 'sigpro';

// Persistent shopping cart
const cart = $.storage('shopping-cart', {
  items: [],
  lastUpdated: null
});

// Computed values
const cartItems = $(() => cart().items);
const itemCount = $(() => cartItems().reduce((sum, item) => sum + item.quantity, 0));
const subtotal = $(() => cartItems().reduce((sum, item) => sum + (item.price * item.quantity), 0));
const tax = $(() => subtotal() * 0.1);
const total = $(() => subtotal() + tax());

// Cart actions
const addToCart = (product, quantity = 1) => {
  const existing = cartItems().findIndex(item => item.id === product.id);
  
  if (existing >= 0) {
    // Update quantity
    const newItems = [...cartItems()];
    newItems[existing] = {
      ...newItems[existing],
      quantity: newItems[existing].quantity + quantity
    };
    
    cart({
      items: newItems,
      lastUpdated: new Date().toISOString()
    });
  } else {
    // Add new item
    cart({
      items: [...cartItems(), { ...product, quantity }],
      lastUpdated: new Date().toISOString()
    });
  }
};

const removeFromCart = (productId) => {
  cart({
    items: cartItems().filter(item => item.id !== productId),
    lastUpdated: new Date().toISOString()
  });
};

const updateQuantity = (productId, quantity) => {
  if (quantity <= 0) {
    removeFromCart(productId);
  } else {
    const newItems = cartItems().map(item =>
      item.id === productId ? { ...item, quantity } : item
    );
    
    cart({
      items: newItems,
      lastUpdated: new Date().toISOString()
    });
  }
};

const clearCart = () => {
  cart({
    items: [],
    lastUpdated: new Date().toISOString()
  });
};

// Cart expiration (7 days)
const CART_EXPIRY_DAYS = 7;

$.effect(() => {
  const lastUpdated = cart().lastUpdated;
  if (lastUpdated) {
    const expiryDate = new Date(lastUpdated);
    expiryDate.setDate(expiryDate.getDate() + CART_EXPIRY_DAYS);
    
    if (new Date() > expiryDate) {
      clearCart();
    }
  }
});

// Cart display component
const CartDisplay = () => html`
  <div class="cart">
    <h3>Shopping Cart (${itemCount} items)</h3>
    
    ${cartItems().map(item => html`
      <div class="cart-item">
        <span>${item.name}</span>
        <span>$${item.price} x ${item.quantity}</span>
        <span>$${item.price * item.quantity}</span>
        <button @click=${() => removeFromCart(item.id)}>Remove</button>
        <input
          type="number"
          min="1"
          :value=${item.quantity}
          @change=${(e) => updateQuantity(item.id, parseInt(e.target.value))}
        />
      </div>
    `)}
    
    <div class="cart-totals">
      <p>Subtotal: $${subtotal}</p>
      <p>Tax (10%): $${tax}</p>
      <p><strong>Total: $${total}</strong></p>
    </div>
    
    ${() => cartItems().length > 0 ? html`
      <button @click=${checkout}>Checkout</button>
      <button @click=${clearCart}>Clear Cart</button>
    ` : html`
      <p>Your cart is empty</p>
    `}
  </div>
`;

Recent Searches History

import { $, html } from 'sigpro';

// Persistent search history (max 10 items)
const searchHistory = $.storage('search-history', []);

// Add search to history
const addSearch = (query) => {
  if (!query.trim()) return;
  
  const current = searchHistory();
  const newHistory = [
    { query, timestamp: new Date().toISOString() },
    ...current.filter(item => item.query !== query)
  ].slice(0, 10); // Keep only last 10
  
  searchHistory(newHistory);
};

// Clear history
const clearHistory = () => {
  searchHistory([]);
};

// Remove specific item
const removeFromHistory = (query) => {
  searchHistory(searchHistory().filter(item => item.query !== query));
};

// Search component
const SearchWithHistory = () => {
  const searchInput = $('');
  
  const handleSearch = () => {
    const query = searchInput();
    if (query) {
      addSearch(query);
      performSearch(query);
      searchInput('');
    }
  };
  
  return html`
    <div class="search-container">
      <div class="search-box">
        <input
          type="search"
          :value=${searchInput}
          @keydown.enter=${handleSearch}
          placeholder="Search..."
        />
        <button @click=${handleSearch}>Search</button>
      </div>
      
      ${() => searchHistory().length > 0 ? html`
        <div class="search-history">
          <h4>Recent Searches</h4>
          ${searchHistory().map(item => html`
            <div class="history-item">
              <button 
                class="history-query"
                @click=${() => {
                  searchInput(item.query);
                  handleSearch();
                }}
              >
                🔍 ${item.query}
              </button>
              <small>${new Date(item.timestamp).toLocaleString()}</small>
              <button 
                class="remove-btn"
                @click=${() => removeFromHistory(item.query)}
              >
              </button>
            </div>
          `)}
          <button class="clear-btn" @click=${clearHistory}>
            Clear History
          </button>
        </div>
      ` : ''}
    </div>
  `;
};

Multiple Profiles / Accounts

import { $, html } from 'sigpro';

// Profile manager
const profiles = $.storage('user-profiles', {
  current: 'default',
  list: {
    default: {
      name: 'Default',
      theme: 'light',
      preferences: {}
    }
  }
});

// Switch profile
const switchProfile = (profileId) => {
  profiles({
    ...profiles(),
    current: profileId
  });
};

// Create profile
const createProfile = (name) => {
  const id = `profile-${Date.now()}`;
  profiles({
    current: id,
    list: {
      ...profiles().list,
      [id]: {
        name,
        theme: 'light',
        preferences: {},
        createdAt: new Date().toISOString()
      }
    }
  });
  return id;
};

// Delete profile
const deleteProfile = (profileId) => {
  if (profileId === 'default') return; // Can't delete default
  
  const newList = { ...profiles().list };
  delete newList[profileId];
  
  profiles({
    current: 'default',
    list: newList
  });
};

// Get current profile data
const currentProfile = $(() => {
  const { current, list } = profiles();
  return list[current] || list.default;
});

// Profile-aware settings
const profileTheme = $(() => currentProfile().theme);
const profilePreferences = $(() => currentProfile().preferences);

// Update profile data
const updateCurrentProfile = (updates) => {
  const { current, list } = profiles();
  profiles({
    current,
    list: {
      ...list,
      [current]: {
        ...list[current],
        ...updates
      }
    }
  });
};

// Profile selector component
const ProfileSelector = () => html`
  <div class="profile-selector">
    <select 
      :value=${() => profiles().current}
      @change=${(e) => switchProfile(e.target.value)}
    >
      ${Object.entries(profiles().list).map(([id, profile]) => html`
        <option value="${id}">${profile.name}</option>
      `)}
    </select>
    
    <button @click=${() => {
      const name = prompt('Enter profile name:');
      if (name) createProfile(name);
    }}>
      New Profile
    </button>
  </div>
`;

🛡️ Error Handling

Storage Errors

import { $ } from 'sigpro';

// Safe storage wrapper
const safeStorage = (key, initialValue, storage = localStorage) => {
  try {
    return $.storage(key, initialValue, storage);
  } catch (error) {
    console.warn(`Storage failed for ${key}, using in-memory fallback:`, error);
    return $(initialValue);
  }
};

// Usage with fallback
const theme = safeStorage('theme', 'light');
const user = safeStorage('user', null);

Quota Exceeded Handling

import { $ } from 'sigpro';

const createManagedStorage = (key, initialValue, maxSize = 1024 * 100) => { // 100KB limit
  const signal = $.storage(key, initialValue);
  
  // Monitor size
  const size = $(0);
  
  $.effect(() => {
    try {
      const value = signal();
      const json = JSON.stringify(value);
      const bytes = new Blob([json]).size;
      
      size(bytes);
      
      if (bytes > maxSize) {
        console.warn(`Storage for ${key} exceeded ${maxSize} bytes`);
        // Could implement cleanup strategy here
      }
    } catch (e) {
      console.error('Size check failed:', e);
    }
  });
  
  return { signal, size };
};

// Usage
const { signal: largeData, size } = createManagedStorage('app-data', {}, 50000);

📊 Storage Limits

Storage Type Typical Limit Notes
localStorage 5-10MB Varies by browser
sessionStorage 5-10MB Cleared when tab closes
cookies 4KB Not recommended for SigPro

🎯 Best Practices

1. Validate Stored Data

import { $ } from 'sigpro';

// Schema validation
const createValidatedStorage = (key, schema, defaultValue, storage) => {
  const signal = $.storage(key, defaultValue, storage);
  
  // Wrap to validate on read/write
  const validated = (...args) => {
    if (args.length) {
      // Validate before writing
      const value = args[0];
      if (typeof value === 'function') {
        // Handle functional updates
        return validated(validated());
      }
      
      // Basic validation
      const isValid = Object.keys(schema).every(key => {
        const validator = schema[key];
        return !validator || validator(value[key]);
      });
      
      if (!isValid) {
        console.warn('Invalid data, skipping storage write');
        return signal();
      }
    }
    
    return signal(...args);
  };
  
  return validated;
};

// Usage
const userSchema = {
  name: v => v && v.length > 0,
  age: v => v >= 18 && v <= 120,
  email: v => /@/.test(v)
};

const user = createValidatedStorage('user', userSchema, {
  name: '',
  age: 25,
  email: ''
});

2. Handle Versioning

import { $ } from 'sigpro';

const VERSION = 2;

const createVersionedStorage = (key, migrations, storage) => {
  const raw = $.storage(key, { version: VERSION, data: {} }, storage);
  
  const migrate = (data) => {
    let current = data;
    const currentVersion = current.version || 1;
    
    for (let v = currentVersion; v < VERSION; v++) {
      const migrator = migrations[v];
      if (migrator) {
        current = migrator(current);
      }
    }
    
    return current;
  };
  
  // Migrate if needed
  const stored = raw();
  if (stored.version !== VERSION) {
    const migrated = migrate(stored);
    raw(migrated);
  }
  
  return raw;
};

// Usage
const migrations = {
  1: (old) => ({
    version: 2,
    data: {
      ...old.data,
      preferences: old.preferences || {}
    }
  })
};

const settings = createVersionedStorage('app-settings', migrations);

3. Encrypt Sensitive Data

import { $ } from 'sigpro';

// Simple encryption (use proper crypto in production)
const encrypt = (text) => {
  return btoa(text); // Base64 - NOT secure, just example
};

const decrypt = (text) => {
  try {
    return atob(text);
  } catch {
    return null;
  }
};

const createSecureStorage = (key, initialValue, storage) => {
  const encryptedKey = `enc_${key}`;
  const signal = $.storage(encryptedKey, null, storage);
  
  const secure = (...args) => {
    if (args.length) {
      // Encrypt before storing
      const value = args[0];
      const encrypted = encrypt(JSON.stringify(value));
      return signal(encrypted);
    }
    
    // Decrypt when reading
    const encrypted = signal();
    if (!encrypted) return initialValue;
    
    try {
      const decrypted = decrypt(encrypted);
      return decrypted ? JSON.parse(decrypted) : initialValue;
    } catch {
      return initialValue;
    }
  };
  
  return secure;
};

// Usage
const secureToken = createSecureStorage('auth-token', null);
secureToken('sensitive-data-123'); // Stored encrypted

📈 Performance Considerations

Operation Cost Notes
Initial read O(1) Single storage read
Write O(1) + JSON.stringify Auto-save on change
Large objects O(n) Stringify/parse overhead
Multiple keys O(k) k = number of keys

Pro Tip: Use sessionStorage for temporary data like form drafts, and localStorage for persistent user preferences. Always validate data when reading from storage to handle corrupted values gracefully.