@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.
- package/CHANGELOG.md +151 -0
- package/benchmarks/run.ts +102 -0
- package/benchmarks/week2.md +9 -0
- package/benchmarks/week2.ts +64 -0
- package/benchmarks/week4.md +13 -0
- package/benchmarks/week4.ts +178 -0
- package/benchmarks/week5.md +15 -0
- package/benchmarks/week5.ts +184 -0
- package/coverage/coverage-summary.json +31 -0
- package/dist/adapters/indexedDB.cjs +2 -0
- package/dist/adapters/indexedDB.cjs.map +1 -0
- package/dist/adapters/indexedDB.mjs +2 -0
- package/dist/adapters/indexedDB.mjs.map +1 -0
- package/dist/adapters/localStorage.cjs +2 -0
- package/dist/adapters/localStorage.cjs.map +1 -0
- package/dist/adapters/localStorage.mjs +2 -0
- package/dist/adapters/localStorage.mjs.map +1 -0
- package/dist/adapters/memory.cjs +2 -0
- package/dist/adapters/memory.cjs.map +1 -0
- package/dist/adapters/memory.mjs +2 -0
- package/dist/adapters/memory.mjs.map +1 -0
- package/dist/adapters/sessionStorage.cjs +2 -0
- package/dist/adapters/sessionStorage.cjs.map +1 -0
- package/dist/adapters/sessionStorage.mjs +2 -0
- package/dist/adapters/sessionStorage.mjs.map +1 -0
- package/dist/async-entry.d.ts +7 -0
- package/dist/async-entry.d.ts.map +1 -0
- package/dist/async.cjs +2 -0
- package/dist/async.cjs.map +1 -0
- package/dist/async.d.ts +52 -0
- package/dist/async.d.ts.map +1 -0
- package/dist/async.mjs +2 -0
- package/dist/async.mjs.map +1 -0
- package/dist/batch.d.ts +12 -0
- package/dist/batch.d.ts.map +1 -0
- package/dist/compose.d.ts +7 -0
- package/dist/compose.d.ts.map +1 -0
- package/dist/computed-entry.d.ts +7 -0
- package/dist/computed-entry.d.ts.map +1 -0
- package/dist/computed.cjs +2 -0
- package/dist/computed.cjs.map +1 -0
- package/dist/computed.d.ts +56 -0
- package/dist/computed.d.ts.map +1 -0
- package/dist/computed.mjs +2 -0
- package/dist/computed.mjs.map +1 -0
- package/dist/devtools/history.d.ts +51 -0
- package/dist/devtools/history.d.ts.map +1 -0
- package/dist/devtools/index.d.ts +5 -0
- package/dist/devtools/index.d.ts.map +1 -0
- package/dist/devtools/redux-bridge.d.ts +21 -0
- package/dist/devtools/redux-bridge.d.ts.map +1 -0
- package/dist/devtools/snapshots.d.ts +32 -0
- package/dist/devtools/snapshots.d.ts.map +1 -0
- package/dist/devtools/withDevtools.d.ts +17 -0
- package/dist/devtools/withDevtools.d.ts.map +1 -0
- package/dist/devtools.cjs +2 -0
- package/dist/devtools.cjs.map +1 -0
- package/dist/devtools.mjs +2 -0
- package/dist/devtools.mjs.map +1 -0
- package/dist/extensions/noop.d.ts +2 -0
- package/dist/extensions/noop.d.ts.map +1 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.js +118 -0
- package/dist/index.cjs.js.map +1 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.esm.js +116 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +1 -0
- package/dist/persist/adapters/indexedDB.d.ts +12 -0
- package/dist/persist/adapters/indexedDB.d.ts.map +1 -0
- package/dist/persist/adapters/localStorage.d.ts +11 -0
- package/dist/persist/adapters/localStorage.d.ts.map +1 -0
- package/dist/persist/adapters/memory.d.ts +11 -0
- package/dist/persist/adapters/memory.d.ts.map +1 -0
- package/dist/persist/adapters/sessionStorage.d.ts +11 -0
- package/dist/persist/adapters/sessionStorage.d.ts.map +1 -0
- package/dist/persist/debounce.d.ts +12 -0
- package/dist/persist/debounce.d.ts.map +1 -0
- package/dist/persist/hydrate.d.ts +15 -0
- package/dist/persist/hydrate.d.ts.map +1 -0
- package/dist/persist/index.d.ts +34 -0
- package/dist/persist/index.d.ts.map +1 -0
- package/dist/persist/serialize.d.ts +28 -0
- package/dist/persist/serialize.d.ts.map +1 -0
- package/dist/persist.cjs +2 -0
- package/dist/persist.cjs.map +1 -0
- package/dist/persist.mjs +2 -0
- package/dist/persist.mjs.map +1 -0
- package/dist/proxy.d.ts +2 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/registry-D3X0HSbl.js +26 -0
- package/dist/registry-D3X0HSbl.js.map +1 -0
- package/dist/registry-RDjbeJdx.js +29 -0
- package/dist/registry-RDjbeJdx.js.map +1 -0
- package/dist/registry-qtr1UpFU.js +2 -0
- package/dist/registry-qtr1UpFU.js.map +1 -0
- package/dist/registry-zaKZ1P-s.js +2 -0
- package/dist/registry-zaKZ1P-s.js.map +1 -0
- package/dist/registry.d.ts +54 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/signals/createSignal.d.ts +19 -0
- package/dist/signals/createSignal.d.ts.map +1 -0
- package/dist/signals/index.d.ts +20 -0
- package/dist/signals/index.d.ts.map +1 -0
- package/dist/signals/useSignal.d.ts +11 -0
- package/dist/signals/useSignal.d.ts.map +1 -0
- package/dist/signals.cjs +2 -0
- package/dist/signals.cjs.map +1 -0
- package/dist/signals.mjs +2 -0
- package/dist/signals.mjs.map +1 -0
- package/dist/stats.html +4949 -0
- package/dist/store.d.ts +12 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/sync/channel.d.ts +7 -0
- package/dist/sync/channel.d.ts.map +1 -0
- package/dist/sync/index.d.ts +3 -0
- package/dist/sync/index.d.ts.map +1 -0
- package/dist/sync/protocol.d.ts +22 -0
- package/dist/sync/protocol.d.ts.map +1 -0
- package/dist/sync/withSync.d.ts +17 -0
- package/dist/sync/withSync.d.ts.map +1 -0
- package/dist/sync.cjs +2 -0
- package/dist/sync.cjs.map +1 -0
- package/dist/sync.mjs +2 -0
- package/dist/sync.mjs.map +1 -0
- package/dist/types.d.ts +134 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +91 -0
- package/rollup.config.mjs +44 -0
- package/src/async-entry.ts +6 -0
- package/src/async.ts +240 -0
- package/src/batch.ts +33 -0
- package/src/compose.ts +50 -0
- package/src/computed-entry.ts +6 -0
- package/src/computed.ts +187 -0
- package/src/devtools/history.ts +103 -0
- package/src/devtools/index.ts +5 -0
- package/src/devtools/redux-bridge.ts +70 -0
- package/src/devtools/snapshots.ts +54 -0
- package/src/devtools/withDevtools.ts +196 -0
- package/src/extensions/noop.ts +12 -0
- package/src/index.ts +4 -0
- package/src/persist/adapters/indexedDB.ts +114 -0
- package/src/persist/adapters/localStorage.ts +28 -0
- package/src/persist/adapters/memory.ts +26 -0
- package/src/persist/adapters/sessionStorage.ts +28 -0
- package/src/persist/debounce.ts +28 -0
- package/src/persist/hydrate.ts +60 -0
- package/src/persist/index.ts +141 -0
- package/src/persist/serialize.ts +60 -0
- package/src/proxy.ts +87 -0
- package/src/registry.ts +67 -0
- package/src/signals/createSignal.ts +81 -0
- package/src/signals/index.ts +20 -0
- package/src/signals/useSignal.ts +18 -0
- package/src/store.ts +250 -0
- package/src/sync/channel.ts +15 -0
- package/src/sync/index.ts +3 -0
- package/src/sync/protocol.ts +18 -0
- package/src/sync/withSync.ts +147 -0
- package/src/types.ts +159 -0
- package/tests/async.test.ts +1100 -0
- package/tests/batch.test.ts +41 -0
- package/tests/compose.test.ts +209 -0
- package/tests/computed.test.ts +867 -0
- package/tests/devtools.test.ts +1039 -0
- package/tests/integration/persist.integration.test.ts +258 -0
- package/tests/integration/signals.integration.test.ts +309 -0
- package/tests/integration.test.ts +278 -0
- package/tests/persist/adapters/indexedDB.adapter.test.ts +185 -0
- package/tests/persist/adapters/localStorage.adapter.test.ts +105 -0
- package/tests/persist/adapters/memory.adapter.test.ts +112 -0
- package/tests/persist/adapters/sessionStorage.adapter.test.ts +128 -0
- package/tests/persist/debounce.test.ts +121 -0
- package/tests/persist/hydrate.test.ts +120 -0
- package/tests/persist/migrate.test.ts +208 -0
- package/tests/persist/persist.test.ts +357 -0
- package/tests/persist/serialize.test.ts +128 -0
- package/tests/proxy.test.ts +473 -0
- package/tests/registry.test.ts +67 -0
- package/tests/signals/derived.test.ts +244 -0
- package/tests/signals/inference.test.ts +108 -0
- package/tests/signals/signal.test.ts +348 -0
- package/tests/signals/useSignal.test.tsx +275 -0
- package/tests/store.test.ts +482 -0
- package/tests/stress.test.ts +268 -0
- package/tests/sync.test.ts +576 -0
- package/tests/types.test.ts +32 -0
- package/tests/v0.3.test.ts +813 -0
- package/tree-shake-test.js +1 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +22 -0
- 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
|
+
|
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
|
+
|