@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.
- package/dist/index.d.ts +327 -0
- package/dist/index.js +528 -0
- package/package.json +44 -0
- package/src/index.ts +58 -0
- package/src/reactive-store.ts +805 -0
- package/src/signal.ts +159 -0
- package/src/store-types.ts +78 -0
|
@@ -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
|
+
}
|