@nerdalytics/beacon 1000.0.0 → 1000.1.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/README.md +29 -0
- package/package.json +10 -13
- package/src/index.ts +212 -6
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ A lightweight reactive state library for Node.js backends. Enables reactive stat
|
|
|
13
13
|
- [effect](#effectfn---void---void)
|
|
14
14
|
- [batch](#batchtfn---t-t)
|
|
15
15
|
- [select](#selectt-rsource-readonlystatet-selectorfn-state-t--r-equalityfn-a-r-b-r--boolean-readonlystater)
|
|
16
|
+
- [lens](#lenst-ksource-statet-accessor-state-t--k-statek)
|
|
16
17
|
- [readonlyState](#readonlystatetstate-statet-readonlystatet)
|
|
17
18
|
- [protectedState](#protectedstatetinitialvalue-t-readonlystatet-writeablestatet)
|
|
18
19
|
- [Development](#development)
|
|
@@ -94,6 +95,29 @@ user.update(u => ({ ...u, name: "Bob" }));
|
|
|
94
95
|
// Updates to other properties won't trigger the effect
|
|
95
96
|
user.update(u => ({ ...u, age: 31 })); // No effect triggered
|
|
96
97
|
|
|
98
|
+
// Using lens for two-way binding with nested properties
|
|
99
|
+
const nested = state({
|
|
100
|
+
user: {
|
|
101
|
+
profile: {
|
|
102
|
+
settings: {
|
|
103
|
+
theme: "dark",
|
|
104
|
+
notifications: true
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Create a lens focused on a deeply nested property
|
|
111
|
+
const themeLens = lens(nested, n => n.user.profile.settings.theme);
|
|
112
|
+
|
|
113
|
+
// Read the focused value
|
|
114
|
+
console.log(themeLens()); // => "dark"
|
|
115
|
+
|
|
116
|
+
// Update the focused value directly (maintains referential integrity)
|
|
117
|
+
themeLens.set("light");
|
|
118
|
+
console.log(themeLens()); // => "light"
|
|
119
|
+
console.log(nested().user.profile.settings.theme); // => "light"
|
|
120
|
+
|
|
97
121
|
// Unsubscribe the effect to stop it from running on future updates
|
|
98
122
|
// and clean up all its internal subscriptions
|
|
99
123
|
unsubscribe();
|
|
@@ -148,6 +172,10 @@ Batches multiple updates to only trigger effects once at the end.
|
|
|
148
172
|
|
|
149
173
|
Creates an efficient subscription to a subset of a state value. The selector will only notify its subscribers when the selected value actually changes according to the provided equality function (defaults to `Object.is`).
|
|
150
174
|
|
|
175
|
+
### `lens<T, K>(source: State<T>, accessor: (state: T) => K): State<K>`
|
|
176
|
+
|
|
177
|
+
Creates a lens for direct updates to nested properties of a state. A lens combines the functionality of `select` (for reading) with the ability to update the nested property while maintaining referential integrity throughout the object tree.
|
|
178
|
+
|
|
151
179
|
### `readonlyState<T>(state: State<T>): ReadOnlyState<T>`
|
|
152
180
|
|
|
153
181
|
Creates a read-only view of a state, hiding mutation methods. Useful when you want to expose a state to other parts of your application without allowing direct mutations.
|
|
@@ -175,6 +203,7 @@ npm run test:unit:effect
|
|
|
175
203
|
npm run test:unit:derive
|
|
176
204
|
npm run test:unit:batch
|
|
177
205
|
npm run test:unit:select
|
|
206
|
+
npm run test:unit:lens
|
|
178
207
|
npm run test:unit:readonly
|
|
179
208
|
npm run test:unit:protected
|
|
180
209
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nerdalytics/beacon",
|
|
3
|
-
"version": "1000.
|
|
3
|
+
"version": "1000.1.0",
|
|
4
4
|
"description": "A lightweight reactive state library for Node.js backends. Enables reactive state management with automatic dependency tracking and efficient updates for server-side applications.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -8,21 +8,20 @@
|
|
|
8
8
|
"files": [
|
|
9
9
|
"dist/index.js",
|
|
10
10
|
"dist/index.d.ts",
|
|
11
|
-
"src/",
|
|
11
|
+
"src/index.ts",
|
|
12
12
|
"LICENSE"
|
|
13
13
|
],
|
|
14
14
|
"repository": {
|
|
15
|
-
"url": "github
|
|
15
|
+
"url": "git+https://github.com/nerdalytics/beacon.git",
|
|
16
16
|
"type": "git"
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
19
|
"lint": "npx @biomejs/biome lint --config-path=./biome.json",
|
|
20
20
|
"lint:fix": "npx @biomejs/biome lint --fix --config-path=./biome.json",
|
|
21
21
|
"lint:fix:unsafe": "npx @biomejs/biome lint --fix --unsafe --config-path=./biome.json",
|
|
22
|
-
"format": "npx @biomejs/biome format
|
|
23
|
-
"check": "npx @biomejs/biome check
|
|
24
|
-
"check:fix": "npx @biomejs/biome format
|
|
25
|
-
|
|
22
|
+
"format": "npx @biomejs/biome format --write --config-path=./biome.json",
|
|
23
|
+
"check": "npx @biomejs/biome check --config-path=./biome.json",
|
|
24
|
+
"check:fix": "npx @biomejs/biome format --fix --config-path=./biome.json",
|
|
26
25
|
"test": "node --test --test-skip-pattern=\"COMPONENT NAME\" tests/**/*.ts",
|
|
27
26
|
"test:coverage": "node --test --experimental-config-file=node.config.json --test-skip-pattern=\"[COMPONENT NAME]\" --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info tests/**/*.ts",
|
|
28
27
|
"test:unit:state": "node --test tests/state.test.ts",
|
|
@@ -30,21 +29,19 @@
|
|
|
30
29
|
"test:unit:batch": "node --test tests/batch.test.ts",
|
|
31
30
|
"test:unit:derive": "node --test tests/derive.test.ts",
|
|
32
31
|
"test:unit:select": "node --test tests/select.test.ts",
|
|
32
|
+
"test:unit:lens": "node --test tests/lens.test.ts",
|
|
33
33
|
"test:unit:cleanup": "node --test tests/cleanup.test.ts",
|
|
34
34
|
"test:unit:cyclic-dependency": "node --test tests/cyclic-dependency.test.ts",
|
|
35
35
|
"test:unit:deep-chain": "node --test tests/deep-chain.test.ts",
|
|
36
36
|
"test:unit:infinite-loop": "node --test tests/infinite-loop.test.ts",
|
|
37
|
-
|
|
38
37
|
"benchmark": "node scripts/benchmark.ts",
|
|
39
|
-
|
|
40
38
|
"build": "npm run build:lts",
|
|
41
39
|
"prebuild:lts": "rm -rf dist/",
|
|
42
40
|
"build:lts": "tsc -p tsconfig.lts.json",
|
|
43
41
|
"prepublishOnly": "npm run build:lts",
|
|
44
|
-
|
|
45
|
-
"
|
|
46
|
-
"test:lts": "node
|
|
47
|
-
|
|
42
|
+
"pretest:lts": "node scripts/run-lts-tests.js",
|
|
43
|
+
"test:lts:20": "node --test dist/tests/**.js",
|
|
44
|
+
"test:lts:22": "node --test --test-skip-pattern=\"COMPONENT NAME\" dist/tests/**/*.js",
|
|
48
45
|
"update-performance-docs": "node --experimental-config-file=node.config.json scripts/update-performance-docs.ts"
|
|
49
46
|
},
|
|
50
47
|
"keywords": [
|
package/src/index.ts
CHANGED
|
@@ -66,6 +66,12 @@ export const protectedState = <T>(initialValue: T): [ReadOnlyState<T>, Writeable
|
|
|
66
66
|
]
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Creates a lens for direct updates to nested properties of a state.
|
|
71
|
+
*/
|
|
72
|
+
export const lens = <T, K>(source: State<T>, accessor: (state: T) => K): State<K> =>
|
|
73
|
+
StateImpl.createLens(source, accessor)
|
|
74
|
+
|
|
69
75
|
class StateImpl<T> {
|
|
70
76
|
// Static fields track global reactivity state - this centralized approach allows
|
|
71
77
|
// for coordinated updates while maintaining individual state isolation
|
|
@@ -92,8 +98,10 @@ class StateImpl<T> {
|
|
|
92
98
|
this.value = initialValue
|
|
93
99
|
}
|
|
94
100
|
|
|
95
|
-
|
|
96
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Creates a reactive state container with the provided initial value.
|
|
103
|
+
* Implementation of the public 'state' function.
|
|
104
|
+
*/
|
|
97
105
|
static createState = <T>(initialValue: T): State<T> => {
|
|
98
106
|
const instance = new StateImpl<T>(initialValue)
|
|
99
107
|
const get = (): T => instance.get()
|
|
@@ -172,7 +180,10 @@ class StateImpl<T> {
|
|
|
172
180
|
this.set(fn(this.value))
|
|
173
181
|
}
|
|
174
182
|
|
|
175
|
-
|
|
183
|
+
/**
|
|
184
|
+
* Registers a function to run whenever its reactive dependencies change.
|
|
185
|
+
* Implementation of the public 'effect' function.
|
|
186
|
+
*/
|
|
176
187
|
static createEffect = (fn: () => void): Unsubscribe => {
|
|
177
188
|
const runEffect = (): void => {
|
|
178
189
|
// Prevent re-entrance to avoid cascade updates during effect execution
|
|
@@ -265,7 +276,10 @@ class StateImpl<T> {
|
|
|
265
276
|
}
|
|
266
277
|
}
|
|
267
278
|
|
|
268
|
-
|
|
279
|
+
/**
|
|
280
|
+
* Groups multiple state updates to trigger effects only once at the end.
|
|
281
|
+
* Implementation of the public 'batch' function.
|
|
282
|
+
*/
|
|
269
283
|
static executeBatch = <T>(fn: () => T): T => {
|
|
270
284
|
// Increment depth counter to handle nested batches correctly
|
|
271
285
|
StateImpl.batchDepth++
|
|
@@ -302,7 +316,10 @@ class StateImpl<T> {
|
|
|
302
316
|
}
|
|
303
317
|
}
|
|
304
318
|
|
|
305
|
-
|
|
319
|
+
/**
|
|
320
|
+
* Creates a read-only computed value that updates when its dependencies change.
|
|
321
|
+
* Implementation of the public 'derive' function.
|
|
322
|
+
*/
|
|
306
323
|
static createDerive = <T>(computeFn: () => T): ReadOnlyState<T> => {
|
|
307
324
|
const valueState = StateImpl.createState<T | undefined>(undefined)
|
|
308
325
|
let initialized = false
|
|
@@ -334,7 +351,10 @@ class StateImpl<T> {
|
|
|
334
351
|
}
|
|
335
352
|
}
|
|
336
353
|
|
|
337
|
-
|
|
354
|
+
/**
|
|
355
|
+
* Creates an efficient subscription to a subset of a state value.
|
|
356
|
+
* Implementation of the public 'select' function.
|
|
357
|
+
*/
|
|
338
358
|
static createSelect = <T, R>(
|
|
339
359
|
source: ReadOnlyState<T>,
|
|
340
360
|
selectorFn: (state: T) => R,
|
|
@@ -381,6 +401,85 @@ class StateImpl<T> {
|
|
|
381
401
|
}
|
|
382
402
|
}
|
|
383
403
|
|
|
404
|
+
/**
|
|
405
|
+
* Creates a lens for direct updates to nested properties of a state.
|
|
406
|
+
* Implementation of the public 'lens' function.
|
|
407
|
+
*/
|
|
408
|
+
static createLens = <T, K>(source: State<T>, accessor: (state: T) => K): State<K> => {
|
|
409
|
+
// Extract the property path once during lens creation
|
|
410
|
+
const extractPath = (): (string | number)[] => {
|
|
411
|
+
const path: (string | number)[] = []
|
|
412
|
+
const proxy = new Proxy(
|
|
413
|
+
{},
|
|
414
|
+
{
|
|
415
|
+
get: (_: object, prop: string | symbol): unknown => {
|
|
416
|
+
if (typeof prop === 'string' || typeof prop === 'number') {
|
|
417
|
+
path.push(prop)
|
|
418
|
+
}
|
|
419
|
+
return proxy
|
|
420
|
+
},
|
|
421
|
+
}
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
try {
|
|
425
|
+
accessor(proxy as unknown as T)
|
|
426
|
+
} catch {
|
|
427
|
+
// Ignore errors, we're just collecting the path
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return path
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Capture the path once
|
|
434
|
+
const path = extractPath()
|
|
435
|
+
|
|
436
|
+
// Create a state with the initial value from the source
|
|
437
|
+
const lensState = StateImpl.createState<K>(accessor(source()))
|
|
438
|
+
|
|
439
|
+
// Prevent circular updates
|
|
440
|
+
let isUpdating = false
|
|
441
|
+
|
|
442
|
+
// Set up an effect to sync from source to lens
|
|
443
|
+
StateImpl.createEffect((): void => {
|
|
444
|
+
if (isUpdating) {
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
isUpdating = true
|
|
449
|
+
try {
|
|
450
|
+
lensState.set(accessor(source()))
|
|
451
|
+
} finally {
|
|
452
|
+
isUpdating = false
|
|
453
|
+
}
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
// Override the lens state's set method to update the source
|
|
457
|
+
const originalSet = lensState.set
|
|
458
|
+
lensState.set = (value: K): void => {
|
|
459
|
+
if (isUpdating) {
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
isUpdating = true
|
|
464
|
+
try {
|
|
465
|
+
// Update lens state
|
|
466
|
+
originalSet(value)
|
|
467
|
+
|
|
468
|
+
// Update source by modifying the value at path
|
|
469
|
+
source.update((current: T): T => setValueAtPath(current, path, value))
|
|
470
|
+
} finally {
|
|
471
|
+
isUpdating = false
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Add update method for completeness
|
|
476
|
+
lensState.update = (fn: (value: K) => K): void => {
|
|
477
|
+
lensState.set(fn(lensState()))
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return lensState
|
|
481
|
+
}
|
|
482
|
+
|
|
384
483
|
// Processes queued subscriber notifications in a controlled, non-reentrant way
|
|
385
484
|
private static notifySubscribers = (): void => {
|
|
386
485
|
// Prevent reentrance to avoid cascading notification loops when
|
|
@@ -425,3 +524,110 @@ class StateImpl<T> {
|
|
|
425
524
|
}
|
|
426
525
|
}
|
|
427
526
|
}
|
|
527
|
+
// Helper for array updates
|
|
528
|
+
const updateArrayItem = <V>(arr: unknown[], index: number, value: V): unknown[] => {
|
|
529
|
+
const copy = [...arr]
|
|
530
|
+
copy[index] = value
|
|
531
|
+
return copy
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Helper for single-level updates (optimization)
|
|
535
|
+
const updateShallowProperty = <V>(
|
|
536
|
+
obj: Record<string | number, unknown>,
|
|
537
|
+
key: string | number,
|
|
538
|
+
value: V
|
|
539
|
+
): Record<string | number, unknown> => {
|
|
540
|
+
const result = { ...obj }
|
|
541
|
+
result[key] = value
|
|
542
|
+
return result
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Helper to create the appropriate container type
|
|
546
|
+
const createContainer = (key: string | number): Record<string | number, unknown> | unknown[] => {
|
|
547
|
+
const isArrayKey = typeof key === 'number' || !Number.isNaN(Number(key))
|
|
548
|
+
return isArrayKey ? [] : {}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Helper for handling array path updates
|
|
552
|
+
const updateArrayPath = <V>(array: unknown[], pathSegments: (string | number)[], value: V): unknown[] => {
|
|
553
|
+
const index = Number(pathSegments[0])
|
|
554
|
+
|
|
555
|
+
if (pathSegments.length === 1) {
|
|
556
|
+
// Simple array item update
|
|
557
|
+
return updateArrayItem(array, index, value)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Nested path in array
|
|
561
|
+
const copy = [...array]
|
|
562
|
+
const nextPathSegments = pathSegments.slice(1)
|
|
563
|
+
const nextKey = nextPathSegments[0]
|
|
564
|
+
|
|
565
|
+
// For null/undefined values in arrays, create appropriate containers
|
|
566
|
+
let nextValue = array[index]
|
|
567
|
+
if (nextValue === undefined || nextValue === null) {
|
|
568
|
+
// Use empty object as default if nextKey is undefined
|
|
569
|
+
nextValue = nextKey !== undefined ? createContainer(nextKey) : {}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
copy[index] = setValueAtPath(nextValue, nextPathSegments, value)
|
|
573
|
+
return copy
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Helper for handling object path updates
|
|
577
|
+
const updateObjectPath = <V>(
|
|
578
|
+
obj: Record<string | number, unknown>,
|
|
579
|
+
pathSegments: (string | number)[],
|
|
580
|
+
value: V
|
|
581
|
+
): Record<string | number, unknown> => {
|
|
582
|
+
// Ensure we have a valid key
|
|
583
|
+
const currentKey = pathSegments[0]
|
|
584
|
+
if (currentKey === undefined) {
|
|
585
|
+
// This shouldn't happen given our checks in the main function
|
|
586
|
+
return obj
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (pathSegments.length === 1) {
|
|
590
|
+
// Simple object property update
|
|
591
|
+
return updateShallowProperty(obj, currentKey, value)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Nested path in object
|
|
595
|
+
const nextPathSegments = pathSegments.slice(1)
|
|
596
|
+
const nextKey = nextPathSegments[0]
|
|
597
|
+
|
|
598
|
+
// For null/undefined values, create appropriate containers
|
|
599
|
+
let currentValue = obj[currentKey]
|
|
600
|
+
if (currentValue === undefined || currentValue === null) {
|
|
601
|
+
// Use empty object as default if nextKey is undefined
|
|
602
|
+
currentValue = nextKey !== undefined ? createContainer(nextKey) : {}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Create new object with updated property
|
|
606
|
+
const result = { ...obj }
|
|
607
|
+
result[currentKey] = setValueAtPath(currentValue, nextPathSegments, value)
|
|
608
|
+
return result
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Simplified function to update a nested value at a path
|
|
612
|
+
const setValueAtPath = <V, O>(obj: O, pathSegments: (string | number)[], value: V): O => {
|
|
613
|
+
// Handle base cases
|
|
614
|
+
if (pathSegments.length === 0) {
|
|
615
|
+
return value as unknown as O
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (obj === undefined || obj === null) {
|
|
619
|
+
return setValueAtPath({} as O, pathSegments, value)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const currentKey = pathSegments[0]
|
|
623
|
+
if (currentKey === undefined) {
|
|
624
|
+
return obj
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Delegate to specialized handlers based on data type
|
|
628
|
+
if (Array.isArray(obj)) {
|
|
629
|
+
return updateArrayPath(obj, pathSegments, value) as unknown as O
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return updateObjectPath(obj as Record<string | number, unknown>, pathSegments, value) as unknown as O
|
|
633
|
+
}
|