@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.js ADDED
@@ -0,0 +1,528 @@
1
+ // src/signal.ts
2
+ import {
3
+ batch as preactBatch,
4
+ computed as preactComputed,
5
+ effect as preactEffect,
6
+ signal as preactSignal
7
+ } from "@preact/signals-core";
8
+ function signal(initial) {
9
+ return preactSignal(initial);
10
+ }
11
+ function computed(compute) {
12
+ return preactComputed(compute);
13
+ }
14
+ function effect(fn) {
15
+ return preactEffect(fn);
16
+ }
17
+ function batch(fn) {
18
+ return preactBatch(fn);
19
+ }
20
+ function isSignal(value) {
21
+ return value !== null && typeof value === "object" && "value" in value && "peek" in value && "subscribe" in value;
22
+ }
23
+ function toPromise(sig) {
24
+ return new Promise((resolve) => {
25
+ let isFirst = true;
26
+ let unsub;
27
+ unsub = sig.subscribe((value) => {
28
+ if (isFirst) {
29
+ isFirst = false;
30
+ return;
31
+ }
32
+ unsub();
33
+ resolve(value);
34
+ });
35
+ });
36
+ }
37
+ function derive(signals, fn) {
38
+ return computed(() => fn(signals.map((s) => s.value)));
39
+ }
40
+ // src/reactive-store.ts
41
+ import { applyUpdate, makeEntityKey } from "@sylphx/lens-core";
42
+ import {
43
+ createCachePlugin,
44
+ execute,
45
+ registerPlugin,
46
+ unregisterPlugin
47
+ } from "@sylphx/reify";
48
+ class ReactiveStore {
49
+ entities = new Map;
50
+ lists = new Map;
51
+ optimisticUpdates = new Map;
52
+ optimisticTransactions = new Map;
53
+ config;
54
+ tagIndex = new Map;
55
+ constructor(config = {}) {
56
+ this.config = {
57
+ optimistic: config.optimistic ?? true,
58
+ cacheTTL: config.cacheTTL ?? 5 * 60 * 1000,
59
+ maxCacheSize: config.maxCacheSize ?? 1000,
60
+ cascadeRules: config.cascadeRules ?? []
61
+ };
62
+ }
63
+ getEntity(entityName, entityId) {
64
+ const key = this.makeKey(entityName, entityId);
65
+ if (!this.entities.has(key)) {
66
+ this.entities.set(key, signal({
67
+ data: null,
68
+ loading: true,
69
+ error: null,
70
+ stale: false,
71
+ refCount: 0
72
+ }));
73
+ }
74
+ return this.entities.get(key);
75
+ }
76
+ setEntity(entityName, entityId, data, tags) {
77
+ const key = this.makeKey(entityName, entityId);
78
+ const entitySignal = this.entities.get(key);
79
+ const now = Date.now();
80
+ if (entitySignal) {
81
+ entitySignal.value = {
82
+ ...entitySignal.value,
83
+ data,
84
+ loading: false,
85
+ error: null,
86
+ stale: false,
87
+ cachedAt: now,
88
+ tags: tags ?? entitySignal.value.tags
89
+ };
90
+ } else {
91
+ this.entities.set(key, signal({
92
+ data,
93
+ loading: false,
94
+ error: null,
95
+ stale: false,
96
+ refCount: 0,
97
+ cachedAt: now,
98
+ tags
99
+ }));
100
+ }
101
+ if (tags) {
102
+ for (const tag of tags) {
103
+ if (!this.tagIndex.has(tag)) {
104
+ this.tagIndex.set(tag, new Set);
105
+ }
106
+ this.tagIndex.get(tag).add(key);
107
+ }
108
+ }
109
+ }
110
+ applyServerUpdate(entityName, entityId, update) {
111
+ const key = this.makeKey(entityName, entityId);
112
+ const entitySignal = this.entities.get(key);
113
+ if (entitySignal && entitySignal.value.data != null) {
114
+ const newData = applyUpdate(entitySignal.value.data, update);
115
+ entitySignal.value = {
116
+ ...entitySignal.value,
117
+ data: newData,
118
+ stale: false
119
+ };
120
+ }
121
+ }
122
+ setEntityError(entityName, entityId, error) {
123
+ const key = this.makeKey(entityName, entityId);
124
+ const entitySignal = this.entities.get(key);
125
+ if (entitySignal) {
126
+ entitySignal.value = {
127
+ ...entitySignal.value,
128
+ loading: false,
129
+ error
130
+ };
131
+ }
132
+ }
133
+ setEntityLoading(entityName, entityId, loading) {
134
+ const key = this.makeKey(entityName, entityId);
135
+ const entitySignal = this.entities.get(key);
136
+ if (entitySignal) {
137
+ entitySignal.value = {
138
+ ...entitySignal.value,
139
+ loading
140
+ };
141
+ }
142
+ }
143
+ removeEntity(entityName, entityId) {
144
+ const key = this.makeKey(entityName, entityId);
145
+ this.entities.delete(key);
146
+ }
147
+ hasEntity(entityName, entityId) {
148
+ const key = this.makeKey(entityName, entityId);
149
+ return this.entities.has(key);
150
+ }
151
+ getList(queryKey) {
152
+ if (!this.lists.has(queryKey)) {
153
+ this.lists.set(queryKey, signal({
154
+ data: null,
155
+ loading: true,
156
+ error: null,
157
+ stale: false,
158
+ refCount: 0
159
+ }));
160
+ }
161
+ return this.lists.get(queryKey);
162
+ }
163
+ setList(queryKey, data) {
164
+ const listSignal = this.lists.get(queryKey);
165
+ if (listSignal) {
166
+ listSignal.value = {
167
+ ...listSignal.value,
168
+ data,
169
+ loading: false,
170
+ error: null,
171
+ stale: false
172
+ };
173
+ } else {
174
+ this.lists.set(queryKey, signal({
175
+ data,
176
+ loading: false,
177
+ error: null,
178
+ stale: false,
179
+ refCount: 0
180
+ }));
181
+ }
182
+ }
183
+ applyOptimistic(entityName, type, data) {
184
+ if (!this.config.optimistic) {
185
+ return "";
186
+ }
187
+ const optimisticId = `opt_${Date.now()}_${Math.random().toString(36).slice(2)}`;
188
+ const entityId = data.id;
189
+ const key = this.makeKey(entityName, entityId);
190
+ const entitySignal = this.entities.get(key);
191
+ const originalData = entitySignal?.value.data ?? null;
192
+ batch(() => {
193
+ switch (type) {
194
+ case "create":
195
+ this.setEntity(entityName, entityId, data);
196
+ break;
197
+ case "update":
198
+ if (entitySignal?.value.data) {
199
+ this.setEntity(entityName, entityId, {
200
+ ...entitySignal.value.data,
201
+ ...data
202
+ });
203
+ }
204
+ break;
205
+ case "delete":
206
+ if (entitySignal) {
207
+ entitySignal.value = {
208
+ ...entitySignal.value,
209
+ data: null
210
+ };
211
+ }
212
+ break;
213
+ }
214
+ });
215
+ this.optimisticUpdates.set(optimisticId, {
216
+ id: optimisticId,
217
+ entityName,
218
+ entityId,
219
+ type,
220
+ originalData,
221
+ optimisticData: data,
222
+ timestamp: Date.now()
223
+ });
224
+ return optimisticId;
225
+ }
226
+ confirmOptimistic(optimisticId, serverData) {
227
+ const entry = this.optimisticUpdates.get(optimisticId);
228
+ if (!entry)
229
+ return;
230
+ if (serverData !== undefined && entry.type !== "delete") {
231
+ this.setEntity(entry.entityName, entry.entityId, serverData);
232
+ }
233
+ this.optimisticUpdates.delete(optimisticId);
234
+ }
235
+ rollbackOptimistic(optimisticId) {
236
+ const entry = this.optimisticUpdates.get(optimisticId);
237
+ if (!entry)
238
+ return;
239
+ batch(() => {
240
+ switch (entry.type) {
241
+ case "create":
242
+ this.removeEntity(entry.entityName, entry.entityId);
243
+ break;
244
+ case "update":
245
+ case "delete":
246
+ if (entry.originalData !== null) {
247
+ this.setEntity(entry.entityName, entry.entityId, entry.originalData);
248
+ }
249
+ break;
250
+ }
251
+ });
252
+ this.optimisticUpdates.delete(optimisticId);
253
+ }
254
+ getPendingOptimistic() {
255
+ return Array.from(this.optimisticUpdates.values());
256
+ }
257
+ async applyPipelineOptimistic(pipeline, input) {
258
+ if (!this.config.optimistic) {
259
+ return "";
260
+ }
261
+ const txId = `tx_${Date.now()}_${Math.random().toString(36).slice(2)}`;
262
+ const originalData = new Map;
263
+ const cacheAdapter = {
264
+ get: (key) => {
265
+ const [entityName, entityId] = key.split(":");
266
+ const entitySignal = this.entities.get(this.makeKey(entityName, entityId));
267
+ return entitySignal?.value.data ?? undefined;
268
+ },
269
+ set: (key, value) => {
270
+ const [entityName, entityId] = key.split(":");
271
+ const storeKey = this.makeKey(entityName, entityId);
272
+ if (!originalData.has(storeKey)) {
273
+ const entitySignal = this.entities.get(storeKey);
274
+ originalData.set(storeKey, entitySignal?.value.data ?? null);
275
+ }
276
+ this.setEntity(entityName, entityId, value);
277
+ },
278
+ delete: (key) => {
279
+ const [entityName, entityId] = key.split(":");
280
+ const storeKey = this.makeKey(entityName, entityId);
281
+ if (!originalData.has(storeKey)) {
282
+ const entitySignal2 = this.entities.get(storeKey);
283
+ originalData.set(storeKey, entitySignal2?.value.data ?? null);
284
+ }
285
+ const entitySignal = this.entities.get(storeKey);
286
+ if (entitySignal) {
287
+ entitySignal.value = { ...entitySignal.value, data: null };
288
+ }
289
+ return true;
290
+ },
291
+ has: (key) => {
292
+ const [entityName, entityId] = key.split(":");
293
+ return this.entities.has(this.makeKey(entityName, entityId));
294
+ }
295
+ };
296
+ const cachePlugin = createCachePlugin(cacheAdapter);
297
+ registerPlugin(cachePlugin);
298
+ let results;
299
+ try {
300
+ results = await execute(pipeline, input);
301
+ } finally {
302
+ unregisterPlugin("entity");
303
+ }
304
+ this.optimisticTransactions.set(txId, {
305
+ id: txId,
306
+ results,
307
+ originalData,
308
+ timestamp: Date.now()
309
+ });
310
+ return txId;
311
+ }
312
+ confirmPipelineOptimistic(txId, serverResults) {
313
+ const tx = this.optimisticTransactions.get(txId);
314
+ if (!tx)
315
+ return;
316
+ if (serverResults) {
317
+ batch(() => {
318
+ for (const result of serverResults) {
319
+ this.removeEntity(result.entity, result.tempId);
320
+ const realData = result.data;
321
+ if (realData?.id) {
322
+ this.setEntity(result.entity, realData.id, realData);
323
+ }
324
+ }
325
+ });
326
+ }
327
+ this.optimisticTransactions.delete(txId);
328
+ }
329
+ rollbackPipelineOptimistic(txId) {
330
+ const tx = this.optimisticTransactions.get(txId);
331
+ if (!tx)
332
+ return;
333
+ batch(() => {
334
+ for (const [key, originalData] of tx.originalData) {
335
+ const [entityName, entityId] = key.split(":");
336
+ if (originalData === null) {
337
+ this.removeEntity(entityName, entityId);
338
+ } else {
339
+ this.setEntity(entityName, entityId, originalData);
340
+ }
341
+ }
342
+ });
343
+ this.optimisticTransactions.delete(txId);
344
+ }
345
+ getPendingTransactions() {
346
+ return Array.from(this.optimisticTransactions.values());
347
+ }
348
+ invalidate(entityName, entityId, options) {
349
+ const key = this.makeKey(entityName, entityId);
350
+ this.markStale(key);
351
+ if (options?.cascade !== false) {
352
+ this.cascadeInvalidate(entityName, "update");
353
+ }
354
+ }
355
+ invalidateEntity(entityName, options) {
356
+ for (const key of this.entities.keys()) {
357
+ if (key.startsWith(`${entityName}:`)) {
358
+ this.markStale(key);
359
+ }
360
+ }
361
+ for (const listKey of this.lists.keys()) {
362
+ if (listKey.includes(entityName)) {
363
+ const listSignal = this.lists.get(listKey);
364
+ if (listSignal) {
365
+ listSignal.value = { ...listSignal.value, stale: true };
366
+ }
367
+ }
368
+ }
369
+ if (options?.cascade !== false) {
370
+ this.cascadeInvalidate(entityName, "update");
371
+ }
372
+ }
373
+ invalidateByTags(tags) {
374
+ let count = 0;
375
+ for (const tag of tags) {
376
+ const keys = this.tagIndex.get(tag);
377
+ if (keys) {
378
+ for (const key of keys) {
379
+ this.markStale(key);
380
+ count++;
381
+ }
382
+ }
383
+ }
384
+ return count;
385
+ }
386
+ invalidateByPattern(pattern) {
387
+ const regex = this.patternToRegex(pattern);
388
+ let count = 0;
389
+ for (const key of this.entities.keys()) {
390
+ if (regex.test(key)) {
391
+ this.markStale(key);
392
+ count++;
393
+ }
394
+ }
395
+ return count;
396
+ }
397
+ tagEntity(entityName, entityId, tags) {
398
+ const key = this.makeKey(entityName, entityId);
399
+ const entitySignal = this.entities.get(key);
400
+ if (entitySignal) {
401
+ entitySignal.value = {
402
+ ...entitySignal.value,
403
+ tags: [...new Set([...entitySignal.value.tags ?? [], ...tags])]
404
+ };
405
+ for (const tag of tags) {
406
+ if (!this.tagIndex.has(tag)) {
407
+ this.tagIndex.set(tag, new Set);
408
+ }
409
+ this.tagIndex.get(tag).add(key);
410
+ }
411
+ }
412
+ }
413
+ isStale(entityName, entityId) {
414
+ const key = this.makeKey(entityName, entityId);
415
+ const entitySignal = this.entities.get(key);
416
+ if (!entitySignal)
417
+ return true;
418
+ if (entitySignal.value.stale)
419
+ return true;
420
+ if (!entitySignal.value.cachedAt)
421
+ return false;
422
+ return Date.now() - entitySignal.value.cachedAt > this.config.cacheTTL;
423
+ }
424
+ getStaleWhileRevalidate(entityName, entityId, revalidate) {
425
+ const key = this.makeKey(entityName, entityId);
426
+ const entitySignal = this.entities.get(key);
427
+ const isStale = this.isStale(entityName, entityId);
428
+ let revalidating = null;
429
+ if (isStale && entitySignal?.value.data != null) {
430
+ revalidating = revalidate().then((newData) => {
431
+ this.setEntity(entityName, entityId, newData);
432
+ return newData;
433
+ });
434
+ }
435
+ return {
436
+ data: entitySignal?.value.data ?? null,
437
+ isStale,
438
+ revalidating
439
+ };
440
+ }
441
+ markStale(key) {
442
+ const entitySignal = this.entities.get(key);
443
+ if (entitySignal) {
444
+ entitySignal.value = { ...entitySignal.value, stale: true };
445
+ }
446
+ }
447
+ cascadeInvalidate(entityName, operation) {
448
+ for (const rule of this.config.cascadeRules) {
449
+ if (rule.source !== entityName)
450
+ continue;
451
+ if (rule.operations && !rule.operations.includes(operation))
452
+ continue;
453
+ for (const target of rule.targets) {
454
+ this.invalidateEntity(target, { cascade: false });
455
+ }
456
+ }
457
+ }
458
+ patternToRegex(pattern) {
459
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
460
+ return new RegExp(`^${escaped}$`);
461
+ }
462
+ retain(entityName, entityId) {
463
+ const key = this.makeKey(entityName, entityId);
464
+ const entitySignal = this.entities.get(key);
465
+ if (entitySignal) {
466
+ entitySignal.value = {
467
+ ...entitySignal.value,
468
+ refCount: entitySignal.value.refCount + 1
469
+ };
470
+ }
471
+ }
472
+ release(entityName, entityId) {
473
+ const key = this.makeKey(entityName, entityId);
474
+ const entitySignal = this.entities.get(key);
475
+ if (entitySignal) {
476
+ const newRefCount = Math.max(0, entitySignal.value.refCount - 1);
477
+ entitySignal.value = {
478
+ ...entitySignal.value,
479
+ refCount: newRefCount
480
+ };
481
+ if (newRefCount === 0) {
482
+ entitySignal.value = {
483
+ ...entitySignal.value,
484
+ stale: true
485
+ };
486
+ }
487
+ }
488
+ }
489
+ gc() {
490
+ let cleared = 0;
491
+ for (const [key, entitySignal] of this.entities) {
492
+ if (entitySignal.value.stale && entitySignal.value.refCount === 0) {
493
+ this.entities.delete(key);
494
+ cleared++;
495
+ }
496
+ }
497
+ return cleared;
498
+ }
499
+ clear() {
500
+ this.entities.clear();
501
+ this.lists.clear();
502
+ this.optimisticUpdates.clear();
503
+ }
504
+ makeKey(entityName, entityId) {
505
+ return makeEntityKey(entityName, entityId);
506
+ }
507
+ getStats() {
508
+ return {
509
+ entities: this.entities.size,
510
+ lists: this.lists.size,
511
+ pendingOptimistic: this.optimisticUpdates.size
512
+ };
513
+ }
514
+ }
515
+ function createStore(config) {
516
+ return new ReactiveStore(config);
517
+ }
518
+ export {
519
+ toPromise,
520
+ signal,
521
+ isSignal,
522
+ effect,
523
+ derive,
524
+ createStore,
525
+ computed,
526
+ batch,
527
+ ReactiveStore
528
+ };
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@sylphx/lens-signals",
3
+ "version": "1.0.0",
4
+ "description": "Signals-based reactive store for Lens client",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "bunup",
16
+ "typecheck": "tsc --noEmit",
17
+ "test": "echo 'no tests yet'",
18
+ "prepack": "[ -d dist ] || bun run build"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src"
23
+ ],
24
+ "keywords": [
25
+ "lens",
26
+ "signals",
27
+ "reactive",
28
+ "store",
29
+ "preact"
30
+ ],
31
+ "author": "SylphxAI",
32
+ "license": "MIT",
33
+ "dependencies": {
34
+ "@sylphx/lens-core": "^2.0.1",
35
+ "@sylphx/reify": "^0.1.2"
36
+ },
37
+ "peerDependencies": {
38
+ "@preact/signals-core": ">=1.8.0"
39
+ },
40
+ "devDependencies": {
41
+ "@preact/signals-core": "^1.8.0",
42
+ "typescript": "^5.9.3"
43
+ }
44
+ }
package/src/index.ts ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @sylphx/lens-signals
3
+ *
4
+ * Signals-based reactive store for Lens client.
5
+ * Uses Preact Signals for fine-grained reactivity.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { createStore, signal, computed, effect } from "@sylphx/lens-signals";
10
+ *
11
+ * // Create reactive store
12
+ * const store = createStore();
13
+ *
14
+ * // Get entity signal
15
+ * const user = store.getEntity<User>("User", "123");
16
+ *
17
+ * // React to changes
18
+ * effect(() => {
19
+ * console.log("User:", user.value.data);
20
+ * });
21
+ * ```
22
+ */
23
+
24
+ // =============================================================================
25
+ // Signals
26
+ // =============================================================================
27
+
28
+ export {
29
+ batch,
30
+ computed,
31
+ derive,
32
+ effect,
33
+ isSignal,
34
+ // Types
35
+ type Signal,
36
+ type Subscriber,
37
+ // Functions
38
+ signal,
39
+ toPromise,
40
+ type Unsubscribe,
41
+ type WritableSignal,
42
+ } from "./signal.js";
43
+
44
+ // =============================================================================
45
+ // Store
46
+ // =============================================================================
47
+
48
+ export {
49
+ type CascadeRule,
50
+ createStore,
51
+ type EntityKey,
52
+ type EntityState,
53
+ type InvalidationOptions,
54
+ type OptimisticEntry,
55
+ type OptimisticTransaction,
56
+ ReactiveStore,
57
+ type StoreConfig,
58
+ } from "./reactive-store.js";