@nerdalytics/beacon 1000.0.0 → 1000.1.1

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 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
 
@@ -0,0 +1,43 @@
1
+ type Unsubscribe = () => void;
2
+ export type ReadOnlyState<T> = () => T;
3
+ export interface WriteableState<T> {
4
+ set(value: T): void;
5
+ update(fn: (value: T) => T): void;
6
+ }
7
+ declare const STATE_ID: unique symbol;
8
+ export type State<T> = ReadOnlyState<T> & WriteableState<T> & {
9
+ [STATE_ID]?: symbol;
10
+ };
11
+ /**
12
+ * Creates a reactive state container with the provided initial value.
13
+ */
14
+ export declare const state: <T>(initialValue: T) => State<T>;
15
+ /**
16
+ * Registers a function to run whenever its reactive dependencies change.
17
+ */
18
+ export declare const effect: (fn: () => void) => Unsubscribe;
19
+ /**
20
+ * Groups multiple state updates to trigger effects only once at the end.
21
+ */
22
+ export declare const batch: <T>(fn: () => T) => T;
23
+ /**
24
+ * Creates a read-only computed value that updates when its dependencies change.
25
+ */
26
+ export declare const derive: <T>(computeFn: () => T) => ReadOnlyState<T>;
27
+ /**
28
+ * Creates an efficient subscription to a subset of a state value.
29
+ */
30
+ export declare const select: <T, R>(source: ReadOnlyState<T>, selectorFn: (state: T) => R, equalityFn?: (a: R, b: R) => boolean) => ReadOnlyState<R>;
31
+ /**
32
+ * Creates a read-only view of a state, hiding mutation methods.
33
+ */
34
+ export declare const readonlyState: <T>(state: State<T>) => ReadOnlyState<T>;
35
+ /**
36
+ * Creates a state with access control, returning a tuple of reader and writer.
37
+ */
38
+ export declare const protectedState: <T>(initialValue: T) => [ReadOnlyState<T>, WriteableState<T>];
39
+ /**
40
+ * Creates a lens for direct updates to nested properties of a state.
41
+ */
42
+ export declare const lens: <T, K>(source: State<T>, accessor: (state: T) => K) => State<K>;
43
+ export {};
@@ -0,0 +1,520 @@
1
+ // Special symbol used for internal tracking
2
+ const STATE_ID = Symbol();
3
+ /**
4
+ * Creates a reactive state container with the provided initial value.
5
+ */
6
+ export const state = (initialValue) => StateImpl.createState(initialValue);
7
+ /**
8
+ * Registers a function to run whenever its reactive dependencies change.
9
+ */
10
+ export const effect = (fn) => StateImpl.createEffect(fn);
11
+ /**
12
+ * Groups multiple state updates to trigger effects only once at the end.
13
+ */
14
+ export const batch = (fn) => StateImpl.executeBatch(fn);
15
+ /**
16
+ * Creates a read-only computed value that updates when its dependencies change.
17
+ */
18
+ export const derive = (computeFn) => StateImpl.createDerive(computeFn);
19
+ /**
20
+ * Creates an efficient subscription to a subset of a state value.
21
+ */
22
+ export const select = (source, selectorFn, equalityFn = Object.is) => StateImpl.createSelect(source, selectorFn, equalityFn);
23
+ /**
24
+ * Creates a read-only view of a state, hiding mutation methods.
25
+ */
26
+ export const readonlyState = (state) => () => state();
27
+ /**
28
+ * Creates a state with access control, returning a tuple of reader and writer.
29
+ */
30
+ export const protectedState = (initialValue) => {
31
+ const fullState = state(initialValue);
32
+ return [
33
+ () => readonlyState(fullState)(),
34
+ {
35
+ set: (value) => fullState.set(value),
36
+ update: (fn) => fullState.update(fn),
37
+ },
38
+ ];
39
+ };
40
+ /**
41
+ * Creates a lens for direct updates to nested properties of a state.
42
+ */
43
+ export const lens = (source, accessor) => StateImpl.createLens(source, accessor);
44
+ class StateImpl {
45
+ // Static fields track global reactivity state - this centralized approach allows
46
+ // for coordinated updates while maintaining individual state isolation
47
+ static currentSubscriber = null;
48
+ static pendingSubscribers = new Set();
49
+ static isNotifying = false;
50
+ static batchDepth = 0;
51
+ static deferredEffectCreations = [];
52
+ static activeSubscribers = new Set();
53
+ // WeakMaps enable automatic garbage collection when subscribers are no
54
+ // longer referenced, preventing memory leaks in long-running applications
55
+ static stateTracking = new WeakMap();
56
+ static subscriberDependencies = new WeakMap();
57
+ static parentSubscriber = new WeakMap();
58
+ static childSubscribers = new WeakMap();
59
+ // Instance state - each state has unique subscribers and ID
60
+ value;
61
+ subscribers = new Set();
62
+ stateId = Symbol();
63
+ constructor(initialValue) {
64
+ this.value = initialValue;
65
+ }
66
+ /**
67
+ * Creates a reactive state container with the provided initial value.
68
+ * Implementation of the public 'state' function.
69
+ */
70
+ static createState = (initialValue) => {
71
+ const instance = new StateImpl(initialValue);
72
+ const get = () => instance.get();
73
+ get.set = (value) => instance.set(value);
74
+ get.update = (fn) => instance.update(fn);
75
+ get[STATE_ID] = instance.stateId;
76
+ return get;
77
+ };
78
+ // Auto-tracks dependencies when called within effects, creating a fine-grained
79
+ // reactivity graph that only updates affected components
80
+ get = () => {
81
+ const currentEffect = StateImpl.currentSubscriber;
82
+ if (currentEffect) {
83
+ // Add this effect to subscribers for future notification
84
+ this.subscribers.add(currentEffect);
85
+ // Maintain bidirectional dependency tracking to enable precise cleanup
86
+ // when effects are unsubscribed, preventing memory leaks
87
+ let dependencies = StateImpl.subscriberDependencies.get(currentEffect);
88
+ if (!dependencies) {
89
+ dependencies = new Set();
90
+ StateImpl.subscriberDependencies.set(currentEffect, dependencies);
91
+ }
92
+ dependencies.add(this.subscribers);
93
+ // Track read states to detect direct cyclical dependencies that
94
+ // could cause infinite loops
95
+ let readStates = StateImpl.stateTracking.get(currentEffect);
96
+ if (!readStates) {
97
+ readStates = new Set();
98
+ StateImpl.stateTracking.set(currentEffect, readStates);
99
+ }
100
+ readStates.add(this.stateId);
101
+ }
102
+ return this.value;
103
+ };
104
+ // Handles value updates with built-in optimizations and safeguards
105
+ set = (newValue) => {
106
+ // Skip updates for unchanged values to prevent redundant effect executions
107
+ if (Object.is(this.value, newValue)) {
108
+ return;
109
+ }
110
+ // Infinite loop detection prevents direct self-mutation within effects,
111
+ // while allowing nested effect patterns that would otherwise appear cyclical
112
+ const effect = StateImpl.currentSubscriber;
113
+ if (effect) {
114
+ const states = StateImpl.stateTracking.get(effect);
115
+ if (states?.has(this.stateId) && !StateImpl.parentSubscriber.get(effect)) {
116
+ throw new Error('Infinite loop detected: effect() cannot update a state() it depends on!');
117
+ }
118
+ }
119
+ this.value = newValue;
120
+ // Skip updates when there are no subscribers, avoiding unnecessary processing
121
+ if (this.subscribers.size === 0) {
122
+ return;
123
+ }
124
+ // Queue notifications instead of executing immediately to support batch operations
125
+ // and prevent redundant effect runs
126
+ for (const sub of this.subscribers) {
127
+ StateImpl.pendingSubscribers.add(sub);
128
+ }
129
+ // Immediate execution outside of batches, deferred execution inside batches
130
+ if (StateImpl.batchDepth === 0 && !StateImpl.isNotifying) {
131
+ StateImpl.notifySubscribers();
132
+ }
133
+ };
134
+ update = (fn) => {
135
+ this.set(fn(this.value));
136
+ };
137
+ /**
138
+ * Registers a function to run whenever its reactive dependencies change.
139
+ * Implementation of the public 'effect' function.
140
+ */
141
+ static createEffect = (fn) => {
142
+ const runEffect = () => {
143
+ // Prevent re-entrance to avoid cascade updates during effect execution
144
+ if (StateImpl.activeSubscribers.has(runEffect)) {
145
+ return;
146
+ }
147
+ StateImpl.activeSubscribers.add(runEffect);
148
+ const parentEffect = StateImpl.currentSubscriber;
149
+ try {
150
+ // Clean existing subscriptions before running to ensure only
151
+ // currently accessed states are tracked as dependencies
152
+ StateImpl.cleanupEffect(runEffect);
153
+ // Set current context for automatic dependency tracking
154
+ StateImpl.currentSubscriber = runEffect;
155
+ StateImpl.stateTracking.set(runEffect, new Set());
156
+ // Track parent-child relationships to handle nested effects correctly
157
+ // and enable hierarchical cleanup later
158
+ if (parentEffect) {
159
+ StateImpl.parentSubscriber.set(runEffect, parentEffect);
160
+ let children = StateImpl.childSubscribers.get(parentEffect);
161
+ if (!children) {
162
+ children = new Set();
163
+ StateImpl.childSubscribers.set(parentEffect, children);
164
+ }
165
+ children.add(runEffect);
166
+ }
167
+ // Execute the effect function, which will auto-track dependencies
168
+ fn();
169
+ }
170
+ finally {
171
+ // Restore previous context when done
172
+ StateImpl.currentSubscriber = parentEffect;
173
+ StateImpl.activeSubscribers.delete(runEffect);
174
+ }
175
+ };
176
+ // Run immediately unless we're in a batch operation
177
+ if (StateImpl.batchDepth === 0) {
178
+ runEffect();
179
+ }
180
+ else {
181
+ // Still track parent-child relationship even when deferred,
182
+ // ensuring proper hierarchical cleanup later
183
+ if (StateImpl.currentSubscriber) {
184
+ const parent = StateImpl.currentSubscriber;
185
+ StateImpl.parentSubscriber.set(runEffect, parent);
186
+ let children = StateImpl.childSubscribers.get(parent);
187
+ if (!children) {
188
+ children = new Set();
189
+ StateImpl.childSubscribers.set(parent, children);
190
+ }
191
+ children.add(runEffect);
192
+ }
193
+ // Queue for execution when batch completes
194
+ StateImpl.deferredEffectCreations.push(runEffect);
195
+ }
196
+ // Return cleanup function to properly disconnect from reactivity graph
197
+ return () => {
198
+ // Remove from dependency tracking to stop future notifications
199
+ StateImpl.cleanupEffect(runEffect);
200
+ StateImpl.pendingSubscribers.delete(runEffect);
201
+ StateImpl.activeSubscribers.delete(runEffect);
202
+ StateImpl.stateTracking.delete(runEffect);
203
+ // Clean up parent-child relationship bidirectionally
204
+ const parent = StateImpl.parentSubscriber.get(runEffect);
205
+ if (parent) {
206
+ const siblings = StateImpl.childSubscribers.get(parent);
207
+ if (siblings) {
208
+ siblings.delete(runEffect);
209
+ }
210
+ }
211
+ StateImpl.parentSubscriber.delete(runEffect);
212
+ // Recursively clean up child effects to prevent memory leaks in
213
+ // nested effect scenarios
214
+ const children = StateImpl.childSubscribers.get(runEffect);
215
+ if (children) {
216
+ for (const child of children) {
217
+ StateImpl.cleanupEffect(child);
218
+ }
219
+ children.clear();
220
+ StateImpl.childSubscribers.delete(runEffect);
221
+ }
222
+ };
223
+ };
224
+ /**
225
+ * Groups multiple state updates to trigger effects only once at the end.
226
+ * Implementation of the public 'batch' function.
227
+ */
228
+ static executeBatch = (fn) => {
229
+ // Increment depth counter to handle nested batches correctly
230
+ StateImpl.batchDepth++;
231
+ try {
232
+ return fn();
233
+ }
234
+ catch (error) {
235
+ // Clean up on error to prevent stale subscribers from executing
236
+ // and potentially causing cascading errors
237
+ if (StateImpl.batchDepth === 1) {
238
+ StateImpl.pendingSubscribers.clear();
239
+ StateImpl.deferredEffectCreations.length = 0;
240
+ }
241
+ throw error;
242
+ }
243
+ finally {
244
+ StateImpl.batchDepth--;
245
+ // Only process effects when exiting the outermost batch,
246
+ // maintaining proper execution order while avoiding redundant runs
247
+ if (StateImpl.batchDepth === 0) {
248
+ // Process effects created during the batch
249
+ if (StateImpl.deferredEffectCreations.length > 0) {
250
+ const effectsToRun = [...StateImpl.deferredEffectCreations];
251
+ StateImpl.deferredEffectCreations.length = 0;
252
+ for (const effect of effectsToRun) {
253
+ effect();
254
+ }
255
+ }
256
+ // Process state updates that occurred during the batch
257
+ if (StateImpl.pendingSubscribers.size > 0 && !StateImpl.isNotifying) {
258
+ StateImpl.notifySubscribers();
259
+ }
260
+ }
261
+ }
262
+ };
263
+ /**
264
+ * Creates a read-only computed value that updates when its dependencies change.
265
+ * Implementation of the public 'derive' function.
266
+ */
267
+ static createDerive = (computeFn) => {
268
+ const valueState = StateImpl.createState(undefined);
269
+ let initialized = false;
270
+ let cachedValue;
271
+ // Internal effect automatically tracks dependencies and updates the derived value
272
+ StateImpl.createEffect(() => {
273
+ const newValue = computeFn();
274
+ // Only update if the value actually changed to preserve referential equality
275
+ // and prevent unnecessary downstream updates
276
+ if (!(initialized && Object.is(cachedValue, newValue))) {
277
+ cachedValue = newValue;
278
+ valueState.set(newValue);
279
+ }
280
+ initialized = true;
281
+ });
282
+ // Return function with lazy initialization - ensures value is available
283
+ // even when accessed before its dependencies have had a chance to update
284
+ return () => {
285
+ if (!initialized) {
286
+ cachedValue = computeFn();
287
+ initialized = true;
288
+ valueState.set(cachedValue);
289
+ }
290
+ return valueState();
291
+ };
292
+ };
293
+ /**
294
+ * Creates an efficient subscription to a subset of a state value.
295
+ * Implementation of the public 'select' function.
296
+ */
297
+ static createSelect = (source, selectorFn, equalityFn = Object.is) => {
298
+ let lastSourceValue;
299
+ let lastSelectedValue;
300
+ let initialized = false;
301
+ const valueState = StateImpl.createState(undefined);
302
+ // Internal effect to track the source and update only when needed
303
+ StateImpl.createEffect(() => {
304
+ const sourceValue = source();
305
+ // Skip computation if source reference hasn't changed
306
+ if (initialized && Object.is(lastSourceValue, sourceValue)) {
307
+ return;
308
+ }
309
+ lastSourceValue = sourceValue;
310
+ const newSelectedValue = selectorFn(sourceValue);
311
+ // Use custom equality function to determine if value semantically changed,
312
+ // allowing for deep equality comparisons with complex objects
313
+ if (initialized && lastSelectedValue !== undefined && equalityFn(lastSelectedValue, newSelectedValue)) {
314
+ return;
315
+ }
316
+ // Update cache and notify subscribers due the value has changed
317
+ lastSelectedValue = newSelectedValue;
318
+ valueState.set(newSelectedValue);
319
+ initialized = true;
320
+ });
321
+ // Return function with eager initialization capability
322
+ return () => {
323
+ if (!initialized) {
324
+ lastSourceValue = source();
325
+ lastSelectedValue = selectorFn(lastSourceValue);
326
+ valueState.set(lastSelectedValue);
327
+ initialized = true;
328
+ }
329
+ return valueState();
330
+ };
331
+ };
332
+ /**
333
+ * Creates a lens for direct updates to nested properties of a state.
334
+ * Implementation of the public 'lens' function.
335
+ */
336
+ static createLens = (source, accessor) => {
337
+ // Extract the property path once during lens creation
338
+ const extractPath = () => {
339
+ const path = [];
340
+ const proxy = new Proxy({}, {
341
+ get: (_, prop) => {
342
+ if (typeof prop === 'string' || typeof prop === 'number') {
343
+ path.push(prop);
344
+ }
345
+ return proxy;
346
+ },
347
+ });
348
+ try {
349
+ accessor(proxy);
350
+ }
351
+ catch {
352
+ // Ignore errors, we're just collecting the path
353
+ }
354
+ return path;
355
+ };
356
+ // Capture the path once
357
+ const path = extractPath();
358
+ // Create a state with the initial value from the source
359
+ const lensState = StateImpl.createState(accessor(source()));
360
+ // Prevent circular updates
361
+ let isUpdating = false;
362
+ // Set up an effect to sync from source to lens
363
+ StateImpl.createEffect(() => {
364
+ if (isUpdating) {
365
+ return;
366
+ }
367
+ isUpdating = true;
368
+ try {
369
+ lensState.set(accessor(source()));
370
+ }
371
+ finally {
372
+ isUpdating = false;
373
+ }
374
+ });
375
+ // Override the lens state's set method to update the source
376
+ const originalSet = lensState.set;
377
+ lensState.set = (value) => {
378
+ if (isUpdating) {
379
+ return;
380
+ }
381
+ isUpdating = true;
382
+ try {
383
+ // Update lens state
384
+ originalSet(value);
385
+ // Update source by modifying the value at path
386
+ source.update((current) => setValueAtPath(current, path, value));
387
+ }
388
+ finally {
389
+ isUpdating = false;
390
+ }
391
+ };
392
+ // Add update method for completeness
393
+ lensState.update = (fn) => {
394
+ lensState.set(fn(lensState()));
395
+ };
396
+ return lensState;
397
+ };
398
+ // Processes queued subscriber notifications in a controlled, non-reentrant way
399
+ static notifySubscribers = () => {
400
+ // Prevent reentrance to avoid cascading notification loops when
401
+ // effects trigger further state changes
402
+ if (StateImpl.isNotifying) {
403
+ return;
404
+ }
405
+ StateImpl.isNotifying = true;
406
+ try {
407
+ // Process all pending effects in batches for better perf,
408
+ // ensuring topological execution order is maintained
409
+ while (StateImpl.pendingSubscribers.size > 0) {
410
+ // Process in snapshot batches to prevent infinite loops
411
+ // when effects trigger further state changes
412
+ const subscribers = Array.from(StateImpl.pendingSubscribers);
413
+ StateImpl.pendingSubscribers.clear();
414
+ for (const effect of subscribers) {
415
+ effect();
416
+ }
417
+ }
418
+ }
419
+ finally {
420
+ StateImpl.isNotifying = false;
421
+ }
422
+ };
423
+ // Removes effect from dependency tracking to prevent memory leaks
424
+ static cleanupEffect = (effect) => {
425
+ // Remove from execution queue to prevent stale updates
426
+ StateImpl.pendingSubscribers.delete(effect);
427
+ // Remove bidirectional dependency references to prevent memory leaks
428
+ const deps = StateImpl.subscriberDependencies.get(effect);
429
+ if (deps) {
430
+ for (const subscribers of deps) {
431
+ subscribers.delete(effect);
432
+ }
433
+ deps.clear();
434
+ StateImpl.subscriberDependencies.delete(effect);
435
+ }
436
+ };
437
+ }
438
+ // Helper for array updates
439
+ const updateArrayItem = (arr, index, value) => {
440
+ const copy = [...arr];
441
+ copy[index] = value;
442
+ return copy;
443
+ };
444
+ // Helper for single-level updates (optimization)
445
+ const updateShallowProperty = (obj, key, value) => {
446
+ const result = { ...obj };
447
+ result[key] = value;
448
+ return result;
449
+ };
450
+ // Helper to create the appropriate container type
451
+ const createContainer = (key) => {
452
+ const isArrayKey = typeof key === 'number' || !Number.isNaN(Number(key));
453
+ return isArrayKey ? [] : {};
454
+ };
455
+ // Helper for handling array path updates
456
+ const updateArrayPath = (array, pathSegments, value) => {
457
+ const index = Number(pathSegments[0]);
458
+ if (pathSegments.length === 1) {
459
+ // Simple array item update
460
+ return updateArrayItem(array, index, value);
461
+ }
462
+ // Nested path in array
463
+ const copy = [...array];
464
+ const nextPathSegments = pathSegments.slice(1);
465
+ const nextKey = nextPathSegments[0];
466
+ // For null/undefined values in arrays, create appropriate containers
467
+ let nextValue = array[index];
468
+ if (nextValue === undefined || nextValue === null) {
469
+ // Use empty object as default if nextKey is undefined
470
+ nextValue = nextKey !== undefined ? createContainer(nextKey) : {};
471
+ }
472
+ copy[index] = setValueAtPath(nextValue, nextPathSegments, value);
473
+ return copy;
474
+ };
475
+ // Helper for handling object path updates
476
+ const updateObjectPath = (obj, pathSegments, value) => {
477
+ // Ensure we have a valid key
478
+ const currentKey = pathSegments[0];
479
+ if (currentKey === undefined) {
480
+ // This shouldn't happen given our checks in the main function
481
+ return obj;
482
+ }
483
+ if (pathSegments.length === 1) {
484
+ // Simple object property update
485
+ return updateShallowProperty(obj, currentKey, value);
486
+ }
487
+ // Nested path in object
488
+ const nextPathSegments = pathSegments.slice(1);
489
+ const nextKey = nextPathSegments[0];
490
+ // For null/undefined values, create appropriate containers
491
+ let currentValue = obj[currentKey];
492
+ if (currentValue === undefined || currentValue === null) {
493
+ // Use empty object as default if nextKey is undefined
494
+ currentValue = nextKey !== undefined ? createContainer(nextKey) : {};
495
+ }
496
+ // Create new object with updated property
497
+ const result = { ...obj };
498
+ result[currentKey] = setValueAtPath(currentValue, nextPathSegments, value);
499
+ return result;
500
+ };
501
+ // Simplified function to update a nested value at a path
502
+ const setValueAtPath = (obj, pathSegments, value) => {
503
+ // Handle base cases
504
+ if (pathSegments.length === 0) {
505
+ return value;
506
+ }
507
+ if (obj === undefined || obj === null) {
508
+ return setValueAtPath({}, pathSegments, value);
509
+ }
510
+ const currentKey = pathSegments[0];
511
+ if (currentKey === undefined) {
512
+ return obj;
513
+ }
514
+ // Delegate to specialized handlers based on data type
515
+ if (Array.isArray(obj)) {
516
+ return updateArrayPath(obj, pathSegments, value);
517
+ }
518
+ return updateObjectPath(obj, pathSegments, value);
519
+ };
520
+ //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,28 +1,27 @@
1
1
  {
2
2
  "name": "@nerdalytics/beacon",
3
- "version": "1000.0.0",
3
+ "version": "1000.1.1",
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
- "main": "dist/index.js",
7
- "types": "dist/index.d.ts",
6
+ "main": "dist/src/index.js",
7
+ "types": "dist/src/index.d.ts",
8
8
  "files": [
9
- "dist/index.js",
10
- "dist/index.d.ts",
11
- "src/",
9
+ "dist/src/index.js",
10
+ "dist/src/index.d.ts",
11
+ "src/index.ts",
12
12
  "LICENSE"
13
13
  ],
14
14
  "repository": {
15
- "url": "github:nerdalytics/beacon",
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 src --write --config-path=./biome.json",
23
- "check": "npx @biomejs/biome check src --config-path=./biome.json",
24
- "check:fix": "npx @biomejs/biome format src --fix --config-path=./biome.json",
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
- "pretest:lts": "npm run build:lts",
46
- "test:lts": "node scripts/run-lts-tests.js",
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
- // Creates a callable function that both reads and writes state -
96
- // this design maintains JavaScript idioms while adding reactivity
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
- // Creates an effect that automatically tracks and responds to state changes
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
- // Batches state updates to improve performance by reducing redundant effect runs
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
- // Creates a derived state that memoizes computations and updates only when dependencies change
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
- // Creates a selector that monitors a slice of state with performance optimizations
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
+ }