@sylphx/lens-signals 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.
@@ -0,0 +1,805 @@
1
+ /**
2
+ * @sylphx/lens-signals - Reactive Store
3
+ *
4
+ * Manages entity signals, caching, and optimistic updates.
5
+ */
6
+
7
+ import type { EntityKey, Pipeline, Update } from "@sylphx/lens-core";
8
+ import { applyUpdate, makeEntityKey } from "@sylphx/lens-core";
9
+ import {
10
+ createCachePlugin,
11
+ execute,
12
+ type PipelineResult,
13
+ registerPlugin,
14
+ unregisterPlugin,
15
+ } from "@sylphx/reify";
16
+ import { batch, type Signal, signal, type WritableSignal } from "./signal.js";
17
+ import type {
18
+ CascadeRule,
19
+ EntityState,
20
+ InvalidationOptions,
21
+ OptimisticEntry,
22
+ OptimisticTransaction,
23
+ StoreConfig,
24
+ } from "./store-types.js";
25
+
26
+ // Re-export types for convenience
27
+ export type { EntityKey };
28
+ export type {
29
+ CascadeRule,
30
+ EntityState,
31
+ InvalidationOptions,
32
+ OptimisticEntry,
33
+ OptimisticTransaction,
34
+ StoreConfig,
35
+ } from "./store-types.js";
36
+
37
+ // =============================================================================
38
+ // Reactive Store
39
+ // =============================================================================
40
+
41
+ /**
42
+ * Reactive store for managing entity state
43
+ */
44
+ export class ReactiveStore {
45
+ /** Entity signals by key */
46
+ private entities = new Map<EntityKey, WritableSignal<EntityState>>();
47
+
48
+ /** List signals by query key */
49
+ private lists = new Map<string, WritableSignal<EntityState<unknown[]>>>();
50
+
51
+ /** Optimistic updates pending confirmation */
52
+ private optimisticUpdates = new Map<string, OptimisticEntry>();
53
+
54
+ /** Multi-entity optimistic transactions */
55
+ private optimisticTransactions = new Map<string, OptimisticTransaction>();
56
+
57
+ /** Configuration */
58
+ private config: Required<Omit<StoreConfig, "cascadeRules">> & { cascadeRules: CascadeRule[] };
59
+
60
+ /** Tag to entity keys mapping */
61
+ private tagIndex = new Map<string, Set<EntityKey>>();
62
+
63
+ constructor(config: StoreConfig = {}) {
64
+ this.config = {
65
+ optimistic: config.optimistic ?? true,
66
+ cacheTTL: config.cacheTTL ?? 5 * 60 * 1000,
67
+ maxCacheSize: config.maxCacheSize ?? 1000,
68
+ cascadeRules: config.cascadeRules ?? [],
69
+ };
70
+ }
71
+
72
+ // ===========================================================================
73
+ // Entity Management
74
+ // ===========================================================================
75
+
76
+ /**
77
+ * Get or create entity signal
78
+ */
79
+ getEntity<T>(entityName: string, entityId: string): Signal<EntityState<T>> {
80
+ const key = this.makeKey(entityName, entityId);
81
+
82
+ if (!this.entities.has(key)) {
83
+ this.entities.set(
84
+ key,
85
+ signal<EntityState>({
86
+ data: null,
87
+ loading: true,
88
+ error: null,
89
+ stale: false,
90
+ refCount: 0,
91
+ }),
92
+ );
93
+ }
94
+
95
+ return this.entities.get(key)! as Signal<EntityState<T>>;
96
+ }
97
+
98
+ /**
99
+ * Set entity data
100
+ */
101
+ setEntity<T>(entityName: string, entityId: string, data: T, tags?: string[]): void {
102
+ const key = this.makeKey(entityName, entityId);
103
+ const entitySignal = this.entities.get(key);
104
+ const now = Date.now();
105
+
106
+ if (entitySignal) {
107
+ entitySignal.value = {
108
+ ...entitySignal.value,
109
+ data,
110
+ loading: false,
111
+ error: null,
112
+ stale: false,
113
+ cachedAt: now,
114
+ tags: tags ?? entitySignal.value.tags,
115
+ };
116
+ } else {
117
+ this.entities.set(
118
+ key,
119
+ signal<EntityState>({
120
+ data,
121
+ loading: false,
122
+ error: null,
123
+ stale: false,
124
+ refCount: 0,
125
+ cachedAt: now,
126
+ tags,
127
+ }),
128
+ );
129
+ }
130
+
131
+ // Update tag index
132
+ if (tags) {
133
+ for (const tag of tags) {
134
+ if (!this.tagIndex.has(tag)) {
135
+ this.tagIndex.set(tag, new Set());
136
+ }
137
+ this.tagIndex.get(tag)!.add(key);
138
+ }
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Update entity with server update
144
+ */
145
+ applyServerUpdate(entityName: string, entityId: string, update: Update): void {
146
+ const key = this.makeKey(entityName, entityId);
147
+ const entitySignal = this.entities.get(key);
148
+
149
+ if (entitySignal && entitySignal.value.data != null) {
150
+ const newData = applyUpdate(entitySignal.value.data, update);
151
+ entitySignal.value = {
152
+ ...entitySignal.value,
153
+ data: newData,
154
+ stale: false,
155
+ };
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Set entity error state
161
+ */
162
+ setEntityError(entityName: string, entityId: string, error: Error): void {
163
+ const key = this.makeKey(entityName, entityId);
164
+ const entitySignal = this.entities.get(key);
165
+
166
+ if (entitySignal) {
167
+ entitySignal.value = {
168
+ ...entitySignal.value,
169
+ loading: false,
170
+ error,
171
+ };
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Set entity loading state
177
+ */
178
+ setEntityLoading(entityName: string, entityId: string, loading: boolean): void {
179
+ const key = this.makeKey(entityName, entityId);
180
+ const entitySignal = this.entities.get(key);
181
+
182
+ if (entitySignal) {
183
+ entitySignal.value = {
184
+ ...entitySignal.value,
185
+ loading,
186
+ };
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Remove entity from cache
192
+ */
193
+ removeEntity(entityName: string, entityId: string): void {
194
+ const key = this.makeKey(entityName, entityId);
195
+ this.entities.delete(key);
196
+ }
197
+
198
+ /**
199
+ * Check if entity exists in cache
200
+ */
201
+ hasEntity(entityName: string, entityId: string): boolean {
202
+ const key = this.makeKey(entityName, entityId);
203
+ return this.entities.has(key);
204
+ }
205
+
206
+ // ===========================================================================
207
+ // List Management
208
+ // ===========================================================================
209
+
210
+ /**
211
+ * Get or create list signal
212
+ */
213
+ getList<T>(queryKey: string): Signal<EntityState<T[]>> {
214
+ if (!this.lists.has(queryKey)) {
215
+ this.lists.set(
216
+ queryKey,
217
+ signal<EntityState<unknown[]>>({
218
+ data: null,
219
+ loading: true,
220
+ error: null,
221
+ stale: false,
222
+ refCount: 0,
223
+ }),
224
+ );
225
+ }
226
+
227
+ return this.lists.get(queryKey)! as Signal<EntityState<T[]>>;
228
+ }
229
+
230
+ /**
231
+ * Set list data
232
+ */
233
+ setList<T>(queryKey: string, data: T[]): void {
234
+ const listSignal = this.lists.get(queryKey);
235
+
236
+ if (listSignal) {
237
+ listSignal.value = {
238
+ ...listSignal.value,
239
+ data: data as unknown[],
240
+ loading: false,
241
+ error: null,
242
+ stale: false,
243
+ };
244
+ } else {
245
+ this.lists.set(
246
+ queryKey,
247
+ signal<EntityState<unknown[]>>({
248
+ data: data as unknown[],
249
+ loading: false,
250
+ error: null,
251
+ stale: false,
252
+ refCount: 0,
253
+ }),
254
+ );
255
+ }
256
+ }
257
+
258
+ // ===========================================================================
259
+ // Optimistic Updates
260
+ // ===========================================================================
261
+
262
+ /**
263
+ * Apply optimistic update
264
+ */
265
+ applyOptimistic<T extends { id: string }>(
266
+ entityName: string,
267
+ type: "create" | "update" | "delete",
268
+ data: Partial<T> & { id: string },
269
+ ): string {
270
+ if (!this.config.optimistic) {
271
+ return "";
272
+ }
273
+
274
+ const optimisticId = `opt_${Date.now()}_${Math.random().toString(36).slice(2)}`;
275
+ const entityId = data.id;
276
+ const key = this.makeKey(entityName, entityId);
277
+
278
+ // Store original data for rollback
279
+ const entitySignal = this.entities.get(key);
280
+ const originalData = entitySignal?.value.data ?? null;
281
+
282
+ batch(() => {
283
+ switch (type) {
284
+ case "create":
285
+ // Add to cache
286
+ this.setEntity(entityName, entityId, data);
287
+ break;
288
+
289
+ case "update":
290
+ // Merge with existing data
291
+ if (entitySignal?.value.data) {
292
+ this.setEntity(entityName, entityId, {
293
+ ...(entitySignal.value.data as object),
294
+ ...data,
295
+ });
296
+ }
297
+ break;
298
+
299
+ case "delete":
300
+ // Mark as deleted (keep in cache but null data)
301
+ if (entitySignal) {
302
+ entitySignal.value = {
303
+ ...entitySignal.value,
304
+ data: null,
305
+ };
306
+ }
307
+ break;
308
+ }
309
+ });
310
+
311
+ // Store for potential rollback
312
+ this.optimisticUpdates.set(optimisticId, {
313
+ id: optimisticId,
314
+ entityName,
315
+ entityId,
316
+ type,
317
+ originalData,
318
+ optimisticData: data,
319
+ timestamp: Date.now(),
320
+ });
321
+
322
+ return optimisticId;
323
+ }
324
+
325
+ /**
326
+ * Confirm optimistic update (server confirmed)
327
+ */
328
+ confirmOptimistic(optimisticId: string, serverData?: unknown): void {
329
+ const entry = this.optimisticUpdates.get(optimisticId);
330
+ if (!entry) return;
331
+
332
+ // If server returned different data, update with it
333
+ if (serverData !== undefined && entry.type !== "delete") {
334
+ this.setEntity(entry.entityName, entry.entityId, serverData);
335
+ }
336
+
337
+ // Remove from pending
338
+ this.optimisticUpdates.delete(optimisticId);
339
+ }
340
+
341
+ /**
342
+ * Rollback optimistic update (server rejected)
343
+ */
344
+ rollbackOptimistic(optimisticId: string): void {
345
+ const entry = this.optimisticUpdates.get(optimisticId);
346
+ if (!entry) return;
347
+
348
+ batch(() => {
349
+ switch (entry.type) {
350
+ case "create":
351
+ // Remove the optimistically created entity
352
+ this.removeEntity(entry.entityName, entry.entityId);
353
+ break;
354
+
355
+ case "update":
356
+ case "delete":
357
+ // Restore original data
358
+ if (entry.originalData !== null) {
359
+ this.setEntity(entry.entityName, entry.entityId, entry.originalData);
360
+ }
361
+ break;
362
+ }
363
+ });
364
+
365
+ // Remove from pending
366
+ this.optimisticUpdates.delete(optimisticId);
367
+ }
368
+
369
+ /**
370
+ * Get pending optimistic updates
371
+ */
372
+ getPendingOptimistic(): OptimisticEntry[] {
373
+ return Array.from(this.optimisticUpdates.values());
374
+ }
375
+
376
+ // ===========================================================================
377
+ // Multi-Entity Optimistic Updates (Transaction-based)
378
+ // ===========================================================================
379
+
380
+ /**
381
+ * Apply optimistic update from Reify Pipeline
382
+ * Returns transaction ID for confirmation/rollback
383
+ *
384
+ * Uses Reify's execute() with a cache adapter that wraps ReactiveStore.
385
+ */
386
+ async applyPipelineOptimistic<TInput extends Record<string, unknown>>(
387
+ pipeline: Pipeline,
388
+ input: TInput,
389
+ ): Promise<string> {
390
+ if (!this.config.optimistic) {
391
+ return "";
392
+ }
393
+
394
+ const txId = `tx_${Date.now()}_${Math.random().toString(36).slice(2)}`;
395
+
396
+ // Store original data for rollback
397
+ const originalData = new Map<string, unknown>();
398
+
399
+ // Create a cache adapter that wraps ReactiveStore
400
+ const cacheAdapter = {
401
+ get: (key: string) => {
402
+ const [entityName, entityId] = key.split(":") as [string, string];
403
+ const entitySignal = this.entities.get(this.makeKey(entityName, entityId));
404
+ return entitySignal?.value.data ?? undefined;
405
+ },
406
+ set: (key: string, value: unknown) => {
407
+ const [entityName, entityId] = key.split(":") as [string, string];
408
+ const storeKey = this.makeKey(entityName, entityId);
409
+
410
+ // Save original data before first modification
411
+ if (!originalData.has(storeKey)) {
412
+ const entitySignal = this.entities.get(storeKey);
413
+ originalData.set(storeKey, entitySignal?.value.data ?? null);
414
+ }
415
+
416
+ this.setEntity(entityName, entityId, value);
417
+ },
418
+ delete: (key: string) => {
419
+ const [entityName, entityId] = key.split(":") as [string, string];
420
+ const storeKey = this.makeKey(entityName, entityId);
421
+
422
+ // Save original data before deletion
423
+ if (!originalData.has(storeKey)) {
424
+ const entitySignal = this.entities.get(storeKey);
425
+ originalData.set(storeKey, entitySignal?.value.data ?? null);
426
+ }
427
+
428
+ const entitySignal = this.entities.get(storeKey);
429
+ if (entitySignal) {
430
+ entitySignal.value = { ...entitySignal.value, data: null };
431
+ }
432
+ return true;
433
+ },
434
+ has: (key: string) => {
435
+ const [entityName, entityId] = key.split(":") as [string, string];
436
+ return this.entities.has(this.makeKey(entityName, entityId));
437
+ },
438
+ };
439
+
440
+ // Execute pipeline with cache adapter
441
+ // Register plugin globally (Reify requires global plugin registration)
442
+ const cachePlugin = createCachePlugin(cacheAdapter);
443
+ registerPlugin(cachePlugin);
444
+
445
+ let results: PipelineResult;
446
+ try {
447
+ results = await execute(pipeline, input);
448
+ } finally {
449
+ // Unregister to avoid conflicts with other store instances
450
+ unregisterPlugin("entity");
451
+ }
452
+
453
+ // Store transaction for potential rollback
454
+ this.optimisticTransactions.set(txId, {
455
+ id: txId,
456
+ results,
457
+ originalData,
458
+ timestamp: Date.now(),
459
+ });
460
+
461
+ return txId;
462
+ }
463
+
464
+ /**
465
+ * Confirm pipeline optimistic transaction
466
+ * Updates entities with server data (replaces temp IDs with real IDs)
467
+ */
468
+ confirmPipelineOptimistic(
469
+ txId: string,
470
+ serverResults?: Array<{ entity: string; tempId: string; data: unknown }>,
471
+ ): void {
472
+ const tx = this.optimisticTransactions.get(txId);
473
+ if (!tx) return;
474
+
475
+ if (serverResults) {
476
+ batch(() => {
477
+ for (const result of serverResults) {
478
+ // Remove temp entity
479
+ this.removeEntity(result.entity, result.tempId);
480
+
481
+ // Add with real ID
482
+ const realData = result.data as { id?: string } | null;
483
+ if (realData?.id) {
484
+ this.setEntity(result.entity, realData.id, realData);
485
+ }
486
+ }
487
+ });
488
+ }
489
+
490
+ this.optimisticTransactions.delete(txId);
491
+ }
492
+
493
+ /**
494
+ * Rollback pipeline optimistic transaction
495
+ * Restores all entities to their original state
496
+ */
497
+ rollbackPipelineOptimistic(txId: string): void {
498
+ const tx = this.optimisticTransactions.get(txId);
499
+ if (!tx) return;
500
+
501
+ batch(() => {
502
+ // Restore all entities to their original state
503
+ for (const [key, originalData] of tx.originalData) {
504
+ const [entityName, entityId] = key.split(":") as [string, string];
505
+
506
+ if (originalData === null) {
507
+ // Entity didn't exist before, remove it
508
+ this.removeEntity(entityName, entityId);
509
+ } else {
510
+ // Restore original data
511
+ this.setEntity(entityName, entityId, originalData);
512
+ }
513
+ }
514
+ });
515
+
516
+ this.optimisticTransactions.delete(txId);
517
+ }
518
+
519
+ /**
520
+ * Get pending multi-entity transactions
521
+ */
522
+ getPendingTransactions(): OptimisticTransaction[] {
523
+ return Array.from(this.optimisticTransactions.values());
524
+ }
525
+
526
+ // ===========================================================================
527
+ // Cache Invalidation
528
+ // ===========================================================================
529
+
530
+ /**
531
+ * Invalidate entity and mark as stale
532
+ */
533
+ invalidate(entityName: string, entityId: string, options?: InvalidationOptions): void {
534
+ const key = this.makeKey(entityName, entityId);
535
+ this.markStale(key);
536
+
537
+ // Cascade invalidation
538
+ if (options?.cascade !== false) {
539
+ this.cascadeInvalidate(entityName, "update");
540
+ }
541
+ }
542
+
543
+ /**
544
+ * Invalidate all entities of a type
545
+ */
546
+ invalidateEntity(entityName: string, options?: InvalidationOptions): void {
547
+ for (const key of this.entities.keys()) {
548
+ if (key.startsWith(`${entityName}:`)) {
549
+ this.markStale(key);
550
+ }
551
+ }
552
+
553
+ // Invalidate related lists
554
+ for (const listKey of this.lists.keys()) {
555
+ if (listKey.includes(entityName)) {
556
+ const listSignal = this.lists.get(listKey);
557
+ if (listSignal) {
558
+ listSignal.value = { ...listSignal.value, stale: true };
559
+ }
560
+ }
561
+ }
562
+
563
+ // Cascade invalidation
564
+ if (options?.cascade !== false) {
565
+ this.cascadeInvalidate(entityName, "update");
566
+ }
567
+ }
568
+
569
+ /**
570
+ * Invalidate by tags
571
+ */
572
+ invalidateByTags(tags: string[]): number {
573
+ let count = 0;
574
+ for (const tag of tags) {
575
+ const keys = this.tagIndex.get(tag);
576
+ if (keys) {
577
+ for (const key of keys) {
578
+ this.markStale(key);
579
+ count++;
580
+ }
581
+ }
582
+ }
583
+ return count;
584
+ }
585
+
586
+ /**
587
+ * Invalidate by pattern (glob-like: User:*, *:123)
588
+ */
589
+ invalidateByPattern(pattern: string): number {
590
+ const regex = this.patternToRegex(pattern);
591
+ let count = 0;
592
+
593
+ for (const key of this.entities.keys()) {
594
+ if (regex.test(key)) {
595
+ this.markStale(key);
596
+ count++;
597
+ }
598
+ }
599
+
600
+ return count;
601
+ }
602
+
603
+ /**
604
+ * Tag an entity for group invalidation
605
+ */
606
+ tagEntity(entityName: string, entityId: string, tags: string[]): void {
607
+ const key = this.makeKey(entityName, entityId);
608
+ const entitySignal = this.entities.get(key);
609
+
610
+ if (entitySignal) {
611
+ entitySignal.value = {
612
+ ...entitySignal.value,
613
+ tags: [...new Set([...(entitySignal.value.tags ?? []), ...tags])],
614
+ };
615
+
616
+ // Update tag index
617
+ for (const tag of tags) {
618
+ if (!this.tagIndex.has(tag)) {
619
+ this.tagIndex.set(tag, new Set());
620
+ }
621
+ this.tagIndex.get(tag)!.add(key);
622
+ }
623
+ }
624
+ }
625
+
626
+ /**
627
+ * Check if entity data is stale (past TTL)
628
+ */
629
+ isStale(entityName: string, entityId: string): boolean {
630
+ const key = this.makeKey(entityName, entityId);
631
+ const entitySignal = this.entities.get(key);
632
+
633
+ if (!entitySignal) return true;
634
+ if (entitySignal.value.stale) return true;
635
+ if (!entitySignal.value.cachedAt) return false;
636
+
637
+ return Date.now() - entitySignal.value.cachedAt > this.config.cacheTTL;
638
+ }
639
+
640
+ /**
641
+ * Get data with stale-while-revalidate pattern
642
+ * Returns stale data immediately and triggers revalidation callback
643
+ */
644
+ getStaleWhileRevalidate<T>(
645
+ entityName: string,
646
+ entityId: string,
647
+ revalidate: () => Promise<T>,
648
+ ): { data: T | null; isStale: boolean; revalidating: Promise<T> | null } {
649
+ const key = this.makeKey(entityName, entityId);
650
+ const entitySignal = this.entities.get(key);
651
+ const isStale = this.isStale(entityName, entityId);
652
+
653
+ let revalidating: Promise<T> | null = null;
654
+
655
+ if (isStale && entitySignal?.value.data != null) {
656
+ // Return stale data and trigger revalidation
657
+ revalidating = revalidate().then((newData) => {
658
+ this.setEntity(entityName, entityId, newData);
659
+ return newData;
660
+ });
661
+ }
662
+
663
+ return {
664
+ data: (entitySignal?.value.data as T) ?? null,
665
+ isStale,
666
+ revalidating,
667
+ };
668
+ }
669
+
670
+ // ===========================================================================
671
+ // Private Invalidation Helpers
672
+ // ===========================================================================
673
+
674
+ private markStale(key: EntityKey): void {
675
+ const entitySignal = this.entities.get(key);
676
+ if (entitySignal) {
677
+ entitySignal.value = { ...entitySignal.value, stale: true };
678
+ }
679
+ }
680
+
681
+ private cascadeInvalidate(entityName: string, operation: "create" | "update" | "delete"): void {
682
+ for (const rule of this.config.cascadeRules) {
683
+ if (rule.source !== entityName) continue;
684
+ if (rule.operations && !rule.operations.includes(operation)) continue;
685
+
686
+ for (const target of rule.targets) {
687
+ this.invalidateEntity(target, { cascade: false }); // Prevent infinite loop
688
+ }
689
+ }
690
+ }
691
+
692
+ private patternToRegex(pattern: string): RegExp {
693
+ // Convert glob-like pattern to regex: * -> .*, ? -> .
694
+ const escaped = pattern
695
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
696
+ .replace(/\*/g, ".*")
697
+ .replace(/\?/g, ".");
698
+ return new RegExp(`^${escaped}$`);
699
+ }
700
+
701
+ // ===========================================================================
702
+ // Reference Counting & Cleanup
703
+ // ===========================================================================
704
+
705
+ /**
706
+ * Increment reference count for entity
707
+ */
708
+ retain(entityName: string, entityId: string): void {
709
+ const key = this.makeKey(entityName, entityId);
710
+ const entitySignal = this.entities.get(key);
711
+
712
+ if (entitySignal) {
713
+ entitySignal.value = {
714
+ ...entitySignal.value,
715
+ refCount: entitySignal.value.refCount + 1,
716
+ };
717
+ }
718
+ }
719
+
720
+ /**
721
+ * Decrement reference count for entity
722
+ */
723
+ release(entityName: string, entityId: string): void {
724
+ const key = this.makeKey(entityName, entityId);
725
+ const entitySignal = this.entities.get(key);
726
+
727
+ if (entitySignal) {
728
+ const newRefCount = Math.max(0, entitySignal.value.refCount - 1);
729
+ entitySignal.value = {
730
+ ...entitySignal.value,
731
+ refCount: newRefCount,
732
+ };
733
+
734
+ // Mark as stale when no subscribers
735
+ if (newRefCount === 0) {
736
+ entitySignal.value = {
737
+ ...entitySignal.value,
738
+ stale: true,
739
+ };
740
+ }
741
+ }
742
+ }
743
+
744
+ /**
745
+ * Clear all stale entities
746
+ */
747
+ gc(): number {
748
+ let cleared = 0;
749
+
750
+ for (const [key, entitySignal] of this.entities) {
751
+ if (entitySignal.value.stale && entitySignal.value.refCount === 0) {
752
+ this.entities.delete(key);
753
+ cleared++;
754
+ }
755
+ }
756
+
757
+ return cleared;
758
+ }
759
+
760
+ /**
761
+ * Clear entire cache
762
+ */
763
+ clear(): void {
764
+ this.entities.clear();
765
+ this.lists.clear();
766
+ this.optimisticUpdates.clear();
767
+ }
768
+
769
+ // ===========================================================================
770
+ // Utilities
771
+ // ===========================================================================
772
+
773
+ /**
774
+ * Create cache key (delegates to @sylphx/lens-core)
775
+ */
776
+ private makeKey(entityName: string, entityId: string): EntityKey {
777
+ return makeEntityKey(entityName, entityId);
778
+ }
779
+
780
+ /**
781
+ * Get cache statistics
782
+ */
783
+ getStats(): {
784
+ entities: number;
785
+ lists: number;
786
+ pendingOptimistic: number;
787
+ } {
788
+ return {
789
+ entities: this.entities.size,
790
+ lists: this.lists.size,
791
+ pendingOptimistic: this.optimisticUpdates.size,
792
+ };
793
+ }
794
+ }
795
+
796
+ // =============================================================================
797
+ // Factory
798
+ // =============================================================================
799
+
800
+ /**
801
+ * Create a new reactive store
802
+ */
803
+ export function createStore(config?: StoreConfig): ReactiveStore {
804
+ return new ReactiveStore(config);
805
+ }