ext_storage
The ext_storage crate provides persistent key-value storage backed by SQLite for Forge applications through the runtime:storage module.
Overview
ext_storage provides a simple, reliable way to persist application data using a SQLite database. All values are automatically serialized to JSON, supporting strings, numbers, booleans, arrays, and objects.
Key Features:
- SQLite Backend - ACID-compliant persistent storage
- Automatic Serialization - JSON encoding/decoding for all JavaScript values
- Batch Operations - Efficient bulk reads, writes, and deletes (~10x faster)
- Indexed Queries - Fast key lookups with SQLite indexing
- Transactional Writes - Atomic batch operations with rollback support
- Timestamps - Automatic
created_atandupdated_attracking
Quick Start
import { get, set, remove, has, keys } from "runtime:storage";
// Store valuesawait set("user.name", "Alice");await set("user.preferences", { theme: "dark", fontSize: 14 });
// Retrieve valuesconst name = await get<string>("user.name");const prefs = await get<UserPrefs>("user.preferences");
// Check existenceif (await has("user.session")) { await remove("user.session");}
// List all keysconst allKeys = await keys();Module Import
import { // Basic operations get, set, remove, has, keys, clear, size,
// Batch operations getMany, setMany, deleteMany} from "runtime:storage";Core Concepts
Storage Location
The SQLite database is created at:
- macOS:
~/Library/Application Support/.forge/<app-id>/storage.db - Linux:
~/.local/share/.forge/<app-id>/storage.db - Windows:
%APPDATA%\.forge\<app-id>\storage.db
JSON Serialization
All values are automatically serialized to JSON:
Supported Types:
- Primitives:
string,number,boolean,null - Arrays:
string[],number[], etc. - Objects:
{ key: value }, nested objects
Not Supported:
undefined, functions, circular references,BigInt,Symbol
Performance
- Individual Operations: ~1-2ms per operation
- Batch Operations: ~0.1ms per item (~10x faster for 10+ items)
- Database: Indexed for fast key lookups, connection is reused
API Reference
get()
Retrieves a value from storage by key.
function get<T = unknown>(key: string): Promise<T | null>Parameters:
key- The key to retrieve (must be non-empty)
Returns:
- The stored value, or
nullif the key doesn’t exist
Throws:
[8106]if key is empty[8103]if stored value cannot be deserialized[8104]if database operation fails
Examples:
// Simple retrievalconst username = await get<string>("user.name");if (username) { console.log(`Welcome ${username}`);}
// With default valueconst theme = await get<string>("prefs.theme") ?? "light";
// Complex objectsinterface WindowBounds { x: number; y: number; width: number; height: number;}
const bounds = await get<WindowBounds>("window.bounds");set()
Stores a value in persistent storage.
function set<T = unknown>(key: string, value: T): Promise<void>Parameters:
key- The key to store under (must be non-empty)value- The value to store (must be JSON-serializable)
Throws:
[8106]if key is empty[8102]if value cannot be serialized to JSON[8104]if database operation fails
Examples:
// Store primitive valuesawait set("app.version", "1.0.0");await set("user.id", 12345);await set("feature.enabled", true);
// Store complex objectsawait set("user.profile", { name: "Alice", email: "alice@example.com", role: "admin"});
// Store arraysawait set("recent.files", [ "/path/to/file1.txt", "/path/to/file2.txt"]);
// Update existing valueconst count = await get<number>("app.launchCount") ?? 0;await set("app.launchCount", count + 1);remove()
Removes a key and its value from storage.
function remove(key: string): Promise<boolean>Parameters:
key- The key to delete
Returns:
trueif the key existed and was deleted,falseotherwise
Throws:
[8104]if database operation fails
Examples:
// Remove single keyconst wasDeleted = await remove("user.session");if (wasDeleted) { console.log("Session cleared");}
// Clear user data on logoutawait remove("user.token");await remove("user.profile");await remove("user.preferences");has()
Checks whether a key exists in storage.
function has(key: string): Promise<boolean>Parameters:
key- The key to check
Returns:
trueif the key exists,falseotherwise
Throws:
[8104]if database operation fails
Examples:
// Check before readingif (await has("user.profile")) { const profile = await get("user.profile");}
// Initialize on first runif (!await has("app.initialized")) { await set("app.initialized", true); await runFirstTimeSetup();}keys()
Retrieves all keys currently stored in the database.
function keys(): Promise<string[]>Returns:
- Array of all keys, sorted alphabetically
Throws:
[8104]if database operation fails
Examples:
// List all keysconst allKeys = await keys();console.log(`Storage contains ${allKeys.length} keys`);
// Filter keys by prefixconst userKeys = allKeys.filter(k => k.startsWith("user."));
// Migrate old keysfor (const oldKey of allKeys.filter(k => k.startsWith("old_"))) { const value = await get(oldKey); const newKey = oldKey.replace("old_", "new_"); await set(newKey, value); await remove(oldKey);}clear()
Removes all key-value pairs from storage.
function clear(): Promise<void>Throws:
[8104]if database operation fails
Warning: This operation is irreversible!
Examples:
// Reset to defaultsawait clear();await set("app.version", "1.0.0");await set("app.firstRun", true);
// Development mode resetif (Deno.env.get("DEV_MODE") === "true") { await clear();}size()
Returns the total size of all stored values in bytes.
function size(): Promise<number>Returns:
- Total size in bytes of all stored values
Throws:
[8104]if database operation fails
Examples:
// Check storage usageconst bytes = await size();console.log(`Using ${(bytes / 1024).toFixed(2)} KB`);
// Enforce quotaconst MAX_SIZE = 10 * 1024 * 1024; // 10 MBif (await size() > MAX_SIZE) { throw new Error("Storage quota exceeded");}
// Monitor growthconst before = await size();await set("large.dataset", bigArray);const after = await size();console.log(`Added ${after - before} bytes`);getMany()
Efficiently retrieves multiple values at once.
function getMany(keys: string[]): Promise<Map<string, unknown>>Parameters:
keys- Array of keys to retrieve
Returns:
- Map containing key-value pairs for keys that were found (missing keys are omitted)
Throws:
[8104]if database operation fails
Performance: Approximately 10x faster than individual get() calls for 10+ keys.
Examples:
// Bulk retrievalconst values = await getMany(["user.name", "user.email", "user.role"]);console.log("Name:", values.get("user.name"));console.log("Email:", values.get("user.email"));
// Load app stateconst state = await getMany([ "window.bounds", "window.maximized", "recent.files"]);
// Check which keys existconst found = await getMany(["key1", "key2", "key3"]);for (const key of ["key1", "key2", "key3"]) { if (found.has(key)) { console.log(`${key}: ${found.get(key)}`); }}setMany()
Atomically stores multiple key-value pairs at once.
function setMany(entries: Record<string, unknown>): Promise<void>Parameters:
entries- Object containing key-value pairs to store
Throws:
[8106]if any key is empty[8102]if any value cannot be serialized[8104]if database operation fails[8109]if transaction fails (all changes rolled back)
Performance: Approximately 10x faster than individual set() calls for 10+ pairs.
Atomicity: Either all writes succeed or none do (transaction rollback).
Examples:
// Bulk initializationawait setMany({ "app.version": "1.0.0", "app.firstRun": true, "user.theme": "dark"});
// Save app state atomicallyawait setMany({ "window.bounds": { x: 100, y: 100, width: 800, height: 600 }, "window.maximized": false, "recent.files": ["/path/to/file1.txt"], "recent.searches": ["typescript"]});
// All-or-nothing behaviortry { await setMany({ "user.name": "Alice", "user.invalid": circularRef // This fails! });} catch (err) { // Neither value was saved (transaction rolled back) console.error("Save failed:", err);}deleteMany()
Efficiently deletes multiple keys at once.
function deleteMany(keys: string[]): Promise<number>Parameters:
keys- Array of keys to delete
Returns:
- Number of keys that existed and were successfully deleted
Throws:
[8104]if database operation fails
Performance: Approximately 10x faster than individual remove() calls for 10+ keys.
Examples:
// Clear session dataconst deleted = await deleteMany([ "session.token", "session.userId", "session.expires"]);console.log(`Deleted ${deleted} session keys`);
// Clean up cacheconst allKeys = await keys();const cacheKeys = allKeys.filter(k => k.startsWith("cache."));if (cacheKeys.length > 0) { await deleteMany(cacheKeys);}
// Batch cleanup with verificationconst keysToDelete = ["key1", "key2", "key3"];const deleted = await deleteMany(keysToDelete);if (deleted === keysToDelete.length) { console.log("All keys deleted");} else { console.log(`Only ${deleted}/${keysToDelete.length} existed`);}Usage Examples
1. Application State Persistence
import { getMany, setMany } from "runtime:storage";
// Save app state on exitasync function saveAppState() { await setMany({ "window.bounds": getCurrentWindowBounds(), "window.maximized": isWindowMaximized(), "recent.files": getRecentFiles(), "editor.openFiles": getOpenFiles(), "editor.activeFile": getActiveFile() });}
// Restore app state on launchasync function restoreAppState() { const state = await getMany([ "window.bounds", "window.maximized", "recent.files", "editor.openFiles", "editor.activeFile" ]);
if (state.has("window.bounds")) { restoreWindowBounds(state.get("window.bounds")); } if (state.get("window.maximized")) { maximizeWindow(); } if (state.has("recent.files")) { setRecentFiles(state.get("recent.files") as string[]); }}2. User Preferences with Defaults
import { get, set } from "runtime:storage";
async function getPreference<T>(key: string, defaultValue: T): Promise<T> { const value = await get<T>(`prefs.${key}`); return value ?? defaultValue;}
async function setPreference<T>(key: string, value: T): Promise<void> { await set(`prefs.${key}`, value);}
// Usageconst theme = await getPreference("theme", "light");const fontSize = await getPreference("fontSize", 14);const autoSave = await getPreference("autoSave", true);
await setPreference("theme", "dark");3. Caching with Expiration
import { get, set, remove } from "runtime:storage";
interface CacheEntry<T> { value: T; expiresAt: number;}
async function cacheSet<T>(key: string, value: T, ttlMs: number): Promise<void> { await set(`cache.${key}`, { value, expiresAt: Date.now() + ttlMs } as CacheEntry<T>);}
async function cacheGet<T>(key: string): Promise<T | null> { const entry = await get<CacheEntry<T>>(`cache.${key}`); if (!entry) return null;
if (Date.now() > entry.expiresAt) { await remove(`cache.${key}`); return null; }
return entry.value;}
// Usageawait cacheSet("api.users", usersData, 60 * 1000); // 1 minuteconst users = await cacheGet("api.users");4. Storage Quota Management
import { size, keys, deleteMany, set } from "runtime:storage";
const MAX_STORAGE = 10 * 1024 * 1024; // 10 MB
async function setWithQuota(key: string, value: unknown): Promise<void> { const currentSize = await size(); const valueSize = JSON.stringify(value).length;
if (currentSize + valueSize > MAX_STORAGE) { // Clean up cache const allKeys = await keys(); const cacheKeys = allKeys.filter(k => k.startsWith("cache."));
if (cacheKeys.length > 0) { await deleteMany(cacheKeys); console.log("Cleared cache to free space"); } else { throw new Error("Storage quota exceeded"); } }
await set(key, value);}5. Data Migration
import { get, set, remove, keys } from "runtime:storage";
const STORAGE_VERSION = 2;
async function migrateStorageIfNeeded(): Promise<void> { const version = await get<number>("storage.version") ?? 1;
if (version < 2) { console.log("Migrating storage to version 2...");
const allKeys = await keys(); const oldKeys = allKeys.filter(k => k.startsWith("old_"));
for (const oldKey of oldKeys) { const value = await get(oldKey); const newKey = oldKey.replace("old_", "new_"); await set(newKey, value); await remove(oldKey); }
await set("storage.version", 2); console.log("Migration complete"); }}Best Practices
✅ Do
-
Use Batch Operations for Multiple Items
// Good - 10x fasterconst values = await getMany(["key1", "key2", "key3"]);// Bad - 3 separate queriesconst val1 = await get("key1");const val2 = await get("key2");const val3 = await get("key3"); -
Use Namespaced Keys
// Good - organized and clearawait set("user.profile.name", "Alice");await set("cache.api.users", usersData);// Bad - hard to manageawait set("name", "Alice");await set("users", usersData); -
Provide Default Values
// Good - safe fallbackconst theme = await get<string>("prefs.theme") ?? "light";// Bad - might be nullconst theme = await get<string>("prefs.theme"); -
Use TypeScript Generics
// Good - type-safeconst count = await get<number>("app.launchCount") ?? 0;// Bad - requires castingconst count = (await get("app.launchCount") || 0) as number; -
Handle Serialization Errors
// Good - graceful error handlingtry {await set("user.data", complexObject);} catch (err) {if (err.message.includes("[8102]")) {console.error("Circular reference detected");}}
Common Pitfalls
❌ Don’t
-
Don’t Use
get()in Loops// Bad - very slowconst values = [];for (const key of manyKeys) {values.push(await get(key));}// Good - 10x fasterconst values = await getMany(manyKeys); -
Don’t Store Circular References
// Bad - will throw [8102]const circular: any = { name: "Alice" };circular.self = circular;await set("user", circular); // Error!// Good - break the cycleconst { self, ...clean } = circular;await set("user", clean); -
Don’t Assume Values Exist
// Bad - might throw if missingconst profile = await get<UserProfile>("user.profile");console.log(profile.name); // Error if null!// Good - check firstconst profile = await get<UserProfile>("user.profile");if (profile) {console.log(profile.name);} -
Don’t Store Very Large Values
// Bad - use ext_database insteadawait set("huge.dataset", tenMBArray);// Good - use appropriate storageimport { query } from "runtime:database";await query("INSERT INTO datasets VALUES (?)", [data]); -
Don’t Use Empty Keys
// Bad - will throw [8106]await set("", "value"); // Error!// Good - use meaningful keysawait set("app.defaultValue", "value");
Error Handling
All storage operations may throw errors with structured codes:
| Code | Error | Description |
|---|---|---|
8100 | Generic | Unspecified storage error |
8101 | NotFound | Key does not exist |
8102 | SerializationError | Value cannot be serialized to JSON |
8103 | DeserializationError | Stored value is not valid JSON |
8104 | DatabaseError | SQLite operation failed |
8105 | PermissionDenied | Storage operation not permitted |
8106 | InvalidKey | Key is invalid (empty) |
8107 | QuotaExceeded | Storage quota limit reached |
8108 | ConnectionFailed | Database connection failed |
8109 | TransactionFailed | Batch operation rolled back |
Error Handling Pattern
try { await set("user.data", value);} catch (err) { const message = err.message;
if (message.includes("[8102]")) { console.error("Cannot serialize value (circular reference?)"); } else if (message.includes("[8106]")) { console.error("Key cannot be empty"); } else if (message.includes("[8104]")) { console.error("Database error:", message); } else { console.error("Storage error:", message); }}Platform Support
| Platform | Supported | Storage Location |
|---|---|---|
| macOS | ✅ | ~/Library/Application Support/.forge/<app-id> |
| Linux | ✅ | ~/.local/share/.forge/<app-id> |
| Windows | ✅ | %APPDATA%\.forge\<app-id> |
Database file: storage.db
Permissions
Storage operations do not currently require permissions in manifest.app.toml, but this may change in future versions.
Related Extensions
ext_database- Full SQL database access for complex queriesext_app- Application paths and metadataext_crypto- Encryption for sensitive data