@bquery/bquery 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,848 @@
1
+ /**
2
+ * Minimal state management built on signals.
3
+ *
4
+ * This module provides a lightweight store pattern inspired by Pinia/Vuex
5
+ * but built entirely on bQuery's reactive primitives. Features include:
6
+ * - Signal-based reactive state
7
+ * - Computed getters
8
+ * - Actions with async support
9
+ * - Devtools hooks for debugging
10
+ * - Plugin system for extensions
11
+ *
12
+ * @module bquery/store
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import { createStore } from 'bquery/store';
17
+ * import { effect } from 'bquery/reactive';
18
+ *
19
+ * const counterStore = createStore({
20
+ * id: 'counter',
21
+ * state: () => ({ count: 0 }),
22
+ * getters: {
23
+ * doubled: (state) => state.count * 2,
24
+ * isPositive: (state) => state.count > 0,
25
+ * },
26
+ * actions: {
27
+ * increment() {
28
+ * this.count++;
29
+ * },
30
+ * async fetchAndSet(url: string) {
31
+ * const response = await fetch(url);
32
+ * const data = await response.json();
33
+ * this.count = data.count;
34
+ * },
35
+ * },
36
+ * });
37
+ *
38
+ * effect(() => {
39
+ * console.log('Count:', counterStore.count);
40
+ * console.log('Doubled:', counterStore.doubled);
41
+ * });
42
+ *
43
+ * counterStore.increment();
44
+ * ```
45
+ */
46
+
47
+ import { batch, computed, signal, type ReadonlySignal, type Signal } from '../reactive/index';
48
+
49
+ // ============================================================================
50
+ // Types
51
+ // ============================================================================
52
+
53
+ /**
54
+ * Store state factory function.
55
+ */
56
+ export type StateFactory<S> = () => S;
57
+
58
+ /**
59
+ * Getter definition - derives computed values from state.
60
+ */
61
+ export type Getters<S, G> = {
62
+ [K in keyof G]: (state: S, getters: G) => G[K];
63
+ };
64
+
65
+ /**
66
+ * Action definition - methods that can modify state.
67
+ */
68
+ export type Actions<S, A> = {
69
+ [K in keyof A]: A[K] extends (...args: infer P) => infer R
70
+ ? (this: S & A, ...args: P) => R
71
+ : never;
72
+ };
73
+
74
+ /**
75
+ * Store definition for createStore.
76
+ */
77
+ export type StoreDefinition<
78
+ S extends Record<string, unknown> = Record<string, unknown>,
79
+ G extends Record<string, unknown> = Record<string, unknown>,
80
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
81
+ A extends Record<string, (...args: any[]) => any> = Record<string, never>,
82
+ > = {
83
+ /** Unique store identifier for devtools */
84
+ id: string;
85
+ /** State factory function */
86
+ state: StateFactory<S>;
87
+ /** Computed getters */
88
+ getters?: Getters<S, G>;
89
+ /** Action methods */
90
+ actions?: A;
91
+ };
92
+
93
+ /**
94
+ * The returned store instance with state, getters, and actions merged.
95
+ */
96
+ export type Store<
97
+ S extends Record<string, unknown>,
98
+ G extends Record<string, unknown>,
99
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
+ A extends Record<string, (...args: any[]) => any>,
101
+ > = S &
102
+ G &
103
+ A & {
104
+ /** Store identifier */
105
+ $id: string;
106
+ /** Reset state to initial values */
107
+ $reset: () => void;
108
+ /** Subscribe to state changes */
109
+ $subscribe: (callback: (state: S) => void) => () => void;
110
+ /** Patch multiple state properties at once (shallow) */
111
+ $patch: (partial: Partial<S> | ((state: S) => void)) => void;
112
+ /**
113
+ * Patch with deep reactivity support.
114
+ * Unlike $patch, this method deep-clones nested objects before mutation,
115
+ * ensuring that all changes trigger reactive updates.
116
+ */
117
+ $patchDeep: (partial: Partial<S> | ((state: S) => void)) => void;
118
+ /** Get raw state object (non-reactive snapshot) */
119
+ $state: S;
120
+ };
121
+
122
+ /**
123
+ * Plugin that can extend store functionality.
124
+ */
125
+ export type StorePlugin<S = unknown> = (context: {
126
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
127
+ store: Store<any, any, any>;
128
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
129
+ options: StoreDefinition<any, any, any>;
130
+ }) => Partial<S> | void;
131
+
132
+ // ============================================================================
133
+ // Internal State
134
+ // ============================================================================
135
+
136
+ /** @internal Registry of all stores for devtools */
137
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
138
+ const storeRegistry = new Map<string, Store<any, any, any>>();
139
+
140
+ // ============================================================================
141
+ // Internal Utilities
142
+ // ============================================================================
143
+
144
+ /**
145
+ * Check if a value is a plain object (not array, null, Date, etc.).
146
+ * @internal
147
+ */
148
+ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
149
+ return (
150
+ value !== null && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype
151
+ );
152
+ };
153
+
154
+ /**
155
+ * Deep clones an object. Used for deep reactivity support.
156
+ * @internal
157
+ */
158
+ const deepClone = <T>(obj: T): T => {
159
+ if (obj === null || typeof obj !== 'object') {
160
+ return obj;
161
+ }
162
+
163
+ if (Array.isArray(obj)) {
164
+ return obj.map(deepClone) as T;
165
+ }
166
+
167
+ if (obj instanceof Date) {
168
+ return new Date(obj.getTime()) as T;
169
+ }
170
+
171
+ if (obj instanceof Map) {
172
+ return new Map(Array.from(obj.entries()).map(([k, v]) => [k, deepClone(v)])) as T;
173
+ }
174
+
175
+ if (obj instanceof Set) {
176
+ return new Set(Array.from(obj).map(deepClone)) as T;
177
+ }
178
+
179
+ const cloned = {} as T;
180
+ for (const key of Object.keys(obj)) {
181
+ (cloned as Record<string, unknown>)[key] = deepClone((obj as Record<string, unknown>)[key]);
182
+ }
183
+ return cloned;
184
+ };
185
+
186
+ /**
187
+ * Compares two values for deep equality.
188
+ * @internal
189
+ */
190
+ const deepEqual = (a: unknown, b: unknown): boolean => {
191
+ if (a === b) return true;
192
+ if (a === null || b === null) return false;
193
+ if (typeof a !== 'object' || typeof b !== 'object') return false;
194
+
195
+ if (Array.isArray(a) && Array.isArray(b)) {
196
+ if (a.length !== b.length) return false;
197
+ return a.every((item, i) => deepEqual(item, b[i]));
198
+ }
199
+
200
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
201
+
202
+ const keysA = Object.keys(a as object);
203
+ const keysB = Object.keys(b as object);
204
+
205
+ if (keysA.length !== keysB.length) return false;
206
+
207
+ return keysA.every((key) =>
208
+ deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])
209
+ );
210
+ };
211
+
212
+ /**
213
+ * Detects if nested objects were mutated but the reference stayed the same.
214
+ * Returns the keys where nested mutations were detected.
215
+ * @internal
216
+ */
217
+ const detectNestedMutations = <S extends Record<string, unknown>>(
218
+ before: S,
219
+ after: S,
220
+ signalValues: Map<keyof S, unknown>
221
+ ): Array<keyof S> => {
222
+ const mutatedKeys: Array<keyof S> = [];
223
+
224
+ for (const key of Object.keys(after) as Array<keyof S>) {
225
+ const beforeValue = before[key];
226
+ const afterValue = after[key];
227
+ const signalValue = signalValues.get(key);
228
+
229
+ // Check if it's the same reference but content changed
230
+ if (
231
+ signalValue === afterValue && // Same reference as signal
232
+ isPlainObject(beforeValue) &&
233
+ isPlainObject(afterValue) &&
234
+ !deepEqual(beforeValue, afterValue)
235
+ ) {
236
+ mutatedKeys.push(key);
237
+ }
238
+ }
239
+
240
+ return mutatedKeys;
241
+ };
242
+
243
+ /** @internal Flag to enable/disable development warnings */
244
+ const __DEV__ = (() => {
245
+ try {
246
+ // Check for Node.js environment
247
+ const globalProcess = (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process;
248
+ return typeof globalProcess !== 'undefined' && globalProcess.env?.NODE_ENV !== 'production';
249
+ } catch {
250
+ return true; // Default to dev mode if detection fails
251
+ }
252
+ })();
253
+
254
+ /** @internal Registered plugins */
255
+ const plugins: StorePlugin[] = [];
256
+
257
+ /** @internal Devtools hook */
258
+ declare global {
259
+ interface Window {
260
+ __BQUERY_DEVTOOLS__?: {
261
+ stores: Map<string, unknown>;
262
+ onStoreCreated?: (id: string, store: unknown) => void;
263
+ onStateChange?: (id: string, state: unknown) => void;
264
+ };
265
+ }
266
+ }
267
+
268
+ // ============================================================================
269
+ // Store Creation
270
+ // ============================================================================
271
+
272
+ /**
273
+ * Creates a reactive store with state, getters, and actions.
274
+ *
275
+ * @template S - State type
276
+ * @template G - Getters type
277
+ * @template A - Actions type
278
+ * @param definition - Store definition
279
+ * @returns The reactive store instance
280
+ *
281
+ * @example
282
+ * ```ts
283
+ * import { createStore } from 'bquery/store';
284
+ *
285
+ * // Simple counter store
286
+ * const useCounter = createStore({
287
+ * id: 'counter',
288
+ * state: () => ({ count: 0, step: 1 }),
289
+ * getters: {
290
+ * doubled: (state) => state.count * 2,
291
+ * next: (state) => state.count + state.step,
292
+ * },
293
+ * actions: {
294
+ * increment() {
295
+ * this.count += this.step;
296
+ * },
297
+ * decrement() {
298
+ * this.count -= this.step;
299
+ * },
300
+ * setStep(newStep: number) {
301
+ * this.step = newStep;
302
+ * },
303
+ * async loadFromServer() {
304
+ * const res = await fetch('/api/counter');
305
+ * const data = await res.json();
306
+ * this.count = data.count;
307
+ * },
308
+ * },
309
+ * });
310
+ *
311
+ * // Use the store
312
+ * useCounter.increment();
313
+ * console.log(useCounter.count); // 1
314
+ * console.log(useCounter.doubled); // 2
315
+ * ```
316
+ */
317
+ export const createStore = <
318
+ S extends Record<string, unknown>,
319
+ G extends Record<string, unknown> = Record<string, never>,
320
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
321
+ A extends Record<string, (...args: any[]) => any> = Record<string, never>,
322
+ >(
323
+ definition: StoreDefinition<S, G, A>
324
+ ): Store<S, G, A> => {
325
+ const { id, state: stateFactory, getters = {} as Getters<S, G>, actions = {} as A } = definition;
326
+
327
+ // Check for duplicate store IDs
328
+ if (storeRegistry.has(id)) {
329
+ console.warn(`bQuery store: Store "${id}" already exists. Returning existing instance.`);
330
+ return storeRegistry.get(id) as Store<S, G, A>;
331
+ }
332
+
333
+ // Create initial state
334
+ const initialState = stateFactory();
335
+
336
+ // Create signals for each state property
337
+ const stateSignals = new Map<keyof S, Signal<unknown>>();
338
+ for (const key of Object.keys(initialState) as Array<keyof S>) {
339
+ stateSignals.set(key, signal(initialState[key]));
340
+ }
341
+
342
+ // Subscribers for $subscribe
343
+ const subscribers: Array<(state: S) => void> = [];
344
+
345
+ /**
346
+ * Notifies subscribers of state changes.
347
+ * @internal
348
+ */
349
+ const notifySubscribers = (): void => {
350
+ const currentState = getCurrentState();
351
+ for (const callback of subscribers) {
352
+ callback(currentState);
353
+ }
354
+
355
+ // Notify devtools
356
+ if (typeof window !== 'undefined' && window.__BQUERY_DEVTOOLS__?.onStateChange) {
357
+ window.__BQUERY_DEVTOOLS__.onStateChange(id, currentState);
358
+ }
359
+ };
360
+
361
+ /**
362
+ * Cached state proxy that lazily reads signal values.
363
+ * Uses a Proxy to avoid creating new objects on each access.
364
+ *
365
+ * **Note:** This returns a shallow snapshot of the state. Nested object
366
+ * mutations will NOT trigger reactive updates. For nested reactivity,
367
+ * replace the entire object or use signals for nested properties.
368
+ *
369
+ * @internal
370
+ */
371
+ const stateProxy = new Proxy({} as S, {
372
+ get: (_, prop: string | symbol) => {
373
+ const key = prop as keyof S;
374
+ if (stateSignals.has(key)) {
375
+ return stateSignals.get(key)!.value;
376
+ }
377
+ return undefined;
378
+ },
379
+ ownKeys: () => Array.from(stateSignals.keys()) as string[],
380
+ getOwnPropertyDescriptor: (_, prop) => {
381
+ if (stateSignals.has(prop as keyof S)) {
382
+ return { enumerable: true, configurable: true };
383
+ }
384
+ return undefined;
385
+ },
386
+ has: (_, prop) => stateSignals.has(prop as keyof S),
387
+ });
388
+
389
+ /**
390
+ * Gets the current state.
391
+ *
392
+ * For subscriber notifications (where a plain object snapshot is needed),
393
+ * this creates a shallow copy. For internal reads, use stateProxy directly.
394
+ *
395
+ * **Note:** Returns a shallow snapshot. Nested object mutations will NOT
396
+ * trigger reactive updates. This differs from frameworks like Pinia that
397
+ * use deep reactivity. To update nested state, replace the entire object:
398
+ *
399
+ * @example
400
+ * ```ts
401
+ * // ❌ Won't trigger updates
402
+ * store.user.name = 'New Name';
403
+ *
404
+ * // ✅ Will trigger updates
405
+ * store.user = { ...store.user, name: 'New Name' };
406
+ * ```
407
+ *
408
+ * @internal
409
+ */
410
+ const getCurrentState = (): S => ({ ...stateProxy });
411
+
412
+ // Create computed getters
413
+ const getterComputed = new Map<keyof G, ReadonlySignal<unknown>>();
414
+
415
+ // Build the store proxy
416
+ const store = {} as Store<S, G, A>;
417
+
418
+ // Define state properties with getters/setters
419
+ for (const key of Object.keys(initialState) as Array<keyof S>) {
420
+ Object.defineProperty(store, key, {
421
+ get: () => stateSignals.get(key)!.value,
422
+ set: (value: unknown) => {
423
+ stateSignals.get(key)!.value = value;
424
+ notifySubscribers();
425
+ },
426
+ enumerable: true,
427
+ configurable: false,
428
+ });
429
+ }
430
+
431
+ // Define getters as computed properties
432
+ for (const key of Object.keys(getters) as Array<keyof G>) {
433
+ const getterFn = getters[key];
434
+
435
+ // Create computed that reads from state signals via proxy (more efficient)
436
+ const computedGetter = computed(() => {
437
+ const state = stateProxy;
438
+ // For getter dependencies, pass a proxy that reads from computed getters
439
+ const getterProxy = new Proxy({} as G, {
440
+ get: (_, prop: string | symbol) => {
441
+ const propKey = prop as keyof G;
442
+ if (getterComputed.has(propKey)) {
443
+ return getterComputed.get(propKey)!.value;
444
+ }
445
+ return undefined;
446
+ },
447
+ });
448
+ return getterFn(state, getterProxy);
449
+ });
450
+
451
+ getterComputed.set(key, computedGetter as unknown as ReadonlySignal<unknown>);
452
+
453
+ Object.defineProperty(store, key, {
454
+ get: () => computedGetter.value,
455
+ enumerable: true,
456
+ configurable: false,
457
+ });
458
+ }
459
+
460
+ // Bind actions to the store context
461
+ for (const key of Object.keys(actions) as Array<keyof A>) {
462
+ const actionFn = actions[key];
463
+
464
+ // Wrap action to enable 'this' binding
465
+ (store as Record<string, unknown>)[key as string] = function (...args: unknown[]) {
466
+ // Create a context that allows 'this.property' access
467
+ const context = new Proxy(store, {
468
+ get: (target, prop) => {
469
+ if (typeof prop === 'string' && stateSignals.has(prop as keyof S)) {
470
+ return stateSignals.get(prop as keyof S)!.value;
471
+ }
472
+ return (target as Record<string, unknown>)[prop as string];
473
+ },
474
+ set: (_target, prop, value) => {
475
+ if (typeof prop === 'string' && stateSignals.has(prop as keyof S)) {
476
+ stateSignals.get(prop as keyof S)!.value = value;
477
+ notifySubscribers();
478
+ return true;
479
+ }
480
+ return false;
481
+ },
482
+ });
483
+
484
+ return actionFn.apply(context, args);
485
+ };
486
+ }
487
+
488
+ // Add store utility methods
489
+ Object.defineProperties(store, {
490
+ $id: {
491
+ value: id,
492
+ writable: false,
493
+ enumerable: false,
494
+ },
495
+ $reset: {
496
+ value: () => {
497
+ const fresh = stateFactory();
498
+ batch(() => {
499
+ for (const [key, sig] of stateSignals) {
500
+ sig.value = fresh[key];
501
+ }
502
+ });
503
+ notifySubscribers();
504
+ },
505
+ writable: false,
506
+ enumerable: false,
507
+ },
508
+ $subscribe: {
509
+ value: (callback: (state: S) => void) => {
510
+ subscribers.push(callback);
511
+ return () => {
512
+ const index = subscribers.indexOf(callback);
513
+ if (index > -1) subscribers.splice(index, 1);
514
+ };
515
+ },
516
+ writable: false,
517
+ enumerable: false,
518
+ },
519
+ $patch: {
520
+ value: (partial: Partial<S> | ((state: S) => void)) => {
521
+ batch(() => {
522
+ if (typeof partial === 'function') {
523
+ // Capture state before mutation for nested mutation detection
524
+ const stateBefore = __DEV__ ? deepClone(getCurrentState()) : null;
525
+ const signalValuesBefore = __DEV__
526
+ ? new Map(Array.from(stateSignals.entries()).map(([k, s]) => [k, s.value]))
527
+ : null;
528
+
529
+ // Mutation function
530
+ const state = getCurrentState();
531
+ partial(state);
532
+
533
+ // Detect nested mutations in development mode
534
+ if (__DEV__ && stateBefore && signalValuesBefore) {
535
+ const mutatedKeys = detectNestedMutations(stateBefore, state, signalValuesBefore);
536
+ if (mutatedKeys.length > 0) {
537
+ console.warn(
538
+ `[bQuery store "${id}"] Nested mutation detected in $patch() for keys: ${mutatedKeys.map(String).join(', ')}.\n` +
539
+ 'Nested object mutations do not trigger reactive updates because the store uses shallow reactivity.\n' +
540
+ 'To fix this, either:\n' +
541
+ ' 1. Replace the entire object: state.user = { ...state.user, name: "New" }\n' +
542
+ ' 2. Use $patchDeep() for automatic deep cloning\n' +
543
+ 'See: https://bquery.dev/guide/store#deep-reactivity'
544
+ );
545
+ }
546
+ }
547
+
548
+ for (const [key, value] of Object.entries(state) as Array<[keyof S, unknown]>) {
549
+ if (stateSignals.has(key)) {
550
+ stateSignals.get(key)!.value = value;
551
+ }
552
+ }
553
+ } else {
554
+ // Partial object
555
+ for (const [key, value] of Object.entries(partial) as Array<[keyof S, unknown]>) {
556
+ if (stateSignals.has(key)) {
557
+ stateSignals.get(key)!.value = value;
558
+ }
559
+ }
560
+ }
561
+ });
562
+ notifySubscribers();
563
+ },
564
+ writable: false,
565
+ enumerable: false,
566
+ },
567
+ $patchDeep: {
568
+ value: (partial: Partial<S> | ((state: S) => void)) => {
569
+ batch(() => {
570
+ if (typeof partial === 'function') {
571
+ // Deep clone state before mutation to ensure new references
572
+ const state = deepClone(getCurrentState());
573
+ partial(state);
574
+
575
+ for (const [key, value] of Object.entries(state) as Array<[keyof S, unknown]>) {
576
+ if (stateSignals.has(key)) {
577
+ stateSignals.get(key)!.value = value;
578
+ }
579
+ }
580
+ } else {
581
+ // Deep clone each value in partial to ensure new references
582
+ for (const [key, value] of Object.entries(partial) as Array<[keyof S, unknown]>) {
583
+ if (stateSignals.has(key)) {
584
+ stateSignals.get(key)!.value = deepClone(value);
585
+ }
586
+ }
587
+ }
588
+ });
589
+ notifySubscribers();
590
+ },
591
+ writable: false,
592
+ enumerable: false,
593
+ },
594
+ $state: {
595
+ get: () => getCurrentState(),
596
+ enumerable: false,
597
+ },
598
+ });
599
+
600
+ // Register store
601
+ storeRegistry.set(id, store);
602
+
603
+ // Apply plugins
604
+ for (const plugin of plugins) {
605
+ const extension = plugin({ store, options: definition });
606
+ if (extension) {
607
+ Object.assign(store, extension);
608
+ }
609
+ }
610
+
611
+ // Notify devtools
612
+ if (typeof window !== 'undefined') {
613
+ if (!window.__BQUERY_DEVTOOLS__) {
614
+ window.__BQUERY_DEVTOOLS__ = { stores: new Map() };
615
+ }
616
+ window.__BQUERY_DEVTOOLS__.stores.set(id, store);
617
+ window.__BQUERY_DEVTOOLS__.onStoreCreated?.(id, store);
618
+ }
619
+
620
+ return store;
621
+ };
622
+
623
+ // ============================================================================
624
+ // Store Utilities
625
+ // ============================================================================
626
+
627
+ /**
628
+ * Retrieves an existing store by its ID.
629
+ *
630
+ * @param id - The store identifier
631
+ * @returns The store instance or undefined if not found
632
+ *
633
+ * @example
634
+ * ```ts
635
+ * import { getStore } from 'bquery/store';
636
+ *
637
+ * const counter = getStore('counter');
638
+ * if (counter) {
639
+ * counter.increment();
640
+ * }
641
+ * ```
642
+ */
643
+ export const getStore = <T = unknown>(id: string): T | undefined => {
644
+ return storeRegistry.get(id) as T | undefined;
645
+ };
646
+
647
+ /**
648
+ * Lists all registered store IDs.
649
+ *
650
+ * @returns Array of store IDs
651
+ *
652
+ * @example
653
+ * ```ts
654
+ * import { listStores } from 'bquery/store';
655
+ *
656
+ * console.log('Active stores:', listStores());
657
+ * ```
658
+ */
659
+ export const listStores = (): string[] => {
660
+ return Array.from(storeRegistry.keys());
661
+ };
662
+
663
+ /**
664
+ * Removes a store from the registry.
665
+ *
666
+ * @param id - The store identifier
667
+ *
668
+ * @example
669
+ * ```ts
670
+ * import { destroyStore } from 'bquery/store';
671
+ *
672
+ * destroyStore('counter');
673
+ * ```
674
+ */
675
+ export const destroyStore = (id: string): void => {
676
+ storeRegistry.delete(id);
677
+ if (typeof window !== 'undefined' && window.__BQUERY_DEVTOOLS__) {
678
+ window.__BQUERY_DEVTOOLS__.stores.delete(id);
679
+ }
680
+ };
681
+
682
+ /**
683
+ * Registers a plugin that extends all stores.
684
+ *
685
+ * @param plugin - The plugin function
686
+ *
687
+ * @example
688
+ * ```ts
689
+ * import { registerPlugin } from 'bquery/store';
690
+ *
691
+ * // Add localStorage persistence
692
+ * registerPlugin(({ store, options }) => {
693
+ * const key = `bquery-store-${options.id}`;
694
+ *
695
+ * // Load saved state
696
+ * const saved = localStorage.getItem(key);
697
+ * if (saved) {
698
+ * store.$patch(JSON.parse(saved));
699
+ * }
700
+ *
701
+ * // Save on changes
702
+ * store.$subscribe((state) => {
703
+ * localStorage.setItem(key, JSON.stringify(state));
704
+ * });
705
+ * });
706
+ * ```
707
+ */
708
+ export const registerPlugin = (plugin: StorePlugin): void => {
709
+ plugins.push(plugin);
710
+ };
711
+
712
+ // ============================================================================
713
+ // Composition Helpers
714
+ // ============================================================================
715
+
716
+ /**
717
+ * Creates a store with automatic persistence to localStorage.
718
+ *
719
+ * @param definition - Store definition
720
+ * @param storageKey - Optional custom storage key
721
+ * @returns The reactive store instance
722
+ *
723
+ * @example
724
+ * ```ts
725
+ * import { createPersistedStore } from 'bquery/store';
726
+ *
727
+ * const settings = createPersistedStore({
728
+ * id: 'settings',
729
+ * state: () => ({
730
+ * theme: 'dark',
731
+ * language: 'en',
732
+ * }),
733
+ * });
734
+ *
735
+ * // State is automatically saved/loaded from localStorage
736
+ * settings.theme = 'light';
737
+ * ```
738
+ */
739
+ export const createPersistedStore = <
740
+ S extends Record<string, unknown>,
741
+ G extends Record<string, unknown> = Record<string, never>,
742
+ A extends Record<string, (...args: unknown[]) => unknown> = Record<string, never>,
743
+ >(
744
+ definition: StoreDefinition<S, G, A>,
745
+ storageKey?: string
746
+ ): Store<S, G, A> => {
747
+ const key = storageKey ?? `bquery-store-${definition.id}`;
748
+
749
+ // Wrap state factory to load from storage
750
+ const originalStateFactory = definition.state;
751
+ definition.state = () => {
752
+ const defaultState = originalStateFactory();
753
+
754
+ if (typeof window !== 'undefined') {
755
+ try {
756
+ const saved = localStorage.getItem(key);
757
+ if (saved) {
758
+ return { ...defaultState, ...JSON.parse(saved) };
759
+ }
760
+ } catch {
761
+ // Ignore parse errors
762
+ }
763
+ }
764
+
765
+ return defaultState;
766
+ };
767
+
768
+ const store = createStore(definition);
769
+
770
+ // Subscribe to save changes
771
+ store.$subscribe((state) => {
772
+ if (typeof window !== 'undefined') {
773
+ try {
774
+ localStorage.setItem(key, JSON.stringify(state));
775
+ } catch {
776
+ // Ignore quota errors
777
+ }
778
+ }
779
+ });
780
+
781
+ return store;
782
+ };
783
+
784
+ /**
785
+ * Maps store state properties to a reactive object for use in components.
786
+ *
787
+ * @param store - The store instance
788
+ * @param keys - State keys to map
789
+ * @returns Object with mapped properties
790
+ *
791
+ * @example
792
+ * ```ts
793
+ * import { mapState } from 'bquery/store';
794
+ *
795
+ * const counter = useCounter();
796
+ * const { count, step } = mapState(counter, ['count', 'step']);
797
+ * ```
798
+ */
799
+ export const mapState = <S extends Record<string, unknown>, K extends keyof S>(
800
+ store: S,
801
+ keys: K[]
802
+ ): Pick<S, K> => {
803
+ const mapped = {} as Pick<S, K>;
804
+
805
+ for (const key of keys) {
806
+ Object.defineProperty(mapped, key, {
807
+ get: () => store[key],
808
+ enumerable: true,
809
+ });
810
+ }
811
+
812
+ return mapped;
813
+ };
814
+
815
+ /**
816
+ * Maps store actions to an object for easier destructuring.
817
+ *
818
+ * @param store - The store instance
819
+ * @param keys - Action keys to map
820
+ * @returns Object with mapped actions
821
+ *
822
+ * @example
823
+ * ```ts
824
+ * import { mapActions } from 'bquery/store';
825
+ *
826
+ * const counter = useCounter();
827
+ * const { increment, decrement } = mapActions(counter, ['increment', 'decrement']);
828
+ *
829
+ * // Use directly
830
+ * increment();
831
+ * ```
832
+ */
833
+ export const mapActions = <
834
+ A extends Record<string, (...args: unknown[]) => unknown>,
835
+ K extends keyof A,
836
+ >(
837
+ store: A,
838
+ keys: K[]
839
+ ): Pick<A, K> => {
840
+ const mapped = {} as Pick<A, K>;
841
+
842
+ for (const key of keys) {
843
+ (mapped as Record<string, unknown>)[key as string] = (...args: unknown[]) =>
844
+ (store[key] as (...args: unknown[]) => unknown)(...args);
845
+ }
846
+
847
+ return mapped;
848
+ };