@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,60 @@
1
+ /**
2
+ * Returns a new object with only the specified keys from state.
3
+ * If keys is undefine or empty, returns the full state.
4
+ *
5
+ * @template T - The state object type.
6
+ * @param {T} state - The complete state object.
7
+ * @param {Array<keyof T>} [keys] - The keys to pick.
8
+ * @returns {Partial<T> | T} A new object with picked keys, or the original state.
9
+ */
10
+ export function pick<T extends object>(state: T, keys?: Array<keyof T>): Partial<T> | T {
11
+ if (keys === undefined || keys.length === 0) {
12
+ return { ...state }
13
+ }
14
+
15
+ const result: Partial<T> = {}
16
+
17
+ for (let i = 0; i < keys.length; i++) {
18
+ const key = keys[i]
19
+ if (key in state) {
20
+ result[key] = state[key]
21
+ }
22
+ }
23
+
24
+ return result
25
+ }
26
+
27
+ /**
28
+ * Safely stringifies a value to JSON, throwing a clear error if it fails.
29
+ *
30
+ * @param {unknown} value - The object or value to serialize.
31
+ * @returns {string} The JSON string representation.
32
+ * @throws {Error} Throws a detailed error if JSON.stringify fails.
33
+ */
34
+ export function toJSON(value: unknown): string {
35
+ try {
36
+ return JSON.stringify(value)
37
+ } catch (err) {
38
+ throw new Error(`[storve] Failed to serialize state to JSON: ${err instanceof Error ? err.message : String(err)}`)
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Safely parses a JSON string, throwing a clear error if parsing fails or if the string is empty/null.
44
+ *
45
+ * @template T - The expected output type.
46
+ * @param {string} raw - The string to parse.
47
+ * @returns {T} The typed parsed JSON object.
48
+ * @throws {Error} Throws if 'raw' is empty or if JSON.parse fails.
49
+ */
50
+ export function fromJSON<T>(raw: string): T {
51
+ if (!raw) {
52
+ throw new Error('[storve] Cannot parse empty or null/undefined JSON string.')
53
+ }
54
+ try {
55
+ const parsed: T = JSON.parse(raw)
56
+ return parsed
57
+ } catch (err) {
58
+ throw new Error(`[storve] Failed to parse JSON state: ${err instanceof Error ? err.message : String(err)}`)
59
+ }
60
+ }
package/src/proxy.ts ADDED
@@ -0,0 +1,87 @@
1
+ const proxyMap = new WeakMap<object, object>();
2
+ const rawMap = new WeakMap<object, object>();
3
+
4
+ let isBatching = false;
5
+
6
+ function isPlainObjectOrArray(value: unknown): value is object {
7
+ if (value === null || typeof value !== 'object') return false;
8
+ const proto = Object.getPrototypeOf(value);
9
+ return proto === Object.prototype || Array.isArray(value);
10
+ }
11
+
12
+ export function createStateProxy<T extends object>(state: T, onChange: () => void): T {
13
+ if (!isPlainObjectOrArray(state)) {
14
+ return state;
15
+ }
16
+
17
+ if (proxyMap.has(state)) {
18
+ return proxyMap.get(state) as T;
19
+ }
20
+
21
+ if (rawMap.has(state)) {
22
+ return state; // It's already a proxy
23
+ }
24
+
25
+ const handler: ProxyHandler<T> = {
26
+ get(target, prop, receiver) {
27
+ if (Array.isArray(target) && typeof prop === 'string' && ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].includes(prop)) {
28
+ return (...args: unknown[]) => {
29
+ const prevBatching = isBatching;
30
+ isBatching = true;
31
+
32
+ const method = Reflect.get(target, prop, receiver) as (...a: unknown[]) => unknown;
33
+ const result = Reflect.apply(method, receiver, args);
34
+
35
+ isBatching = prevBatching;
36
+ if (!isBatching) {
37
+ onChange();
38
+ }
39
+ return result;
40
+ };
41
+ }
42
+
43
+ const value = Reflect.get(target, prop, receiver);
44
+ if (isPlainObjectOrArray(value) && proxyMap.has(value)) {
45
+ return proxyMap.get(value);
46
+ }
47
+ return value;
48
+ },
49
+ set(target, prop, value, receiver) {
50
+ // Unpack if value is a proxy itself
51
+ const rawValue = rawMap.has(value as object) ? rawMap.get(value as object) : value;
52
+
53
+ // Immediately wrap new nested objects
54
+ if (isPlainObjectOrArray(rawValue)) {
55
+ createStateProxy(rawValue as object, onChange);
56
+ }
57
+
58
+ const result = Reflect.set(target, prop, rawValue, receiver);
59
+
60
+ // Trigger listeners on write
61
+ if (!isBatching) onChange();
62
+
63
+ return result;
64
+ },
65
+ deleteProperty(target, prop) {
66
+ const result = Reflect.deleteProperty(target, prop);
67
+ if (!isBatching) onChange();
68
+ return result;
69
+ }
70
+ };
71
+
72
+ const proxy = new Proxy(state, handler);
73
+ proxyMap.set(state, proxy);
74
+ rawMap.set(proxy, state);
75
+
76
+ // Recursively proxy existing nested objects upfront
77
+ for (const key in state) {
78
+ if (Object.prototype.hasOwnProperty.call(state, key)) {
79
+ const val = state[(key as keyof typeof state)];
80
+ if (isPlainObjectOrArray(val)) {
81
+ createStateProxy(val as object, onChange);
82
+ }
83
+ }
84
+ }
85
+
86
+ return proxy;
87
+ }
@@ -0,0 +1,67 @@
1
+ import type { Store } from './types';
2
+
3
+ /**
4
+ * Extension registry for store plugins.
5
+ * Features register when imported; createStore applies all registered extensions.
6
+ * @internal
7
+ */
8
+
9
+ export interface ProcessDefinitionResult {
10
+ state: Record<string, unknown>;
11
+ engines?: Map<string, unknown>;
12
+ /** Async keys to init after store has setState. Each init receives (onUpdate) => engine. */
13
+ asyncInits?: Array<{ key: string; init: (onUpdate: (state: unknown) => void) => unknown }>;
14
+ readOnlyKeys?: Set<string>;
15
+ /** Called when state changes. Extensions can recompute derived values via setComputed. */
16
+ onStateChanged?: (ctx: {
17
+ changedKeys: Set<string>;
18
+ getState: () => Record<string, unknown>;
19
+ setComputed: (key: string, value: unknown) => void;
20
+ store: unknown;
21
+ }) => void;
22
+ }
23
+
24
+ export interface ExtensionContext {
25
+ engines: Map<string, unknown>;
26
+ store: Store<object>;
27
+ definition: object;
28
+ }
29
+
30
+
31
+
32
+ export interface StoreExtension {
33
+ /** Unique key to avoid double registration */
34
+ key: string;
35
+ /** Process definition values before store init. Return modified state + optional metadata. */
36
+ processDefinition?: (definition: Record<string, unknown>) => ProcessDefinitionResult;
37
+ /** Add methods to the store. Called after store is created. */
38
+ extendStore?: (context: ExtensionContext) => Record<string, unknown>;
39
+ }
40
+
41
+ const extensions: StoreExtension[] = [];
42
+
43
+ /**
44
+ * Register an extension. Called by feature modules on import (side-effect).
45
+ * @internal
46
+ */
47
+ export function registerExtension(ext: StoreExtension & { order?: number }): void {
48
+ if (extensions.some((e) => e.key === ext.key)) return;
49
+ extensions.push(ext);
50
+ extensions.sort((a, b) => ((a as { order?: number }).order ?? 99) - ((b as { order?: number }).order ?? 99));
51
+ }
52
+
53
+ /**
54
+ * Get all registered extensions. Used by createStore.
55
+ * @internal
56
+ */
57
+ export function getExtensions(): readonly StoreExtension[] {
58
+ return extensions;
59
+ }
60
+
61
+ /**
62
+ * Clear all extensions. For testing only.
63
+ * @internal
64
+ */
65
+ export function __testingOnlyClearExtensions(): void {
66
+ extensions.length = 0
67
+ }
@@ -0,0 +1,81 @@
1
+ import type { Store, StoreState } from '../types';
2
+ import type { Signal } from './index';
3
+
4
+ /**
5
+ * Creates a Signal that subscribes to a specific key in a Storve store.
6
+ * Signals provide fine-grained reactivity by only notifying listeners when
7
+ * the specific key's value changes.
8
+ *
9
+ * @example
10
+ * const countSignal = signal(store, 'count');
11
+ */
12
+ export function signal<D extends object, K extends keyof StoreState<D>>(
13
+ store: Store<D>,
14
+ key: K
15
+ ): Signal<StoreState<D>[K]>;
16
+
17
+ /**
18
+ * Creates a derived read-only Signal that transforms a value from the store.
19
+ *
20
+ * @example
21
+ * const doubleSignal = signal(store, 'count', v => v * 2);
22
+ */
23
+ export function signal<D extends object, K extends keyof StoreState<D>, R>(
24
+ store: Store<D>,
25
+ key: K,
26
+ transform: (value: StoreState<D>[K]) => R
27
+ ): Signal<R>;
28
+
29
+ export function signal<D extends object, K extends keyof StoreState<D>, R>(
30
+ store: Store<D>,
31
+ key: K,
32
+ transform?: (value: StoreState<D>[K]) => R
33
+ ): Signal<R | StoreState<D>[K]> {
34
+ const isDerived = !!transform;
35
+
36
+ const get = () => {
37
+ const value = store.getState()[key];
38
+ return transform ? transform(value) : value;
39
+ };
40
+
41
+ const signalInstance = {
42
+ get,
43
+ set(value: StoreState<D>[K] | ((prev: StoreState<D>[K]) => StoreState<D>[K])) {
44
+ if (isDerived) {
45
+ throw new Error(
46
+ 'Storve: cannot call set() on a derived signal. Derived signals are read-only.'
47
+ );
48
+ }
49
+ const next =
50
+ typeof value === 'function'
51
+ ? (value as (prev: StoreState<D>[K]) => StoreState<D>[K])(store.getState()[key])
52
+ : value;
53
+ store.setState({ [key]: next } as Partial<StoreState<D>>);
54
+ },
55
+ subscribe(listener: (value: R | StoreState<D>[K]) => void) {
56
+ let prev = transform
57
+ ? transform(store.getState()[key])
58
+ : (store.getState()[key] as R | StoreState<D>[K]);
59
+
60
+ return store.subscribe(() => {
61
+ const next = transform
62
+ ? transform(store.getState()[key])
63
+ : (store.getState()[key] as R | StoreState<D>[K]);
64
+
65
+ if (Object.is(prev, next)) return;
66
+ prev = next;
67
+ listener(next);
68
+ });
69
+ },
70
+ _derived: isDerived,
71
+ };
72
+
73
+ return new Proxy(signalInstance, {
74
+ set(target, prop, value) {
75
+ if (prop === '_derived') {
76
+ return true; // Silently ignore writes to _derived
77
+ }
78
+ return Reflect.set(target, prop, value);
79
+ },
80
+ }) as Signal<R | StoreState<D>[K]>;
81
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A subscribable reference to a single key in a Storve store.
3
+ * Read-only signals (derived) throw a clear error when set() is called.
4
+ */
5
+ export interface Signal<T> {
6
+ /** Returns the current value of this signal */
7
+ get(): T;
8
+ /**
9
+ * Sets a new value. Throws if called on a derived (read-only) signal.
10
+ * Writes back to the store — the store remains the single source of truth.
11
+ */
12
+ set(value: T | ((prev: T) => T)): void;
13
+ /** Subscribe to value changes. Returns an unsubscribe function. */
14
+ subscribe(listener: (value: T) => void): () => void;
15
+ /** Internal flag — true if this is a derived read-only signal */
16
+ readonly _derived: boolean;
17
+ }
18
+
19
+ export { signal } from './createSignal';
20
+ export { useSignal } from './useSignal';
@@ -0,0 +1,18 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ import type { Signal } from './index';
3
+
4
+ /**
5
+ * React hook that subscribes to a Signal and returns its current value.
6
+ * The component re-renders ONLY when this signal's value changes.
7
+ * Unrelated store key changes are completely ignored.
8
+ *
9
+ * @example
10
+ * const count = useSignal(countSignal) // re-renders only when count changes
11
+ */
12
+ export function useSignal<T>(signal: Signal<T>): T {
13
+ return useSyncExternalStore(
14
+ (onStoreChange: () => void) => signal.subscribe(onStoreChange),
15
+ () => signal.get(),
16
+ () => signal.get()
17
+ );
18
+ }
package/src/store.ts ADDED
@@ -0,0 +1,250 @@
1
+ import { Store, StoreDefinition, Listener, StoreOptions, StoreState, StoreActions } from './types'
2
+ import { createStateProxy } from './proxy'
3
+ import { produce } from 'immer'
4
+ import { isBatching, subscribeToBatch } from './batch'
5
+ import { getExtensions } from './registry'
6
+
7
+ /**
8
+ * Creates a reactive store with auto-tracking features via Proxies.
9
+ * Any mutations to the state via setState or directly to deep objects will notify subscribers.
10
+ * Extensions (async, computed) register when their modules are imported and extend the store.
11
+ *
12
+ * @param definition - The initial state object including optional actions.
13
+ * @param options - Configuration options for the store (e.g., immer).
14
+ * @returns A generic store instance with getState, setState, subscribe, batch, and actions.
15
+ */
16
+ export function createStore<D extends object>(
17
+ definition: StoreDefinition<D>,
18
+ options: StoreOptions = {}
19
+ ): Store<D> {
20
+ const { actions: rawActions = {}, ...initialData } =
21
+ definition as D & { actions?: Record<string, (...args: unknown[]) => unknown> }
22
+
23
+ let workingData: Record<string, unknown> = { ...initialData }
24
+ const allAsyncInits: Array<{ key: string; init: (onUpdate: (state: unknown) => void) => unknown }> = []
25
+ const readOnlyKeys = new Set<string>()
26
+ const onStateChangedCallbacks: Array<(ctx: {
27
+ changedKeys: Set<string>;
28
+ getState: () => Record<string, unknown>;
29
+ setComputed: (key: string, value: unknown) => void;
30
+ store: Store<D>;
31
+ }) => void> = []
32
+
33
+ // Run extension pipeline
34
+ for (const ext of getExtensions()) {
35
+ if (ext.processDefinition) {
36
+ const result = ext.processDefinition(workingData)
37
+ workingData = { ...workingData, ...result.state }
38
+ if (result.asyncInits) allAsyncInits.push(...result.asyncInits)
39
+ if (result.readOnlyKeys) result.readOnlyKeys.forEach((k) => readOnlyKeys.add(k))
40
+ if (result.onStateChanged) onStateChangedCallbacks.push(result.onStateChanged)
41
+ }
42
+ }
43
+
44
+ // Run async inits — use ref so callback can call setState before store is assigned
45
+ const setStateRef: { current: ((p: Partial<StoreState<D>>) => void) | null } = { current: null }
46
+ const engines = new Map<string, unknown>()
47
+ for (const { key, init } of allAsyncInits) {
48
+ const engine = init((nodeState) => {
49
+ setStateRef.current?.({ [key]: nodeState } as Partial<StoreState<D>>)
50
+ })
51
+ engines.set(key, engine)
52
+ workingData[key] = (engine as { getState: () => unknown }).getState()
53
+ }
54
+
55
+ const initialState = workingData as StoreState<D>
56
+ const listeners = new Set<Listener<StoreState<D>>>()
57
+ let currentState = initialState as StoreState<D>
58
+ let batchCount = 0
59
+ let batchDirty = false
60
+ let unsubscribeBatch: (() => void) | null = null
61
+ let pendingChangedKeys = new Set<string>()
62
+ let lastSnapshot: StoreState<D> | null = null
63
+ let lastSnapshotState: StoreState<D> | null = null
64
+
65
+ const notify = () => {
66
+ if (batchCount > 0 || isBatching()) {
67
+ batchDirty = true
68
+ return
69
+ }
70
+ batchDirty = false
71
+ listeners.forEach((listener) => listener(currentState))
72
+ }
73
+
74
+ const runOnStateChanged = (changedKeys: Set<string>) => {
75
+ const setComputed = (key: string, value: unknown) => {
76
+ (currentState as Record<string, unknown>)[key] = value
77
+ }
78
+ for (const cb of onStateChangedCallbacks) {
79
+ cb({
80
+ changedKeys,
81
+ getState: () => currentState as Record<string, unknown>,
82
+ setComputed,
83
+ store,
84
+ })
85
+ }
86
+ }
87
+
88
+ const proxyState = createStateProxy(initialState, notify)
89
+
90
+ const setState = (
91
+ updater:
92
+ | Partial<StoreState<D>>
93
+ | ((s: StoreState<D>) => Partial<StoreState<D>>)
94
+ | ((draft: StoreState<D>) => void)
95
+ ) => {
96
+ let nextState: StoreState<D>
97
+
98
+ if (typeof updater === 'function') {
99
+ if (options.immer) {
100
+ nextState = produce(currentState, updater as (draft: StoreState<D>) => void) as StoreState<D>
101
+ } else {
102
+ nextState = {
103
+ ...currentState,
104
+ ...(updater as (s: StoreState<D>) => Partial<StoreState<D>>)(currentState),
105
+ }
106
+ }
107
+ } else {
108
+ nextState = { ...currentState, ...updater }
109
+ }
110
+
111
+ if (nextState === currentState) return
112
+
113
+ const writableNext = { ...nextState } as Record<string, unknown>
114
+ readOnlyKeys.forEach((k) => delete writableNext[k])
115
+ const prevState = currentState
116
+ const updatedKeys = new Set(
117
+ Object.keys(writableNext).filter(
118
+ (k) => (prevState as Record<string, unknown>)[k] !== writableNext[k]
119
+ )
120
+ )
121
+ // Notify even if no keys changed, because signals or other extensions might care about
122
+ // derived changes or reference-based equality in transforms.
123
+ // if (updatedKeys.size === 0) return
124
+
125
+ currentState = { ...currentState, ...writableNext } as StoreState<D>
126
+
127
+ if (batchCount > 0 || isBatching()) {
128
+ updatedKeys.forEach((k) => pendingChangedKeys.add(k))
129
+ batchDirty = true
130
+ } else {
131
+ runOnStateChanged(updatedKeys)
132
+ }
133
+
134
+ lastSnapshot = null
135
+ lastSnapshotState = null
136
+
137
+ batchCount++
138
+ try {
139
+ for (const key in currentState) {
140
+ if (
141
+ Object.prototype.hasOwnProperty.call(currentState, key) &&
142
+ (currentState as Record<string, unknown>)[key] !==
143
+ (prevState as Record<string, unknown>)[key]
144
+ ) {
145
+ (proxyState as Record<string, unknown>)[key] =
146
+ currentState[key as keyof StoreState<D>]
147
+ }
148
+ }
149
+ } finally {
150
+ batchCount--
151
+ }
152
+
153
+ if (batchCount > 0) {
154
+ batchDirty = true
155
+ } else {
156
+ notify()
157
+ }
158
+ }
159
+
160
+ setStateRef.current = setState
161
+
162
+ const store = {
163
+ getState: () => {
164
+ if (lastSnapshot !== null && lastSnapshotState === currentState) {
165
+ return lastSnapshot
166
+ }
167
+ const snapshot = { ...currentState } as StoreState<D>
168
+ lastSnapshot = snapshot
169
+ lastSnapshotState = currentState
170
+ return snapshot
171
+ },
172
+
173
+ setState,
174
+
175
+ subscribe: (listener: Listener<StoreState<D>>) => {
176
+ listeners.add(listener)
177
+ if (listeners.size === 1) {
178
+ unsubscribeBatch = subscribeToBatch(() => {
179
+ if (batchDirty) {
180
+ batchDirty = false
181
+ runOnStateChanged(pendingChangedKeys)
182
+ pendingChangedKeys = new Set()
183
+ notify()
184
+ }
185
+ })
186
+ if (batchDirty) {
187
+ batchDirty = false
188
+ runOnStateChanged(pendingChangedKeys)
189
+ pendingChangedKeys = new Set()
190
+ notify()
191
+ }
192
+ }
193
+ return () => {
194
+ listeners.delete(listener)
195
+ if (listeners.size === 0) {
196
+ unsubscribeBatch?.()
197
+ unsubscribeBatch = null
198
+ }
199
+ }
200
+ },
201
+
202
+ batch: (fn: () => void) => {
203
+ batchCount++
204
+ try {
205
+ fn()
206
+ } finally {
207
+ batchCount--
208
+ if (batchCount === 0 && batchDirty) {
209
+ batchDirty = false
210
+ runOnStateChanged(pendingChangedKeys)
211
+ pendingChangedKeys = new Set()
212
+ notify()
213
+ }
214
+ }
215
+ },
216
+
217
+ actions: {} as StoreActions<D>,
218
+
219
+ // Default async stubs — overwritten by async extension when engines exist
220
+ fetch: async (key: string) => {
221
+ throw new Error(`Storve: no async key "${key}" found in store. Import "storve/async" to use createAsync.`);
222
+ },
223
+ refetch: async () => {},
224
+ invalidate: () => {},
225
+ invalidateAll: () => {},
226
+ getAsyncState: () => undefined,
227
+ } as Store<D>
228
+
229
+ // Add methods from extensions (async overwrites stubs when engines exist)
230
+ for (const ext of getExtensions()) {
231
+ if (ext.extendStore) {
232
+ const methods = ext.extendStore({ engines, store: store as unknown as Store<object>, definition: definition as object })
233
+ Object.defineProperties(store, Object.getOwnPropertyDescriptors(methods))
234
+ }
235
+ }
236
+
237
+ // Trigger initial onStateChanged for extensions (e.g. devtools init)
238
+ runOnStateChanged(new Set(Object.keys(currentState)))
239
+
240
+ type RawActionsType = Record<string, (...args: unknown[]) => unknown>
241
+ const boundActions = {} as StoreActions<D>
242
+ Object.keys(rawActions).forEach((key) => {
243
+ (boundActions as RawActionsType)[key] = (...args: unknown[]) =>
244
+ (rawActions as RawActionsType)[key](...args)
245
+ })
246
+ Object.assign(store, boundActions)
247
+ store.actions = boundActions
248
+
249
+ return store
250
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Opens a BroadcastChannel if available in the current environment.
3
+ * Gracefully returns null in SSR or older browsers.
4
+ * @internal
5
+ */
6
+ export function openChannel(name: string): BroadcastChannel | null {
7
+ if (typeof window === 'undefined') return null;
8
+ if (typeof BroadcastChannel === 'undefined') return null;
9
+ try {
10
+ return new BroadcastChannel(name);
11
+ } catch {
12
+ /* v8 ignore next 2 */
13
+ return null;
14
+ }
15
+ }
@@ -0,0 +1,3 @@
1
+ /* v8 ignore next 10 */
2
+ export { withSync } from './withSync';
3
+ export type { SyncOptions } from './withSync';
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Random tab ID generated once per tab session.
3
+ */
4
+ export const tabId = (function () {
5
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
6
+ return crypto.randomUUID();
7
+ }
8
+ return Math.random().toString(36).substring(2, 11);
9
+ })();
10
+
11
+ /**
12
+ * Message types for cross-tab synchronization.
13
+ * @template S - The state type
14
+ */
15
+ export type SyncMessage<S> =
16
+ | { type: 'STATE_UPDATE'; payload: Partial<S>; tabId: string }
17
+ | { type: 'REQUEST_STATE'; tabId: string }
18
+ | { type: 'PROVIDE_STATE'; payload: S; targetTabId: string; tabId: string };