@storve/core 1.0.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.
Files changed (196) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/benchmarks/run.ts +102 -0
  3. package/benchmarks/week2.md +9 -0
  4. package/benchmarks/week2.ts +64 -0
  5. package/benchmarks/week4.md +13 -0
  6. package/benchmarks/week4.ts +178 -0
  7. package/benchmarks/week5.md +15 -0
  8. package/benchmarks/week5.ts +184 -0
  9. package/coverage/coverage-summary.json +31 -0
  10. package/dist/adapters/indexedDB.cjs +2 -0
  11. package/dist/adapters/indexedDB.cjs.map +1 -0
  12. package/dist/adapters/indexedDB.mjs +2 -0
  13. package/dist/adapters/indexedDB.mjs.map +1 -0
  14. package/dist/adapters/localStorage.cjs +2 -0
  15. package/dist/adapters/localStorage.cjs.map +1 -0
  16. package/dist/adapters/localStorage.mjs +2 -0
  17. package/dist/adapters/localStorage.mjs.map +1 -0
  18. package/dist/adapters/memory.cjs +2 -0
  19. package/dist/adapters/memory.cjs.map +1 -0
  20. package/dist/adapters/memory.mjs +2 -0
  21. package/dist/adapters/memory.mjs.map +1 -0
  22. package/dist/adapters/sessionStorage.cjs +2 -0
  23. package/dist/adapters/sessionStorage.cjs.map +1 -0
  24. package/dist/adapters/sessionStorage.mjs +2 -0
  25. package/dist/adapters/sessionStorage.mjs.map +1 -0
  26. package/dist/async-entry.d.ts +7 -0
  27. package/dist/async-entry.d.ts.map +1 -0
  28. package/dist/async.cjs +2 -0
  29. package/dist/async.cjs.map +1 -0
  30. package/dist/async.d.ts +52 -0
  31. package/dist/async.d.ts.map +1 -0
  32. package/dist/async.mjs +2 -0
  33. package/dist/async.mjs.map +1 -0
  34. package/dist/batch.d.ts +12 -0
  35. package/dist/batch.d.ts.map +1 -0
  36. package/dist/compose.d.ts +7 -0
  37. package/dist/compose.d.ts.map +1 -0
  38. package/dist/computed-entry.d.ts +7 -0
  39. package/dist/computed-entry.d.ts.map +1 -0
  40. package/dist/computed.cjs +2 -0
  41. package/dist/computed.cjs.map +1 -0
  42. package/dist/computed.d.ts +56 -0
  43. package/dist/computed.d.ts.map +1 -0
  44. package/dist/computed.mjs +2 -0
  45. package/dist/computed.mjs.map +1 -0
  46. package/dist/devtools/history.d.ts +51 -0
  47. package/dist/devtools/history.d.ts.map +1 -0
  48. package/dist/devtools/index.d.ts +5 -0
  49. package/dist/devtools/index.d.ts.map +1 -0
  50. package/dist/devtools/redux-bridge.d.ts +21 -0
  51. package/dist/devtools/redux-bridge.d.ts.map +1 -0
  52. package/dist/devtools/snapshots.d.ts +32 -0
  53. package/dist/devtools/snapshots.d.ts.map +1 -0
  54. package/dist/devtools/withDevtools.d.ts +17 -0
  55. package/dist/devtools/withDevtools.d.ts.map +1 -0
  56. package/dist/devtools.cjs +2 -0
  57. package/dist/devtools.cjs.map +1 -0
  58. package/dist/devtools.mjs +2 -0
  59. package/dist/devtools.mjs.map +1 -0
  60. package/dist/extensions/noop.d.ts +2 -0
  61. package/dist/extensions/noop.d.ts.map +1 -0
  62. package/dist/index.cjs +2 -0
  63. package/dist/index.cjs.js +118 -0
  64. package/dist/index.cjs.js.map +1 -0
  65. package/dist/index.cjs.map +1 -0
  66. package/dist/index.d.ts +5 -0
  67. package/dist/index.d.ts.map +1 -0
  68. package/dist/index.esm.js +116 -0
  69. package/dist/index.esm.js.map +1 -0
  70. package/dist/index.mjs +2 -0
  71. package/dist/index.mjs.map +1 -0
  72. package/dist/persist/adapters/indexedDB.d.ts +12 -0
  73. package/dist/persist/adapters/indexedDB.d.ts.map +1 -0
  74. package/dist/persist/adapters/localStorage.d.ts +11 -0
  75. package/dist/persist/adapters/localStorage.d.ts.map +1 -0
  76. package/dist/persist/adapters/memory.d.ts +11 -0
  77. package/dist/persist/adapters/memory.d.ts.map +1 -0
  78. package/dist/persist/adapters/sessionStorage.d.ts +11 -0
  79. package/dist/persist/adapters/sessionStorage.d.ts.map +1 -0
  80. package/dist/persist/debounce.d.ts +12 -0
  81. package/dist/persist/debounce.d.ts.map +1 -0
  82. package/dist/persist/hydrate.d.ts +15 -0
  83. package/dist/persist/hydrate.d.ts.map +1 -0
  84. package/dist/persist/index.d.ts +34 -0
  85. package/dist/persist/index.d.ts.map +1 -0
  86. package/dist/persist/serialize.d.ts +28 -0
  87. package/dist/persist/serialize.d.ts.map +1 -0
  88. package/dist/persist.cjs +2 -0
  89. package/dist/persist.cjs.map +1 -0
  90. package/dist/persist.mjs +2 -0
  91. package/dist/persist.mjs.map +1 -0
  92. package/dist/proxy.d.ts +2 -0
  93. package/dist/proxy.d.ts.map +1 -0
  94. package/dist/registry-D3X0HSbl.js +26 -0
  95. package/dist/registry-D3X0HSbl.js.map +1 -0
  96. package/dist/registry-RDjbeJdx.js +29 -0
  97. package/dist/registry-RDjbeJdx.js.map +1 -0
  98. package/dist/registry-qtr1UpFU.js +2 -0
  99. package/dist/registry-qtr1UpFU.js.map +1 -0
  100. package/dist/registry-zaKZ1P-s.js +2 -0
  101. package/dist/registry-zaKZ1P-s.js.map +1 -0
  102. package/dist/registry.d.ts +54 -0
  103. package/dist/registry.d.ts.map +1 -0
  104. package/dist/signals/createSignal.d.ts +19 -0
  105. package/dist/signals/createSignal.d.ts.map +1 -0
  106. package/dist/signals/index.d.ts +20 -0
  107. package/dist/signals/index.d.ts.map +1 -0
  108. package/dist/signals/useSignal.d.ts +11 -0
  109. package/dist/signals/useSignal.d.ts.map +1 -0
  110. package/dist/signals.cjs +2 -0
  111. package/dist/signals.cjs.map +1 -0
  112. package/dist/signals.mjs +2 -0
  113. package/dist/signals.mjs.map +1 -0
  114. package/dist/stats.html +4949 -0
  115. package/dist/store.d.ts +12 -0
  116. package/dist/store.d.ts.map +1 -0
  117. package/dist/sync/channel.d.ts +7 -0
  118. package/dist/sync/channel.d.ts.map +1 -0
  119. package/dist/sync/index.d.ts +3 -0
  120. package/dist/sync/index.d.ts.map +1 -0
  121. package/dist/sync/protocol.d.ts +22 -0
  122. package/dist/sync/protocol.d.ts.map +1 -0
  123. package/dist/sync/withSync.d.ts +17 -0
  124. package/dist/sync/withSync.d.ts.map +1 -0
  125. package/dist/sync.cjs +2 -0
  126. package/dist/sync.cjs.map +1 -0
  127. package/dist/sync.mjs +2 -0
  128. package/dist/sync.mjs.map +1 -0
  129. package/dist/types.d.ts +134 -0
  130. package/dist/types.d.ts.map +1 -0
  131. package/package.json +91 -0
  132. package/rollup.config.mjs +44 -0
  133. package/src/async-entry.ts +6 -0
  134. package/src/async.ts +240 -0
  135. package/src/batch.ts +33 -0
  136. package/src/compose.ts +50 -0
  137. package/src/computed-entry.ts +6 -0
  138. package/src/computed.ts +187 -0
  139. package/src/devtools/history.ts +103 -0
  140. package/src/devtools/index.ts +5 -0
  141. package/src/devtools/redux-bridge.ts +70 -0
  142. package/src/devtools/snapshots.ts +54 -0
  143. package/src/devtools/withDevtools.ts +196 -0
  144. package/src/extensions/noop.ts +12 -0
  145. package/src/index.ts +4 -0
  146. package/src/persist/adapters/indexedDB.ts +114 -0
  147. package/src/persist/adapters/localStorage.ts +28 -0
  148. package/src/persist/adapters/memory.ts +26 -0
  149. package/src/persist/adapters/sessionStorage.ts +28 -0
  150. package/src/persist/debounce.ts +28 -0
  151. package/src/persist/hydrate.ts +60 -0
  152. package/src/persist/index.ts +141 -0
  153. package/src/persist/serialize.ts +60 -0
  154. package/src/proxy.ts +87 -0
  155. package/src/registry.ts +67 -0
  156. package/src/signals/createSignal.ts +81 -0
  157. package/src/signals/index.ts +20 -0
  158. package/src/signals/useSignal.ts +18 -0
  159. package/src/store.ts +250 -0
  160. package/src/sync/channel.ts +15 -0
  161. package/src/sync/index.ts +3 -0
  162. package/src/sync/protocol.ts +18 -0
  163. package/src/sync/withSync.ts +147 -0
  164. package/src/types.ts +159 -0
  165. package/tests/async.test.ts +1100 -0
  166. package/tests/batch.test.ts +41 -0
  167. package/tests/compose.test.ts +209 -0
  168. package/tests/computed.test.ts +867 -0
  169. package/tests/devtools.test.ts +1039 -0
  170. package/tests/integration/persist.integration.test.ts +258 -0
  171. package/tests/integration/signals.integration.test.ts +309 -0
  172. package/tests/integration.test.ts +278 -0
  173. package/tests/persist/adapters/indexedDB.adapter.test.ts +185 -0
  174. package/tests/persist/adapters/localStorage.adapter.test.ts +105 -0
  175. package/tests/persist/adapters/memory.adapter.test.ts +112 -0
  176. package/tests/persist/adapters/sessionStorage.adapter.test.ts +128 -0
  177. package/tests/persist/debounce.test.ts +121 -0
  178. package/tests/persist/hydrate.test.ts +120 -0
  179. package/tests/persist/migrate.test.ts +208 -0
  180. package/tests/persist/persist.test.ts +357 -0
  181. package/tests/persist/serialize.test.ts +128 -0
  182. package/tests/proxy.test.ts +473 -0
  183. package/tests/registry.test.ts +67 -0
  184. package/tests/signals/derived.test.ts +244 -0
  185. package/tests/signals/inference.test.ts +108 -0
  186. package/tests/signals/signal.test.ts +348 -0
  187. package/tests/signals/useSignal.test.tsx +275 -0
  188. package/tests/store.test.ts +482 -0
  189. package/tests/stress.test.ts +268 -0
  190. package/tests/sync.test.ts +576 -0
  191. package/tests/types.test.ts +32 -0
  192. package/tests/v0.3.test.ts +813 -0
  193. package/tree-shake-test.js +1 -0
  194. package/tsconfig.json +15 -0
  195. package/vitest.config.ts +22 -0
  196. package/vitest_play.ts +7 -0
@@ -0,0 +1,196 @@
1
+ import { registerExtension } from '../registry';
2
+ import { createRingBuffer, push, undo, redo, canUndo, canRedo } from './history';
3
+ import { createSnapshotMap, saveSnapshot, getSnapshot, deleteSnapshot, listSnapshots } from './snapshots';
4
+ import { connectReduxDevtools, DevtoolsInternals } from './redux-bridge';
5
+ import type { Store, StoreState } from '../types';
6
+
7
+ /**
8
+ * Configuration options for DevTools.
9
+ */
10
+ export interface DevtoolsOptions {
11
+ /** Label shown in Redux DevTools panel */
12
+ name: string;
13
+ /** Max history entries in ring buffer (default 50) */
14
+ maxHistory?: number;
15
+ /** Whether devtools are enabled (default true) */
16
+ enabled?: boolean;
17
+ }
18
+
19
+ /** @internal WeakMap to store devtools options for definitions without polluting state */
20
+ const DEVTOOLS_OPTIONS = new WeakMap<object, DevtoolsOptions>();
21
+
22
+ /**
23
+ * Augmented store type with devtools properties.
24
+ * @internal
25
+ */
26
+ type StoreWithDevtools<S extends object> = Store<S> & {
27
+ __devtools?: DevtoolsInternals<S>;
28
+ undo?: () => void;
29
+ redo?: () => void;
30
+ canUndo?: boolean;
31
+ canRedo?: boolean;
32
+ snapshot?: (name: string) => void;
33
+ restore?: (name: string) => void;
34
+ deleteSnapshot?: (name: string) => void;
35
+ clearHistory?: () => void;
36
+ history?: readonly S[];
37
+ snapshots?: readonly string[];
38
+ }
39
+
40
+
41
+ /**
42
+ * Wraps a store definition with DevTools capabilities.
43
+ * Must be imported to register the devtools extension.
44
+ */
45
+ export function withDevtools<D extends object>(
46
+ definition: D,
47
+ options: DevtoolsOptions
48
+ ): D {
49
+ DEVTOOLS_OPTIONS.set(definition, options);
50
+ return definition;
51
+ }
52
+
53
+ // Register the extension via the registry pattern
54
+ registerExtension({
55
+ key: 'devtools',
56
+ processDefinition: (definition) => {
57
+ const options = DEVTOOLS_OPTIONS.get(definition);
58
+ if (!options || options.enabled === false) return { state: {} };
59
+
60
+ return {
61
+ state: {},
62
+ };
63
+ },
64
+ extendStore: (context) => {
65
+ const { store, definition } = context as { store: Store<object>; definition: object };
66
+ const options = DEVTOOLS_OPTIONS.get(definition);
67
+ if (!options || options.enabled === false) return {};
68
+
69
+ const initialState = store.getState();
70
+ const internals: DevtoolsInternals<object> = {
71
+ buffer: createRingBuffer<object>(options.maxHistory || 50),
72
+ snapshots: createSnapshotMap<object>(),
73
+ initialState,
74
+ _isInternalUpdate: false,
75
+ _lastActionName: null,
76
+ _applySnapshot: (state: object) => {
77
+ internals._isInternalUpdate = true;
78
+ store.setState(state as unknown as Partial<StoreState<object>>);
79
+ internals._isInternalUpdate = false;
80
+ }
81
+ };
82
+ const devStore = store as unknown as StoreWithDevtools<object>;
83
+ devStore.__devtools = internals;
84
+
85
+ // One-shot flag: the very first subscribe callback after withSync broadcasts
86
+ // REQUEST_STATE may fire setState back to initialState. We skip that single echo
87
+ // so it doesn't appear as a spurious history entry.
88
+ let _initEchoPending = true;
89
+
90
+ // Use subscribe instead of wrapping setState to capture history.
91
+ // This is more robust against extension ordering issues.
92
+ store.subscribe(() => {
93
+ if (internals._isInternalUpdate) return;
94
+
95
+ const currentState = store.getState();
96
+
97
+ // On first callback: if there's no explicit action name and state matches
98
+ // initialState, this is likely a withSync echo — skip once and clear flag.
99
+ if (_initEchoPending && internals._lastActionName === null) {
100
+ const init = internals.initialState as Record<string, unknown>;
101
+ const curr = currentState as Record<string, unknown>;
102
+ const keys = Object.keys(init);
103
+ const matchesInit = keys.length === Object.keys(curr).length
104
+ && keys.every((k) => curr[k] === init[k]);
105
+ if (matchesInit) {
106
+ _initEchoPending = false;
107
+ return;
108
+ }
109
+ }
110
+ _initEchoPending = false;
111
+
112
+ const actionName = internals._lastActionName ?? 'setState';
113
+ internals.buffer = push(internals.buffer, currentState, actionName);
114
+ internals._lastActionName = null; // Reset after capture
115
+ });
116
+
117
+ // Bridge actions to capture names.
118
+ const rawActions = store.actions as Record<string, (...args: unknown[]) => unknown>;
119
+ Object.keys(rawActions).forEach((key) => {
120
+ const original = rawActions[key];
121
+ const wrapped = (...args: unknown[]) => {
122
+ internals._lastActionName = key;
123
+ return original(...args);
124
+ };
125
+ rawActions[key] = wrapped;
126
+ // Also update the store property if it was bound directly
127
+ const storeAsRecord = store as unknown as Record<string, unknown>;
128
+ if (storeAsRecord[key] === original) {
129
+ storeAsRecord[key] = wrapped;
130
+ }
131
+ });
132
+
133
+ if (typeof window !== 'undefined') {
134
+ connectReduxDevtools(devStore as Required<StoreWithDevtools<object>>, options.name);
135
+ }
136
+
137
+ return {
138
+ undo: () => {
139
+ const { buffer, state } = undo(internals.buffer);
140
+ if (state) {
141
+ internals.buffer = buffer;
142
+ internals._applySnapshot(state);
143
+ }
144
+ },
145
+ redo: () => {
146
+ const { buffer, state } = redo(internals.buffer);
147
+ if (state) {
148
+ internals.buffer = buffer;
149
+ internals._applySnapshot(state);
150
+ }
151
+ },
152
+ get canUndo() {
153
+ return canUndo(internals.buffer);
154
+ },
155
+ get canRedo() {
156
+ return canRedo(internals.buffer);
157
+ },
158
+ snapshot: (name: string) => {
159
+ internals.snapshots = saveSnapshot(internals.snapshots, name, store.getState());
160
+ // We use an internal update to trigger subscribers without pushing to history
161
+ internals._isInternalUpdate = true;
162
+ store.setState({} as unknown as Partial<StoreState<object>>);
163
+ internals._isInternalUpdate = false;
164
+ },
165
+ restore: (name: string) => {
166
+ const entry = getSnapshot(internals.snapshots, name);
167
+ if (!entry) {
168
+ throw new Error(`Storve DevTools: Snapshot "${name}" not found.`);
169
+ }
170
+
171
+ // restore() DOES push to history
172
+ internals._applySnapshot(entry.state);
173
+ internals.buffer = push(internals.buffer, entry.state, `restore('${name}')`);
174
+ },
175
+ deleteSnapshot: (name: string) => {
176
+ internals.snapshots = deleteSnapshot(internals.snapshots, name);
177
+ internals._isInternalUpdate = true;
178
+ store.setState({} as unknown as Partial<StoreState<object>>);
179
+ internals._isInternalUpdate = false;
180
+ },
181
+ clearHistory: () => {
182
+ internals.buffer = createRingBuffer(internals.buffer.capacity);
183
+ internals._isInternalUpdate = true;
184
+ store.setState({} as unknown as Partial<StoreState<object>>);
185
+ internals._isInternalUpdate = false;
186
+ },
187
+ get history() {
188
+ return [...internals.buffer.entries];
189
+ },
190
+ get snapshots() {
191
+ return listSnapshots(internals.snapshots);
192
+ }
193
+ };
194
+ }
195
+ });
196
+
@@ -0,0 +1,12 @@
1
+ /**
2
+ * No-op extension for validating the registry pattern.
3
+ * @internal
4
+ */
5
+ import { registerExtension } from '../registry';
6
+
7
+ registerExtension({
8
+ key: '__noop',
9
+ processDefinition: (definition) => ({
10
+ state: { ...definition },
11
+ }),
12
+ });
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { createStore } from './store';
2
+ export { batch } from './batch';
3
+ export { compose } from './compose';
4
+ export type { Store, StoreDefinition, Listener, Unsubscribe, StoreOptions, StoreState, StoreActions, AsyncState, AsyncOptions, AsyncStatus, ComputedValue, WritableStoreState, ComputedKeys } from './types';
@@ -0,0 +1,114 @@
1
+ import type { PersistAdapter } from '../index.js'
2
+
3
+ /**
4
+ * Creates an IndexedDB persistence adapter.
5
+ * Lazily opens the database on first interaction and caches the Promise.
6
+ * Safe for Server-Side Rendering (SSR) — if 'indexedDB' is totally unavailable,
7
+ * methods elegantly degrade to returning null / no-op promises.
8
+ *
9
+ * @param {string} [dbName='storve-persist'] - Optional custom database name.
10
+ * @returns {PersistAdapter} The IndexedDB persistence adapter.
11
+ */
12
+ export function indexedDBAdapter(dbName: string = 'storve-persist'): PersistAdapter {
13
+ const STORE_NAME = 'keyval'
14
+ const isServer = typeof indexedDB === 'undefined'
15
+ let dbPromise: Promise<IDBDatabase | null> | null = null
16
+
17
+ function getDB(): Promise<IDBDatabase | null> {
18
+ if (isServer) return Promise.resolve(null)
19
+ if (dbPromise !== null) return dbPromise
20
+
21
+ dbPromise = new Promise((resolve) => {
22
+ try {
23
+ const request = indexedDB.open(dbName, 1)
24
+
25
+ request.onupgradeneeded = () => {
26
+ const db = request.result
27
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
28
+ db.createObjectStore(STORE_NAME)
29
+ }
30
+ }
31
+
32
+ request.onsuccess = () => {
33
+ resolve(request.result)
34
+ }
35
+
36
+ request.onerror = () => {
37
+ console.warn(`[storve] Failed to open IndexedDB "${dbName}"`)
38
+ resolve(null)
39
+ }
40
+ } catch (err) {
41
+ console.warn(`[storve] Exception opening IndexedDB "${dbName}":`, err)
42
+ resolve(null)
43
+ }
44
+ })
45
+
46
+ return dbPromise
47
+ }
48
+
49
+ return {
50
+ async getItem(key: string): Promise<string | null> {
51
+ const db = await getDB()
52
+ if (db === null) return null
53
+
54
+ return new Promise((resolve) => {
55
+ try {
56
+ const transaction = db.transaction(STORE_NAME, 'readonly')
57
+ const store = transaction.objectStore(STORE_NAME)
58
+ const request = store.get(key)
59
+
60
+ request.onsuccess = () => {
61
+ const result = request.result
62
+ if (typeof result === 'string') {
63
+ resolve(result)
64
+ } else {
65
+ resolve(null)
66
+ }
67
+ }
68
+
69
+ request.onerror = () => {
70
+ resolve(null)
71
+ }
72
+ } catch {
73
+ resolve(null)
74
+ }
75
+ })
76
+ },
77
+
78
+ async setItem(key: string, value: string): Promise<void> {
79
+ const db = await getDB()
80
+ if (db === null) return
81
+
82
+ return new Promise((resolve) => {
83
+ try {
84
+ const transaction = db.transaction(STORE_NAME, 'readwrite')
85
+ const store = transaction.objectStore(STORE_NAME)
86
+ const request = store.put(value, key)
87
+
88
+ request.onsuccess = () => resolve()
89
+ request.onerror = () => resolve()
90
+ } catch {
91
+ resolve()
92
+ }
93
+ })
94
+ },
95
+
96
+ async removeItem(key: string): Promise<void> {
97
+ const db = await getDB()
98
+ if (db === null) return
99
+
100
+ return new Promise((resolve) => {
101
+ try {
102
+ const transaction = db.transaction(STORE_NAME, 'readwrite')
103
+ const store = transaction.objectStore(STORE_NAME)
104
+ const request = store.delete(key)
105
+
106
+ request.onsuccess = () => resolve()
107
+ request.onerror = () => resolve()
108
+ } catch {
109
+ resolve()
110
+ }
111
+ })
112
+ }
113
+ }
114
+ }
@@ -0,0 +1,28 @@
1
+ import type { PersistAdapter } from '../index.js'
2
+
3
+ /**
4
+ * Creates a localStorage persistence adapter.
5
+ * Uses window.localStorage to automatically persist state modifications in the browser.
6
+ * Safe for Server-Side Rendering (SSR) — if 'window' is completely undefined,
7
+ * methods gracefully return null or perform no-ops.
8
+ *
9
+ * @returns {PersistAdapter} The localStorage persistence adapter.
10
+ */
11
+ export function localStorageAdapter(): PersistAdapter {
12
+ const isServer = typeof window === 'undefined'
13
+
14
+ return {
15
+ getItem(key: string): string | null {
16
+ if (isServer) return null
17
+ return window.localStorage.getItem(key)
18
+ },
19
+ setItem(key: string, value: string): void {
20
+ if (isServer) return
21
+ window.localStorage.setItem(key, value)
22
+ },
23
+ removeItem(key: string): void {
24
+ if (isServer) return
25
+ window.localStorage.removeItem(key)
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,26 @@
1
+ import type { PersistAdapter } from '../index.js'
2
+
3
+ /**
4
+ * Creates an entirely isolated memory-based persistence adapter.
5
+ * This adapter uses a closure-scoped Map to store data, ensuring fully
6
+ * segregated instances without any module-level state.
7
+ * Ideal for testing or Node/SSR environments where no real storage is available.
8
+ *
9
+ * @returns {PersistAdapter} An isolated memory adapter instance.
10
+ */
11
+ export function memoryAdapter(): PersistAdapter {
12
+ const store = new Map<string, string>()
13
+
14
+ return {
15
+ getItem(key: string): string | null {
16
+ const value = store.get(key)
17
+ return value !== undefined ? value : null
18
+ },
19
+ setItem(key: string, value: string): void {
20
+ store.set(key, value)
21
+ },
22
+ removeItem(key: string): void {
23
+ store.delete(key)
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,28 @@
1
+ import type { PersistAdapter } from '../index.js'
2
+
3
+ /**
4
+ * Creates a sessionStorage persistence adapter.
5
+ * Uses window.sessionStorage to persist state for the lifespan of the browser tab.
6
+ * Safe for Server-Side Rendering (SSR) — if 'window' is completely undefined,
7
+ * methods gracefully return null or perform no-ops.
8
+ *
9
+ * @returns {PersistAdapter} The sessionStorage persistence adapter.
10
+ */
11
+ export function sessionStorageAdapter(): PersistAdapter {
12
+ const isServer = typeof window === 'undefined'
13
+
14
+ return {
15
+ getItem(key: string): string | null {
16
+ if (isServer) return null
17
+ return window.sessionStorage.getItem(key)
18
+ },
19
+ setItem(key: string, value: string): void {
20
+ if (isServer) return
21
+ window.sessionStorage.setItem(key, value)
22
+ },
23
+ removeItem(key: string): void {
24
+ if (isServer) return
25
+ window.sessionStorage.removeItem(key)
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Creates a debounced version of a provided function that delays its execution until after
3
+ * the specified milliseconds have elapsed since the last time it was called.
4
+ * If 'ms' is 0, the function invokes immediately.
5
+ *
6
+ * @template T - The arguments type array.
7
+ * @param {(...args: T) => void} fn - The function to debounce.
8
+ * @param {number} ms - The number of milliseconds to wait.
9
+ * @returns {(...args: T) => void} The new debounced function.
10
+ */
11
+ export function createDebounce<T extends unknown[]>(fn: (...args: T) => void, ms: number): (...args: T) => void {
12
+ let timerId: ReturnType<typeof setTimeout> | undefined;
13
+
14
+ return function(...args: T): void {
15
+ if (ms === 0) {
16
+ fn(...args)
17
+ return
18
+ }
19
+
20
+ if (timerId !== undefined) {
21
+ clearTimeout(timerId)
22
+ }
23
+
24
+ timerId = setTimeout(() => {
25
+ fn(...args)
26
+ }, ms)
27
+ }
28
+ }
@@ -0,0 +1,60 @@
1
+ import type { PersistAdapter } from './index.js'
2
+ import { fromJSON } from './serialize.js'
3
+
4
+ type PersistedWrapper<T> = Partial<T> & { __version?: number }
5
+
6
+ /**
7
+ * Hydrates state from a persistence adapter.
8
+ * Handles reading from the adapter, JSON parsing, version checking, and migration.
9
+ *
10
+ * @template T - The state object type.
11
+ * @param {PersistAdapter} adapter - The persistence adapter to read from.
12
+ * @param {string} key - The unique namespace/key for the store in the adapter.
13
+ * @param {T} currentState - The current store state.
14
+ * @param {number} version - The expected state version.
15
+ * @param {(persisted: Partial<T>, version: number) => Partial<T>} [migrate] - Optional migration function.
16
+ * @returns {Promise<Partial<T>>} A promise that resolves to the hydrated partial state (or an empty object).
17
+ */
18
+ export async function hydrate<T extends object>(
19
+ adapter: PersistAdapter,
20
+ key: string,
21
+ currentState: T,
22
+ version: number,
23
+ migrate?: (persisted: Partial<T>, version: number) => Partial<T>
24
+ ): Promise<Partial<T>> {
25
+ const raw = await adapter.getItem(key)
26
+ if (!raw) {
27
+ return {}
28
+ }
29
+
30
+ let parsed: PersistedWrapper<T>
31
+ try {
32
+ parsed = fromJSON<PersistedWrapper<T>>(raw)
33
+ } catch (err) {
34
+ console.warn(`[storve] Hydration failed for key "${key}":`, err)
35
+ return {}
36
+ }
37
+
38
+ const persistedVersion = parsed.__version !== undefined ? parsed.__version : 0
39
+
40
+ let finalState: Partial<T>
41
+
42
+ if (persistedVersion !== version) {
43
+ if (migrate !== undefined) {
44
+ finalState = migrate(parsed, persistedVersion)
45
+ } else {
46
+ console.warn(
47
+ `Storve: persisted state version mismatch (stored: ${persistedVersion}, expected: ${version}). No migrate function provided — falling back to default state.`
48
+ )
49
+ return {} // stale data, no migration path
50
+ }
51
+ } else {
52
+ finalState = parsed
53
+ }
54
+
55
+ // Strip __version from the final state to be merged
56
+ const cleaned: PersistedWrapper<T> = { ...finalState }
57
+ delete cleaned.__version
58
+
59
+ return cleaned
60
+ }
@@ -0,0 +1,141 @@
1
+ import type { Store, StoreState } from '../types.js'
2
+ import { pick, toJSON } from './serialize.js'
3
+ import { createDebounce } from './debounce.js'
4
+ import { hydrate } from './hydrate.js'
5
+
6
+ /**
7
+ * Core interface for Storve persistence adapters.
8
+ * All adapters must implement these three methods to be compatible.
9
+ * Depending on the underlying storage, methods can be sync or async.
10
+ */
11
+ export interface PersistAdapter {
12
+ getItem(key: string): string | null | Promise<string | null>
13
+ setItem(key: string, value: string): void | Promise<void>
14
+ removeItem(key: string): void | Promise<void>
15
+ }
16
+
17
+ /**
18
+ * Options for configuring persistence.
19
+ * @template T - The state type of the store.
20
+ */
21
+ export interface PersistOptions<T> {
22
+ key: string
23
+ adapter: PersistAdapter
24
+ pick?: Array<keyof T>
25
+ version?: number
26
+ migrate?: (persisted: Partial<T>, version: number) => Partial<T>
27
+ debounce?: number
28
+ }
29
+
30
+ // Internal type guard to distinguish options from store while preserving D
31
+ function isPersistOptions<D extends object>(
32
+ obj: Store<D> | PersistOptions<StoreState<D>>
33
+ ): obj is PersistOptions<StoreState<D>> {
34
+ return obj !== null && typeof obj === 'object' && 'adapter' in obj && 'key' in obj
35
+ }
36
+
37
+ // Internal helper for withPersist to avoid signature overload complexities
38
+ function createEnhancedStore<D extends object>(
39
+ store: Store<D>,
40
+ options: PersistOptions<StoreState<D>>
41
+ ): Store<D> & { hydrated: Promise<void> } {
42
+ let resolveHydrated!: () => void
43
+ const hydrated = new Promise<void>((resolve) => {
44
+ resolveHydrated = resolve
45
+ })
46
+
47
+ const version = options.version !== undefined ? options.version : 1
48
+ const debounceMs = options.debounce !== undefined ? options.debounce : 100
49
+
50
+ // 1. Kick off hydration immediately
51
+ hydrate<StoreState<D>>(
52
+ options.adapter,
53
+ options.key,
54
+ store.getState(),
55
+ version,
56
+ options.migrate
57
+ ).then((hydratedState) => {
58
+ // Merge result into store via setState
59
+ store.setState(hydratedState)
60
+ resolveHydrated()
61
+ }).catch(
62
+ /* v8 ignore next 4 */
63
+ (err: unknown) => {
64
+ console.warn(`[storve] withPersist hydrate error for key "${options.key}":`, err)
65
+ resolveHydrated()
66
+ }
67
+ )
68
+
69
+ // 2. Setup debounced exact writes
70
+ const debouncedWrite = createDebounce((serialized: string) => {
71
+ const result = options.adapter.setItem(options.key, serialized)
72
+
73
+ if (result && typeof result.catch === 'function') {
74
+ /* v8 ignore next 4 */
75
+ result.catch((e: unknown) => {
76
+ console.warn(`[storve] Failed to persist state for key "${options.key}":`, e)
77
+ })
78
+ }
79
+ }, debounceMs)
80
+
81
+ // Keep a reference to the last persisted picked state
82
+ const initialPicked = options.pick && options.pick.length > 0
83
+ ? pick(store.getState(), options.pick)
84
+ : { ...store.getState() }
85
+ let lastPersistedSnapshot: string | null = toJSON({ ...initialPicked, __version: version })
86
+
87
+ // 3. Subscribe to store changes to trigger writes
88
+ store.subscribe((newState) => {
89
+ // 1. Extract only the picked keys (or full state if no pick option)
90
+ const picked = options.pick && options.pick.length > 0
91
+ ? pick(newState, options.pick)
92
+ : { ...newState }
93
+
94
+ // 2. Serialize to compare
95
+ const serialized = toJSON({ ...picked, __version: version })
96
+
97
+ // 3. Skip write if nothing changed in the picked portion
98
+ if (serialized === lastPersistedSnapshot) return
99
+
100
+ // 4. Update snapshot reference and write
101
+ lastPersistedSnapshot = serialized
102
+ debouncedWrite(serialized)
103
+ })
104
+
105
+ return {
106
+ ...store,
107
+ hydrated
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Enhances a Storve store with continuous automatic persistence.
113
+ * Can be called directly or curried for use with compose().
114
+ */
115
+ export function withPersist<D extends object>(
116
+ store: Store<D>,
117
+ options: PersistOptions<StoreState<D>>
118
+ ): Store<D> & { hydrated: Promise<void> }
119
+
120
+ export function withPersist<D extends object>(
121
+ options: PersistOptions<StoreState<D>>
122
+ ): (store: Store<D>) => Store<D> & { hydrated: Promise<void> }
123
+
124
+ export function withPersist<D extends object>(
125
+ storeOrOptions: Store<D> | PersistOptions<StoreState<D>>,
126
+ options?: PersistOptions<StoreState<D>>
127
+ ): (Store<D> & { hydrated: Promise<void> }) | ((store: Store<D>) => Store<D> & { hydrated: Promise<void> }) {
128
+ if (options !== undefined) {
129
+ if (!isPersistOptions(storeOrOptions)) {
130
+ return createEnhancedStore(storeOrOptions, options)
131
+ }
132
+ }
133
+
134
+ if (isPersistOptions(storeOrOptions)) {
135
+ return (store: Store<D>) => createEnhancedStore(store, storeOrOptions)
136
+ }
137
+
138
+ /* v8 ignore next 2 */
139
+ throw new Error('[storve] Invalid withPersist arguments')
140
+ }
141
+