@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
package/src/batch.ts ADDED
@@ -0,0 +1,33 @@
1
+ let batchCount = 0;
2
+ const subscribers = new Set<() => void>();
3
+
4
+ /**
5
+ * Batches multiple state updates across any number of stores.
6
+ * Listeners will only be notified once after the batch function completes.
7
+ *
8
+ * @param fn - The function containing state updates to batch.
9
+ */
10
+ export function batch(fn: () => void): void {
11
+ batchCount++;
12
+ try {
13
+ fn();
14
+ } finally {
15
+ batchCount--;
16
+ if (batchCount === 0) {
17
+ subscribers.forEach((s) => s());
18
+ }
19
+ }
20
+ }
21
+
22
+ /** @internal */
23
+ export function isBatching(): boolean {
24
+ return batchCount > 0;
25
+ }
26
+
27
+ /** @internal */
28
+ export function subscribeToBatch(cb: () => void): () => void {
29
+ subscribers.add(cb);
30
+ return () => {
31
+ subscribers.delete(cb);
32
+ };
33
+ }
package/src/compose.ts ADDED
@@ -0,0 +1,50 @@
1
+ import type { Store } from './types.js'
2
+
3
+ export function compose<D extends object, S extends Store<D>>(store: S): S
4
+
5
+ export function compose<D extends object, S extends Store<D>, R1>(
6
+ store: S,
7
+ f1: (store: S) => R1
8
+ ): R1
9
+
10
+ export function compose<D extends object, S extends Store<D>, R1, R2>(
11
+ store: S,
12
+ f1: (store: S) => R1,
13
+ f2: (store: R1) => R2
14
+ ): R2
15
+
16
+ export function compose<D extends object, S extends Store<D>, R1, R2, R3>(
17
+ store: S,
18
+ f1: (store: S) => R1,
19
+ f2: (store: R1) => R2,
20
+ f3: (store: R2) => R3
21
+ ): R3
22
+
23
+ export function compose<D extends object>(
24
+ store: Store<D>,
25
+ ...enhancers: Array<(store: unknown) => unknown>
26
+ ): unknown
27
+
28
+ /**
29
+ * Pipes a Storve store through one or more enhancer functions left to right.
30
+ * Each enhancer receives the output of the previous one.
31
+ *
32
+ * @template D - The generic parameter for the base store definition object.
33
+ * @param {Store<D>} store - The base store to enhance.
34
+ * @param {...Array<Function>} enhancers - The enhancer functions applied left to right.
35
+ * @returns {unknown} The enhanced store instance.
36
+ */
37
+ export function compose<D extends object>(
38
+ store: Store<D>,
39
+ ...enhancers: Array<(store: unknown) => unknown>
40
+ ): unknown {
41
+ if (enhancers.length === 0) {
42
+ return store
43
+ }
44
+
45
+ let currentStore: unknown = store
46
+ for (let i = 0; i < enhancers.length; i++) {
47
+ currentStore = enhancers[i](currentStore)
48
+ }
49
+ return currentStore
50
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Computed entry point. Import from 'storve/computed'.
3
+ * Side-effect: registers computed extension so createStore handles computed values.
4
+ */
5
+ export { computed, COMPUTED_MARKER } from './computed';
6
+ export type { ComputedValue } from './types';
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Computed values implementation for Storve.
3
+ * Provides synchronous derived state with automatic dependency tracking.
4
+ */
5
+
6
+ import { registerExtension } from './registry';
7
+
8
+ /** Marker constant used to identify computed definitions in store definitions. */
9
+ export const COMPUTED_MARKER = '__rf_computed' as const;
10
+
11
+ /**
12
+ * Type representing a computed value definition.
13
+ * Used as a value in the store definition; the store unwraps it to the computed result type T.
14
+ */
15
+ export type ComputedValue<T> = {
16
+ [COMPUTED_MARKER]: true;
17
+ fn: (state: unknown) => T;
18
+ };
19
+
20
+ /**
21
+ * Internal engine shape for a single computed value.
22
+ * @internal
23
+ */
24
+ export interface ComputedEngine<T> {
25
+ fn: (state: unknown) => T;
26
+ value: T;
27
+ deps: Set<string>;
28
+ dirty: boolean;
29
+ }
30
+
31
+ /**
32
+ * Creates a computed value definition. When used in a store definition, the store
33
+ * will run the function against the current state, track which keys were read,
34
+ * and recompute when those dependencies change. Supports chaining (computed can
35
+ * depend on other computeds). Circular dependencies are detected and throw at creation.
36
+ *
37
+ * @param fn - Function that receives the current state and returns the derived value.
38
+ * @returns A marker object that the store recognizes as a computed definition.
39
+ *
40
+ * @example
41
+ * const store = createStore({
42
+ * a: 1,
43
+ * b: 2,
44
+ * sum: computed((s) => s.a + s.b),
45
+ * });
46
+ * store.getState().sum; // 3
47
+ */
48
+ export function computed<T>(fn: (state: unknown) => T): ComputedValue<T> {
49
+ return {
50
+ [COMPUTED_MARKER]: true,
51
+ fn,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Tracks which top-level state keys are accessed during the execution of a computed function.
57
+ * Runs the function against a shallow Proxy of the state and returns the result plus the set of keys read.
58
+ * Used internally by the store for dependency tracking and dirty marking.
59
+ *
60
+ * @param state - Current state object (base + resolved computeds).
61
+ * @param fn - The computed function to run.
62
+ * @returns The computed result and a Set of dependency keys (top-level only).
63
+ */
64
+ export function trackDependencies<S extends object, T>(
65
+ state: S,
66
+ fn: (state: S) => T
67
+ ): { result: T; deps: Set<string> } {
68
+ const deps = new Set<string>();
69
+ const proxy = new Proxy(state, {
70
+ get(target, key) {
71
+ deps.add(key as string);
72
+ return (target as Record<string, unknown>)[key as string];
73
+ },
74
+ });
75
+ const result = fn(proxy as S);
76
+ return { result, deps };
77
+ }
78
+
79
+ function detectCircular(computedKeys: string[], getDeps: (key: string) => Set<string>): void {
80
+ const keySet = new Set(computedKeys);
81
+ const path: string[] = [];
82
+ const visited = new Set<string>();
83
+ function visit(key: string): void {
84
+ if (path.includes(key)) {
85
+ throw new Error(`Storve: circular dependency in computed values: ${[...path, key].join(' → ')}`);
86
+ }
87
+ if (visited.has(key)) return;
88
+ path.push(key);
89
+ for (const d of getDeps(key)) {
90
+ if (keySet.has(d)) visit(d);
91
+ }
92
+ path.pop();
93
+ visited.add(key);
94
+ }
95
+ for (const k of computedKeys) {
96
+ if (!visited.has(k)) visit(k);
97
+ }
98
+ }
99
+
100
+ function topologicalSort(computedKeys: string[], getDeps: (key: string) => Set<string>): string[] {
101
+ const result: string[] = [];
102
+ const visited = new Set<string>();
103
+ const keySet = new Set(computedKeys);
104
+ function visit(k: string): void {
105
+ if (visited.has(k)) return;
106
+ visited.add(k);
107
+ for (const d of getDeps(k)) {
108
+ if (keySet.has(d)) visit(d);
109
+ }
110
+ result.push(k);
111
+ }
112
+ for (const k of computedKeys) visit(k);
113
+ return result;
114
+ }
115
+
116
+ // Register computed extension when module is imported (order 1 = runs after async)
117
+ registerExtension({
118
+ key: 'computed',
119
+ order: 1,
120
+ processDefinition: (definition) => {
121
+ const state = { ...definition };
122
+ const computedKeys = new Set<string>();
123
+ const computedEngines = new Map<string, ComputedEngine<unknown>>();
124
+
125
+ for (const key of Object.keys(definition)) {
126
+ const val = definition[key];
127
+ if (val && typeof val === 'object' && COMPUTED_MARKER in val) {
128
+ const comp = val as { [COMPUTED_MARKER]: true; fn: (state: unknown) => unknown };
129
+ computedKeys.add(key);
130
+ delete state[key];
131
+ const { result, deps } = trackDependencies(state, comp.fn);
132
+ computedEngines.set(key, { fn: comp.fn, value: result, deps, dirty: false });
133
+ }
134
+ }
135
+
136
+ const computedKeysList = Array.from(computedKeys);
137
+ let topoOrder: string[] = [];
138
+ if (computedKeysList.length > 0) {
139
+ const getDeps = (k: string) => computedEngines.get(k)!.deps;
140
+ detectCircular(computedKeysList, getDeps);
141
+ topoOrder = topologicalSort(computedKeysList, getDeps);
142
+ for (const key of topoOrder) {
143
+ const engine = computedEngines.get(key)!;
144
+ const { result, deps } = trackDependencies(state, engine.fn);
145
+ engine.value = result;
146
+ engine.deps = deps;
147
+ state[key] = result;
148
+ }
149
+ }
150
+
151
+ const runRecompute = (
152
+ changedKeys: Set<string>,
153
+ getState: () => Record<string, unknown>,
154
+ setComputed: (key: string, value: unknown) => void
155
+ ) => {
156
+ topoOrder.forEach((key) => {
157
+ const engine = computedEngines.get(key)!;
158
+ for (const d of engine.deps) {
159
+ if (changedKeys.has(d)) {
160
+ engine.dirty = true;
161
+ break;
162
+ }
163
+ }
164
+ });
165
+ topoOrder.forEach((key) => {
166
+ const engine = computedEngines.get(key)!;
167
+ if (!engine.dirty) return;
168
+ const currentState = getState();
169
+ const { result, deps } = trackDependencies(currentState, engine.fn);
170
+ engine.value = result;
171
+ engine.deps = deps;
172
+ engine.dirty = false;
173
+ setComputed(key, result);
174
+ topoOrder.forEach((other) => {
175
+ if (other === key) return;
176
+ if (computedEngines.get(other)!.deps.has(key)) computedEngines.get(other)!.dirty = true;
177
+ });
178
+ });
179
+ };
180
+
181
+ return {
182
+ state,
183
+ readOnlyKeys: computedKeys,
184
+ onStateChanged: (ctx) => runRecompute(ctx.changedKeys, ctx.getState, ctx.setComputed),
185
+ };
186
+ },
187
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Internal structure for a history entry in the ring buffer.
3
+ */
4
+ export interface HistoryEntry<S> {
5
+ state: S;
6
+ timestamp: number;
7
+ actionName: string;
8
+ }
9
+
10
+ /**
11
+ * Ring buffer for time-travel debugging.
12
+ * Fixed capacity ensures no unbounded growth.
13
+ */
14
+ export interface RingBuffer<S> {
15
+ entries: HistoryEntry<S>[];
16
+ cursor: number;
17
+ capacity: number;
18
+ }
19
+
20
+ /**
21
+ * Creates an empty ring buffer with the given capacity.
22
+ * @param capacity - Max number of entries (default 50)
23
+ */
24
+ export function createRingBuffer<S>(capacity: number = 50): RingBuffer<S> {
25
+ return {
26
+ entries: [],
27
+ cursor: -1,
28
+ capacity,
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Pushes a new state to the ring buffer.
34
+ * Discards any redo stack (entries after the cursor).
35
+ * Drops oldest entry if capacity is exceeded.
36
+ */
37
+ export function push<S>(buffer: RingBuffer<S>, state: S, actionName: string): RingBuffer<S> {
38
+ const entry: HistoryEntry<S> = {
39
+ state,
40
+ timestamp: Date.now(),
41
+ actionName,
42
+ };
43
+
44
+ // Discard any entries after the current cursor (redo stack)
45
+ const currentEntries = buffer.entries.slice(0, buffer.cursor + 1);
46
+
47
+ let newEntries = [...currentEntries, entry];
48
+ let newCursor = newEntries.length - 1;
49
+
50
+ // Maintain capacity
51
+ if (newEntries.length > buffer.capacity) {
52
+ newEntries = newEntries.slice(newEntries.length - buffer.capacity);
53
+ newCursor = newEntries.length - 1;
54
+ }
55
+
56
+ return {
57
+ ...buffer,
58
+ entries: newEntries,
59
+ cursor: newCursor,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Moves the cursor back one position and returns the state.
65
+ */
66
+ export function undo<S>(buffer: RingBuffer<S>): { buffer: RingBuffer<S>; state: S | null } {
67
+ if (buffer.cursor > 0) {
68
+ const newCursor = buffer.cursor - 1;
69
+ return {
70
+ buffer: { ...buffer, cursor: newCursor },
71
+ state: buffer.entries[newCursor].state,
72
+ };
73
+ }
74
+ return { buffer, state: null };
75
+ }
76
+
77
+ /**
78
+ * Moves the cursor forward one position and returns the state.
79
+ */
80
+ export function redo<S>(buffer: RingBuffer<S>): { buffer: RingBuffer<S>; state: S | null } {
81
+ if (buffer.cursor < buffer.entries.length - 1) {
82
+ const newCursor = buffer.cursor + 1;
83
+ return {
84
+ buffer: { ...buffer, cursor: newCursor },
85
+ state: buffer.entries[newCursor].state,
86
+ };
87
+ }
88
+ return { buffer, state: null };
89
+ }
90
+
91
+ /**
92
+ * Returns true if the ring buffer can perform an undo.
93
+ */
94
+ export function canUndo<S>(buffer: RingBuffer<S>): boolean {
95
+ return buffer.cursor > 0;
96
+ }
97
+
98
+ /**
99
+ * Returns true if the ring buffer can perform a redo.
100
+ */
101
+ export function canRedo<S>(buffer: RingBuffer<S>): boolean {
102
+ return buffer.cursor < buffer.entries.length - 1;
103
+ }
@@ -0,0 +1,5 @@
1
+ /* v8 ignore next 10 */
2
+ export { withDevtools } from './withDevtools';
3
+ export type { DevtoolsOptions } from './withDevtools';
4
+ export type { HistoryEntry } from './history';
5
+ export type { SnapshotEntry } from './snapshots';
@@ -0,0 +1,70 @@
1
+ import { Store } from '../types';
2
+ import { RingBuffer } from './history';
3
+
4
+ /** @internal */
5
+ export interface DevtoolsInternals<S> {
6
+ buffer: RingBuffer<S>;
7
+ initialState: S;
8
+ snapshots: Map<string, { state: S; timestamp: number }>;
9
+ _lastActionName: string | null;
10
+ _applySnapshot: (state: S) => void;
11
+ _isInternalUpdate: boolean;
12
+ }
13
+
14
+ interface DevtoolsInstance {
15
+ init(state: unknown): void;
16
+ send(action: { type: string }, state: unknown): void;
17
+ subscribe(listener: (msg: { type: string; payload?: { type: string }; state?: string }) => void): () => void;
18
+ }
19
+
20
+ interface ReduxDevtoolsExtension {
21
+ connect(options: { name: string; maxAge?: number }): DevtoolsInstance;
22
+ }
23
+
24
+ /**
25
+ * Connects a devtools-enabled store to the Redux DevTools browser extension.
26
+ */
27
+ export function connectReduxDevtools<S extends object>(
28
+ store: Store<S> & { __devtools: DevtoolsInternals<S> },
29
+ name: string
30
+ ): () => void {
31
+ if (typeof window === 'undefined') return () => {};
32
+
33
+ const extension = (window as unknown as { __REDUX_DEVTOOLS_EXTENSION__?: ReduxDevtoolsExtension }).__REDUX_DEVTOOLS_EXTENSION__;
34
+ if (!extension) return () => {};
35
+
36
+ const devtools: DevtoolsInstance = extension.connect({
37
+ name: `Storve | ${name}`,
38
+ maxAge: store.__devtools.buffer.capacity,
39
+ });
40
+
41
+ devtools.init(store.getState());
42
+
43
+ const unsubscribeStore = store.subscribe((state) => {
44
+ // Only send updates if they are NOT internal (undo/redo/restore from ourselves)
45
+ if (store.__devtools._isInternalUpdate) return;
46
+
47
+ devtools.send(
48
+ { type: store.__devtools._lastActionName ?? 'setState' },
49
+ state
50
+ );
51
+ });
52
+
53
+ const unsubscribeDevtools = devtools.subscribe((message) => {
54
+ if (message.type === 'DISPATCH') {
55
+ if (message.payload?.type === 'JUMP_TO_STATE' || message.payload?.type === 'JUMP_TO_ACTION') {
56
+ if (message.state) {
57
+ store.__devtools._applySnapshot(JSON.parse(message.state));
58
+ }
59
+ } else if (message.payload?.type === 'RESET') {
60
+ store.__devtools._applySnapshot(store.__devtools.initialState);
61
+ }
62
+ }
63
+ });
64
+
65
+
66
+ return () => {
67
+ unsubscribeStore();
68
+ unsubscribeDevtools();
69
+ };
70
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Internal structure for a named snapshot entry.
3
+ */
4
+ export interface SnapshotEntry<S> {
5
+ state: S;
6
+ timestamp: number;
7
+ }
8
+
9
+ /**
10
+ * A Map of named state checkpoints.
11
+ */
12
+ export type SnapshotMap<S> = Map<string, SnapshotEntry<S>>;
13
+
14
+ /**
15
+ * Creates a new empty snapshot map.
16
+ */
17
+ export function createSnapshotMap<S>(): SnapshotMap<S> {
18
+ return new Map<string, SnapshotEntry<S>>();
19
+ }
20
+
21
+ /**
22
+ * Saves a state snapshot under the given name.
23
+ */
24
+ export function saveSnapshot<S>(map: SnapshotMap<S>, name: string, state: S): SnapshotMap<S> {
25
+ const nextMap = new Map(map);
26
+ nextMap.set(name, {
27
+ state,
28
+ timestamp: Date.now(),
29
+ });
30
+ return nextMap;
31
+ }
32
+
33
+ /**
34
+ * Returns the snapshot entry for the given name, or null if not found.
35
+ */
36
+ export function getSnapshot<S>(map: SnapshotMap<S>, name: string): SnapshotEntry<S> | null {
37
+ return map.get(name) || null;
38
+ }
39
+
40
+ /**
41
+ * Removes the snapshot with the given name.
42
+ */
43
+ export function deleteSnapshot<S>(map: SnapshotMap<S>, name: string): SnapshotMap<S> {
44
+ const nextMap = new Map(map);
45
+ nextMap.delete(name);
46
+ return nextMap;
47
+ }
48
+
49
+ /**
50
+ * Returns an array of all snapshot names.
51
+ */
52
+ export function listSnapshots<S>(map: SnapshotMap<S>): string[] {
53
+ return Array.from(map.keys());
54
+ }