@pyreon/storage 0.6.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,205 @@
1
+ import { signal } from '@pyreon/reactivity'
2
+ import { getEntry, removeEntry, setEntry } from './registry'
3
+ import type { IndexedDBOptions, StorageSignal } from './types'
4
+ import { deserialize, isBrowser, serialize } from './utils'
5
+
6
+ // ─── Database management ─────────────────────────────────────────────────────
7
+
8
+ const dbCache = new Map<string, Promise<IDBDatabase>>()
9
+
10
+ function openDB(dbName: string, storeName: string): Promise<IDBDatabase> {
11
+ const cacheKey = `${dbName}:${storeName}`
12
+ const cached = dbCache.get(cacheKey)
13
+ if (cached) return cached
14
+
15
+ const promise = new Promise<IDBDatabase>((resolve, reject) => {
16
+ const request = indexedDB.open(dbName, 1)
17
+
18
+ request.onupgradeneeded = () => {
19
+ const db = request.result
20
+ if (!db.objectStoreNames.contains(storeName)) {
21
+ db.createObjectStore(storeName)
22
+ }
23
+ }
24
+
25
+ request.onsuccess = () => resolve(request.result)
26
+ request.onerror = () => reject(request.error)
27
+ })
28
+
29
+ dbCache.set(cacheKey, promise)
30
+ return promise
31
+ }
32
+
33
+ function idbGet(
34
+ db: IDBDatabase,
35
+ storeName: string,
36
+ key: string,
37
+ ): Promise<string | null> {
38
+ return new Promise((resolve, reject) => {
39
+ const tx = db.transaction(storeName, 'readonly')
40
+ const store = tx.objectStore(storeName)
41
+ const request = store.get(key)
42
+ request.onsuccess = () =>
43
+ resolve(request.result !== undefined ? (request.result as string) : null)
44
+ request.onerror = () => reject(request.error)
45
+ })
46
+ }
47
+
48
+ function idbSet(
49
+ db: IDBDatabase,
50
+ storeName: string,
51
+ key: string,
52
+ value: string,
53
+ ): Promise<void> {
54
+ return new Promise((resolve, reject) => {
55
+ const tx = db.transaction(storeName, 'readwrite')
56
+ const store = tx.objectStore(storeName)
57
+ const request = store.put(value, key)
58
+ request.onsuccess = () => resolve()
59
+ request.onerror = () => reject(request.error)
60
+ })
61
+ }
62
+
63
+ function idbDelete(
64
+ db: IDBDatabase,
65
+ storeName: string,
66
+ key: string,
67
+ ): Promise<void> {
68
+ return new Promise((resolve, reject) => {
69
+ const tx = db.transaction(storeName, 'readwrite')
70
+ const store = tx.objectStore(storeName)
71
+ const request = store.delete(key)
72
+ request.onsuccess = () => resolve()
73
+ request.onerror = () => reject(request.error)
74
+ })
75
+ }
76
+
77
+ // ─── useIndexedDB ────────────────────────────────────────────────────────────
78
+
79
+ /**
80
+ * Reactive signal backed by IndexedDB. Suitable for large or structured
81
+ * data that exceeds localStorage limits. Writes are debounced.
82
+ *
83
+ * The signal starts with `defaultValue` and updates asynchronously
84
+ * when the stored value is read from IndexedDB.
85
+ *
86
+ * @example
87
+ * ```ts
88
+ * const draft = useIndexedDB('article-draft', { title: '', body: '' })
89
+ * draft() // { title: '', body: '' } initially, then stored value
90
+ * draft.set({ title: 'My Post', body: '...' }) // signal updates immediately, IDB write is debounced
91
+ * ```
92
+ */
93
+ export function useIndexedDB<T>(
94
+ key: string,
95
+ defaultValue: T,
96
+ options: IndexedDBOptions<T> = {},
97
+ ): StorageSignal<T> {
98
+ // Return existing signal if already registered
99
+ const existing = getEntry<T>('indexeddb', key)
100
+ if (existing) return existing.signal
101
+
102
+ const dbName = options.dbName ?? 'pyreon-storage'
103
+ const storeName = options.storeName ?? 'kv'
104
+ const debounceMs = options.debounceMs ?? 100
105
+
106
+ const sig = signal<T>(defaultValue)
107
+
108
+ // Async initial load
109
+ if (isBrowser() && typeof indexedDB !== 'undefined') {
110
+ openDB(dbName, storeName)
111
+ .then((db) => idbGet(db, storeName, key))
112
+ .then((raw) => {
113
+ if (raw !== null) {
114
+ const value = deserialize(
115
+ raw,
116
+ defaultValue,
117
+ options.deserializer,
118
+ options.onError,
119
+ )
120
+ sig.set(value)
121
+ }
122
+ })
123
+ .catch(() => {
124
+ // IndexedDB not available — signal keeps defaultValue
125
+ })
126
+ }
127
+
128
+ // Debounced write
129
+ let writeTimer: ReturnType<typeof setTimeout> | null = null
130
+ let pendingValue: T | undefined
131
+
132
+ function flushWrite(): void {
133
+ if (pendingValue === undefined) return
134
+ const value = pendingValue
135
+ pendingValue = undefined
136
+
137
+ if (!isBrowser() || typeof indexedDB === 'undefined') return
138
+
139
+ openDB(dbName, storeName)
140
+ .then((db) =>
141
+ idbSet(db, storeName, key, serialize(value, options.serializer)),
142
+ )
143
+ .catch(() => {
144
+ // Write failed — signal still has the correct value
145
+ })
146
+ }
147
+
148
+ function scheduleWrite(value: T): void {
149
+ pendingValue = value
150
+ if (writeTimer !== null) clearTimeout(writeTimer)
151
+ writeTimer = setTimeout(flushWrite, debounceMs)
152
+ }
153
+
154
+ // Build the storage signal
155
+ const storageSig = (() => sig()) as unknown as StorageSignal<T>
156
+
157
+ storageSig.peek = () => sig.peek()
158
+ storageSig.subscribe = (listener: () => void) => sig.subscribe(listener)
159
+ storageSig.direct = (updater: () => void) => sig.direct(updater)
160
+ storageSig.debug = () => sig.debug()
161
+
162
+ Object.defineProperty(storageSig, 'label', {
163
+ get: () => sig.label,
164
+ set: (v: string | undefined) => {
165
+ sig.label = v
166
+ },
167
+ })
168
+
169
+ storageSig.set = (value: T) => {
170
+ sig.set(value)
171
+ scheduleWrite(value)
172
+ }
173
+
174
+ storageSig.update = (fn: (current: T) => T) => {
175
+ const newValue = fn(sig.peek())
176
+ storageSig.set(newValue)
177
+ }
178
+
179
+ storageSig.remove = () => {
180
+ sig.set(defaultValue)
181
+ pendingValue = undefined
182
+ if (writeTimer !== null) clearTimeout(writeTimer)
183
+
184
+ if (isBrowser() && typeof indexedDB !== 'undefined') {
185
+ openDB(dbName, storeName)
186
+ .then((db) => idbDelete(db, storeName, key))
187
+ .catch(() => {
188
+ // Delete failed — signal already reset
189
+ })
190
+ }
191
+
192
+ removeEntry('indexeddb', key)
193
+ }
194
+
195
+ setEntry('indexeddb', key, storageSig, defaultValue)
196
+
197
+ return storageSig
198
+ }
199
+
200
+ /**
201
+ * Reset the database cache. For testing only.
202
+ */
203
+ export function _resetDBCache(): void {
204
+ dbCache.clear()
205
+ }
package/src/local.ts ADDED
@@ -0,0 +1,143 @@
1
+ import { signal } from '@pyreon/reactivity'
2
+ import { getEntry, removeEntry, setEntry } from './registry'
3
+ import type { StorageOptions, StorageSignal } from './types'
4
+ import { deserialize, getWebStorage, isBrowser, serialize } from './utils'
5
+
6
+ // ─── Cross-tab sync ──────────────────────────────────────────────────────────
7
+
8
+ let listenerAttached = false
9
+
10
+ function attachStorageListener(): void {
11
+ if (listenerAttached || !isBrowser()) return
12
+ listenerAttached = true
13
+
14
+ window.addEventListener('storage', (e) => {
15
+ if (!e.key) return
16
+ const entry = getEntry('local', e.key)
17
+ if (!entry) return
18
+
19
+ const newValue =
20
+ e.newValue !== null
21
+ ? deserialize(e.newValue, entry.defaultValue)
22
+ : entry.defaultValue
23
+
24
+ entry.signal.set(newValue)
25
+ })
26
+ }
27
+
28
+ // ─── useStorage ──────────────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Reactive signal backed by localStorage. Automatically syncs across
32
+ * browser tabs via the native `storage` event.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * const theme = useStorage('theme', 'light')
37
+ * theme() // 'light' (or stored value)
38
+ * theme.set('dark') // updates signal + localStorage
39
+ * theme.remove() // clears storage, resets to default
40
+ * ```
41
+ */
42
+ export function useStorage<T>(
43
+ key: string,
44
+ defaultValue: T,
45
+ options?: StorageOptions<T>,
46
+ ): StorageSignal<T> {
47
+ // Return existing signal if already registered
48
+ const existing = getEntry<T>('local', key)
49
+ if (existing) return existing.signal
50
+
51
+ const storage = getWebStorage('local')
52
+
53
+ // Read initial value from storage
54
+ let initialValue = defaultValue
55
+ if (storage) {
56
+ const raw = storage.getItem(key)
57
+ if (raw !== null) {
58
+ initialValue = deserialize(
59
+ raw,
60
+ defaultValue,
61
+ options?.deserializer,
62
+ options?.onError,
63
+ )
64
+ }
65
+ }
66
+
67
+ const sig = signal<T>(initialValue)
68
+
69
+ // Create the storage signal by extending the base signal
70
+ const storageSig = createStorageSignal(
71
+ sig,
72
+ key,
73
+ defaultValue,
74
+ 'local',
75
+ options,
76
+ )
77
+
78
+ setEntry('local', key, storageSig, defaultValue)
79
+ attachStorageListener()
80
+
81
+ return storageSig
82
+ }
83
+
84
+ // ─── Storage Signal Factory ──────────────────────────────────────────────────
85
+
86
+ /**
87
+ * Wraps a base signal with storage persistence behavior.
88
+ * Used by both useStorage and useSessionStorage.
89
+ */
90
+ export function createStorageSignal<T>(
91
+ sig: ReturnType<typeof signal<T>>,
92
+ key: string,
93
+ defaultValue: T,
94
+ backend: 'local' | 'session',
95
+ options?: StorageOptions<T>,
96
+ ): StorageSignal<T> {
97
+ const storage = getWebStorage(backend)
98
+
99
+ // The callable signal function (read)
100
+ const storageSig = (() => sig()) as unknown as StorageSignal<T>
101
+
102
+ // Delegate all signal methods
103
+ storageSig.peek = () => sig.peek()
104
+ storageSig.subscribe = (listener: () => void) => sig.subscribe(listener)
105
+ storageSig.direct = (updater: () => void) => sig.direct(updater)
106
+ storageSig.debug = () => sig.debug()
107
+
108
+ Object.defineProperty(storageSig, 'label', {
109
+ get: () => sig.label,
110
+ set: (v: string | undefined) => {
111
+ sig.label = v
112
+ },
113
+ })
114
+
115
+ // Override set to persist
116
+ storageSig.set = (value: T) => {
117
+ sig.set(value)
118
+ if (storage) {
119
+ try {
120
+ storage.setItem(key, serialize(value, options?.serializer))
121
+ } catch {
122
+ // Storage full or blocked — signal still updates
123
+ }
124
+ }
125
+ }
126
+
127
+ // Override update to persist
128
+ storageSig.update = (fn: (current: T) => T) => {
129
+ const newValue = fn(sig.peek())
130
+ storageSig.set(newValue)
131
+ }
132
+
133
+ // Add remove method
134
+ storageSig.remove = () => {
135
+ sig.set(defaultValue)
136
+ if (storage) {
137
+ storage.removeItem(key)
138
+ }
139
+ removeEntry(backend, key)
140
+ }
141
+
142
+ return storageSig
143
+ }
@@ -0,0 +1,66 @@
1
+ import type { StorageSignal } from './types'
2
+
3
+ // ─── Signal Registry ─────────────────────────────────────────────────────────
4
+
5
+ interface RegistryEntry<T = unknown> {
6
+ signal: StorageSignal<T>
7
+ defaultValue: T
8
+ backend: string
9
+ }
10
+
11
+ const registry = new Map<string, RegistryEntry>()
12
+
13
+ /**
14
+ * Build a composite key from backend type + storage key to avoid
15
+ * collisions between different backends using the same key name.
16
+ */
17
+ function registryKey(backend: string, key: string): string {
18
+ return `${backend}:${key}`
19
+ }
20
+
21
+ /**
22
+ * Get an existing signal from the registry.
23
+ */
24
+ export function getEntry<T>(
25
+ backend: string,
26
+ key: string,
27
+ ): RegistryEntry<T> | undefined {
28
+ return registry.get(registryKey(backend, key)) as RegistryEntry<T> | undefined
29
+ }
30
+
31
+ /**
32
+ * Register a new signal in the registry.
33
+ */
34
+ export function setEntry<T>(
35
+ backend: string,
36
+ key: string,
37
+ signal: StorageSignal<T>,
38
+ defaultValue: T,
39
+ ): void {
40
+ registry.set(registryKey(backend, key), { signal, defaultValue, backend })
41
+ }
42
+
43
+ /**
44
+ * Remove an entry from the registry.
45
+ */
46
+ export function removeEntry(backend: string, key: string): void {
47
+ registry.delete(registryKey(backend, key))
48
+ }
49
+
50
+ /**
51
+ * Get all entries for a specific backend.
52
+ */
53
+ export function getEntriesByBackend(backend: string): RegistryEntry[] {
54
+ const entries: RegistryEntry[] = []
55
+ for (const entry of registry.values()) {
56
+ if (entry.backend === backend) entries.push(entry)
57
+ }
58
+ return entries
59
+ }
60
+
61
+ /**
62
+ * Clear all entries from the registry. Used for testing.
63
+ */
64
+ export function _resetRegistry(): void {
65
+ registry.clear()
66
+ }
package/src/session.ts ADDED
@@ -0,0 +1,58 @@
1
+ import { signal } from '@pyreon/reactivity'
2
+ import { createStorageSignal } from './local'
3
+ import { getEntry, setEntry } from './registry'
4
+ import type { StorageOptions, StorageSignal } from './types'
5
+ import { deserialize, getWebStorage } from './utils'
6
+
7
+ // ─── useSessionStorage ───────────────────────────────────────────────────────
8
+
9
+ /**
10
+ * Reactive signal backed by sessionStorage. Scoped to the current
11
+ * browser tab — does not sync across tabs.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * const step = useSessionStorage('wizard-step', 0)
16
+ * step() // 0 (or stored value)
17
+ * step.set(3) // updates signal + sessionStorage
18
+ * step.remove() // clears storage, resets to default
19
+ * ```
20
+ */
21
+ export function useSessionStorage<T>(
22
+ key: string,
23
+ defaultValue: T,
24
+ options?: StorageOptions<T>,
25
+ ): StorageSignal<T> {
26
+ // Return existing signal if already registered
27
+ const existing = getEntry<T>('session', key)
28
+ if (existing) return existing.signal
29
+
30
+ const storage = getWebStorage('session')
31
+
32
+ // Read initial value from storage
33
+ let initialValue = defaultValue
34
+ if (storage) {
35
+ const raw = storage.getItem(key)
36
+ if (raw !== null) {
37
+ initialValue = deserialize(
38
+ raw,
39
+ defaultValue,
40
+ options?.deserializer,
41
+ options?.onError,
42
+ )
43
+ }
44
+ }
45
+
46
+ const sig = signal<T>(initialValue)
47
+ const storageSig = createStorageSignal(
48
+ sig,
49
+ key,
50
+ defaultValue,
51
+ 'session',
52
+ options,
53
+ )
54
+
55
+ setEntry('session', key, storageSig, defaultValue)
56
+
57
+ return storageSig
58
+ }
@@ -0,0 +1,104 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
2
+ import {
3
+ _resetRegistry,
4
+ clearStorage,
5
+ removeStorage,
6
+ useSessionStorage,
7
+ useStorage,
8
+ } from '../index'
9
+
10
+ describe('removeStorage', () => {
11
+ beforeEach(() => {
12
+ localStorage.clear()
13
+ sessionStorage.clear()
14
+ _resetRegistry()
15
+ })
16
+
17
+ afterEach(() => {
18
+ localStorage.clear()
19
+ sessionStorage.clear()
20
+ _resetRegistry()
21
+ })
22
+
23
+ it('removes a localStorage entry via signal', () => {
24
+ const theme = useStorage('theme', 'light')
25
+ theme.set('dark')
26
+
27
+ removeStorage('theme')
28
+ expect(theme()).toBe('light')
29
+ expect(localStorage.getItem('theme')).toBeNull()
30
+ })
31
+
32
+ it('removes a sessionStorage entry', () => {
33
+ const step = useSessionStorage('step', 0)
34
+ step.set(3)
35
+
36
+ removeStorage('step', { type: 'session' })
37
+ expect(step()).toBe(0)
38
+ expect(sessionStorage.getItem('step')).toBeNull()
39
+ })
40
+
41
+ it('removes raw localStorage even without a signal', () => {
42
+ localStorage.setItem('orphan', 'value')
43
+ removeStorage('orphan')
44
+ expect(localStorage.getItem('orphan')).toBeNull()
45
+ })
46
+
47
+ it('removes raw sessionStorage even without a signal', () => {
48
+ sessionStorage.setItem('orphan', 'value')
49
+ removeStorage('orphan', { type: 'session' })
50
+ expect(sessionStorage.getItem('orphan')).toBeNull()
51
+ })
52
+
53
+ it('removes cookie without a signal', () => {
54
+ removeStorage('orphan-cookie', { type: 'cookie' })
55
+ // Should not throw even when cookie doesn't exist
56
+ })
57
+ })
58
+
59
+ describe('clearStorage', () => {
60
+ beforeEach(() => {
61
+ localStorage.clear()
62
+ sessionStorage.clear()
63
+ _resetRegistry()
64
+ })
65
+
66
+ afterEach(() => {
67
+ localStorage.clear()
68
+ sessionStorage.clear()
69
+ _resetRegistry()
70
+ })
71
+
72
+ it('clears all managed localStorage entries', () => {
73
+ const a = useStorage('a', 1)
74
+ const b = useStorage('b', 2)
75
+ a.set(10)
76
+ b.set(20)
77
+
78
+ clearStorage()
79
+ expect(a()).toBe(1)
80
+ expect(b()).toBe(2)
81
+ })
82
+
83
+ it('clears all managed sessionStorage entries', () => {
84
+ const a = useSessionStorage('a', 'x')
85
+ const b = useSessionStorage('b', 'y')
86
+ a.set('modified')
87
+ b.set('modified')
88
+
89
+ clearStorage('session')
90
+ expect(a()).toBe('x')
91
+ expect(b()).toBe('y')
92
+ })
93
+
94
+ it('clears all backends with "all"', () => {
95
+ const local = useStorage('l', 'default')
96
+ const session = useSessionStorage('s', 'default')
97
+ local.set('changed')
98
+ session.set('changed')
99
+
100
+ clearStorage('all')
101
+ expect(local()).toBe('default')
102
+ expect(session()).toBe('default')
103
+ })
104
+ })