@zeix/cause-effect 0.17.0 → 0.17.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.
@@ -0,0 +1,227 @@
1
+ import { expect, mock, test } from 'bun:test'
2
+ import { isRef, Ref } from '../src/classes/ref'
3
+ import { createEffect } from '../src/effect'
4
+
5
+ test('Ref - basic functionality', () => {
6
+ const obj = { name: 'test', value: 42 }
7
+ const ref = new Ref(obj)
8
+
9
+ expect(ref.get()).toBe(obj)
10
+ expect(ref[Symbol.toStringTag]).toBe('Ref')
11
+ })
12
+
13
+ test('Ref - isRef type guard', () => {
14
+ const ref = new Ref({ test: true })
15
+ const notRef = { test: true }
16
+
17
+ expect(isRef(ref)).toBe(true)
18
+ expect(isRef(notRef)).toBe(false)
19
+ expect(isRef(null)).toBe(false)
20
+ expect(isRef(undefined)).toBe(false)
21
+ })
22
+
23
+ test('Ref - validation with guard function', () => {
24
+ const isConfig = (
25
+ value: unknown,
26
+ ): value is { host: string; port: number } =>
27
+ typeof value === 'object' &&
28
+ value !== null &&
29
+ 'host' in value &&
30
+ 'port' in value &&
31
+ typeof value.host === 'string' &&
32
+ typeof value.port === 'number'
33
+
34
+ const validConfig = { host: 'localhost', port: 3000 }
35
+ const invalidConfig = { host: 'localhost' } // missing port
36
+
37
+ expect(() => new Ref(validConfig, isConfig)).not.toThrow()
38
+ expect(() => new Ref(invalidConfig, isConfig)).toThrow()
39
+ })
40
+
41
+ test('Ref - reactive subscriptions', () => {
42
+ const server = { status: 'offline', connections: 0 }
43
+ const ref = new Ref(server)
44
+
45
+ let effectRunCount = 0
46
+ let lastStatus: string = ''
47
+
48
+ createEffect(() => {
49
+ const current = ref.get()
50
+ lastStatus = current.status
51
+ effectRunCount++
52
+ })
53
+
54
+ expect(effectRunCount).toBe(1)
55
+ expect(lastStatus).toBe('offline')
56
+
57
+ // Simulate external change without going through reactive system
58
+ server.status = 'online'
59
+ server.connections = 5
60
+
61
+ // Effect shouldn't re-run yet (reference hasn't changed)
62
+ expect(effectRunCount).toBe(1)
63
+
64
+ // Notify that the external object has changed
65
+ ref.notify()
66
+
67
+ expect(effectRunCount).toBe(2)
68
+ expect(lastStatus).toBe('online')
69
+ })
70
+
71
+ test('Ref - notify triggers watchers even with same reference', () => {
72
+ const fileObj = { path: '/test.txt', size: 100, modified: Date.now() }
73
+ const ref = new Ref(fileObj)
74
+
75
+ const mockCallback = mock(() => {})
76
+
77
+ createEffect(() => {
78
+ ref.get()
79
+ mockCallback()
80
+ })
81
+
82
+ expect(mockCallback).toHaveBeenCalledTimes(1)
83
+
84
+ // Simulate file modification (same object reference, different content)
85
+ fileObj.size = 200
86
+ fileObj.modified = Date.now()
87
+
88
+ // Notify about external change
89
+ ref.notify()
90
+
91
+ expect(mockCallback).toHaveBeenCalledTimes(2)
92
+
93
+ // Multiple notifies should trigger multiple times
94
+ ref.notify()
95
+ expect(mockCallback).toHaveBeenCalledTimes(3)
96
+ })
97
+
98
+ test('Ref - multiple effects with same ref', () => {
99
+ const database = { connected: false, queries: 0 }
100
+ const ref = new Ref(database)
101
+
102
+ const effect1Mock = mock(() => {})
103
+ const effect2Mock = mock((_connected: boolean) => {})
104
+
105
+ createEffect(() => {
106
+ ref.get()
107
+ effect1Mock()
108
+ })
109
+
110
+ createEffect(() => {
111
+ const db = ref.get()
112
+ effect2Mock(db.connected)
113
+ })
114
+
115
+ expect(effect1Mock).toHaveBeenCalledTimes(1)
116
+ expect(effect2Mock).toHaveBeenCalledTimes(1)
117
+ expect(effect2Mock).toHaveBeenCalledWith(false)
118
+
119
+ // Simulate database connection change
120
+ database.connected = true
121
+ database.queries = 10
122
+ ref.notify()
123
+
124
+ expect(effect1Mock).toHaveBeenCalledTimes(2)
125
+ expect(effect2Mock).toHaveBeenCalledTimes(2)
126
+ expect(effect2Mock).toHaveBeenLastCalledWith(true)
127
+ })
128
+
129
+ test('Ref - with Bun.file() scenario', () => {
130
+ // Mock a file-like object that could change externally
131
+ const fileRef = {
132
+ name: 'config.json',
133
+ size: 1024,
134
+ lastModified: Date.now(),
135
+ // Simulate file methods
136
+ exists: () => true,
137
+ text: () => Promise.resolve('{"version": "1.0"}'),
138
+ }
139
+
140
+ const ref = new Ref(fileRef)
141
+
142
+ let sizeChanges = 0
143
+ createEffect(() => {
144
+ const file = ref.get()
145
+ if (file.size > 1000) sizeChanges++
146
+ })
147
+
148
+ expect(sizeChanges).toBe(1) // Initial run
149
+
150
+ // Simulate file growing (external change)
151
+ fileRef.size = 2048
152
+ fileRef.lastModified = Date.now()
153
+ ref.notify()
154
+
155
+ expect(sizeChanges).toBe(2) // Effect re-ran and condition still met
156
+
157
+ // Simulate file shrinking
158
+ fileRef.size = 500
159
+ ref.notify()
160
+
161
+ expect(sizeChanges).toBe(2) // Effect re-ran but condition no longer met
162
+ })
163
+
164
+ test('Ref - validation errors', () => {
165
+ // @ts-expect-error deliberatly provoked error
166
+ expect(() => new Ref(null)).toThrow()
167
+ // @ts-expect-error deliberatly provoked error
168
+ expect(() => new Ref(undefined)).toThrow()
169
+ })
170
+
171
+ test('Ref - server config object scenario', () => {
172
+ const config = {
173
+ host: 'localhost',
174
+ port: 3000,
175
+ ssl: false,
176
+ maxConnections: 100,
177
+ }
178
+
179
+ const configRef = new Ref(config)
180
+ const connectionAttempts: string[] = []
181
+
182
+ createEffect(() => {
183
+ const cfg = configRef.get()
184
+ const protocol = cfg.ssl ? 'https' : 'http'
185
+ connectionAttempts.push(`${protocol}://${cfg.host}:${cfg.port}`)
186
+ })
187
+
188
+ expect(connectionAttempts).toEqual(['http://localhost:3000'])
189
+
190
+ // Simulate config reload from file/environment
191
+ config.ssl = true
192
+ config.port = 8443
193
+ configRef.notify()
194
+
195
+ expect(connectionAttempts).toEqual([
196
+ 'http://localhost:3000',
197
+ 'https://localhost:8443',
198
+ ])
199
+ })
200
+
201
+ test('Ref - handles complex nested objects', () => {
202
+ const apiResponse = {
203
+ status: 200,
204
+ data: {
205
+ users: [{ id: 1, name: 'Alice' }],
206
+ pagination: { page: 1, total: 1 },
207
+ },
208
+ headers: { 'content-type': 'application/json' },
209
+ }
210
+
211
+ const ref = new Ref(apiResponse)
212
+ let userCount = 0
213
+
214
+ createEffect(() => {
215
+ const response = ref.get()
216
+ userCount = response.data.users.length
217
+ })
218
+
219
+ expect(userCount).toBe(1)
220
+
221
+ // Simulate API response update
222
+ apiResponse.data.users.push({ id: 2, name: 'Bob' })
223
+ apiResponse.data.pagination.total = 2
224
+ ref.notify()
225
+
226
+ expect(userCount).toBe(2)
227
+ })
package/types/index.d.ts CHANGED
@@ -1,18 +1,19 @@
1
1
  /**
2
2
  * @name Cause & Effect
3
- * @version 0.17.0
3
+ * @version 0.17.1
4
4
  * @author Esther Brunner
5
5
  */
6
- export { Collection, type CollectionCallback, isCollection, TYPE_COLLECTION, } from './src/classes/collection';
6
+ export { type Collection, type CollectionCallback, type CollectionSource, DerivedCollection, isCollection, TYPE_COLLECTION, } from './src/classes/collection';
7
7
  export { type Computed, createComputed, isComputed, isMemoCallback, isTaskCallback, Memo, type MemoCallback, Task, type TaskCallback, TYPE_COMPUTED, } from './src/classes/computed';
8
8
  export { type ArrayToRecord, isList, type KeyConfig, List, TYPE_LIST, } from './src/classes/list';
9
+ export { isRef, Ref, TYPE_REF } from './src/classes/ref';
9
10
  export { isState, State, TYPE_STATE } from './src/classes/state';
10
11
  export { BaseStore, createStore, isStore, type Store, TYPE_STORE, } from './src/classes/store';
11
12
  export { type DiffResult, diff, isEqual, type UnknownArray, type UnknownRecord, } from './src/diff';
12
13
  export { createEffect, type EffectCallback, type MaybeCleanup, } from './src/effect';
13
- export { CircularDependencyError, DuplicateKeyError, InvalidCallbackError, InvalidSignalValueError, NullishSignalValueError, ReadonlySignalError, } from './src/errors';
14
+ export { CircularDependencyError, createError, DuplicateKeyError, type Guard, guardMutableSignal, InvalidCallbackError, InvalidCollectionSourceError, InvalidSignalValueError, NullishSignalValueError, ReadonlySignalError, validateCallback, validateSignalValue, } from './src/errors';
14
15
  export { type MatchHandlers, match } from './src/match';
15
16
  export { type ResolveResult, resolve } from './src/resolve';
16
17
  export { createSignal, isMutableSignal, isSignal, type Signal, type SignalValues, type UnknownSignalRecord, } from './src/signal';
17
18
  export { batchSignalWrites, type Cleanup, createWatcher, emitNotification, flushPendingReactions, type Listener, type Listeners, type Notifications, notifyWatchers, subscribeActiveWatcher, trackSignalReads, type Watcher, } from './src/system';
18
- export { isAbortError, isAsyncFunction, isFunction, isNumber, isObjectOfType, isRecord, isRecordOrArray, isString, isSymbol, toError, UNSET, valueString, } from './src/util';
19
+ export { isAbortError, isAsyncFunction, isFunction, isNumber, isObjectOfType, isRecord, isRecordOrArray, isString, isSymbol, UNSET, valueString, } from './src/util';
@@ -1,14 +1,28 @@
1
+ import type { Signal } from '../signal';
1
2
  import { type Cleanup, type Listener, type Listeners } from '../system';
2
3
  import { type Computed } from './computed';
3
4
  import { type List } from './list';
4
- type CollectionSource<T extends {}> = List<T> | Collection<T, any>;
5
+ type CollectionSource<T extends {}> = List<T> | Collection<T>;
5
6
  type CollectionCallback<T extends {}, U extends {}> = ((sourceValue: U) => T) | ((sourceValue: U, abort: AbortSignal) => Promise<T>);
7
+ type Collection<T extends {}> = {
8
+ readonly [Symbol.toStringTag]: 'Collection';
9
+ readonly [Symbol.isConcatSpreadable]: true;
10
+ [Symbol.iterator](): IterableIterator<Signal<T>>;
11
+ get: () => T[];
12
+ at: (index: number) => Signal<T> | undefined;
13
+ byKey: (key: string) => Signal<T> | undefined;
14
+ keyAt: (index: number) => string | undefined;
15
+ indexOfKey: (key: string) => number | undefined;
16
+ on: <K extends keyof Listeners>(type: K, listener: Listener<K>) => Cleanup;
17
+ deriveCollection: <R extends {}>(callback: CollectionCallback<R, T>) => DerivedCollection<R, T>;
18
+ readonly length: number;
19
+ };
6
20
  declare const TYPE_COLLECTION: "Collection";
7
- declare class Collection<T extends {}, U extends {}> {
21
+ declare class DerivedCollection<T extends {}, U extends {}> implements Collection<T> {
8
22
  #private;
9
23
  constructor(source: CollectionSource<U> | (() => CollectionSource<U>), callback: CollectionCallback<T, U>);
10
24
  get [Symbol.toStringTag](): 'Collection';
11
- get [Symbol.isConcatSpreadable](): boolean;
25
+ get [Symbol.isConcatSpreadable](): true;
12
26
  [Symbol.iterator](): IterableIterator<Computed<T>>;
13
27
  get length(): number;
14
28
  get(): T[];
@@ -18,8 +32,8 @@ declare class Collection<T extends {}, U extends {}> {
18
32
  keyAt(index: number): string | undefined;
19
33
  indexOfKey(key: string): number;
20
34
  on<K extends keyof Listeners>(type: K, listener: Listener<K>): Cleanup;
21
- deriveCollection<R extends {}>(callback: (sourceValue: T) => R): Collection<R, T>;
22
- deriveCollection<R extends {}>(callback: (sourceValue: T, abort: AbortSignal) => Promise<R>): Collection<R, T>;
35
+ deriveCollection<R extends {}>(callback: (sourceValue: T) => R): DerivedCollection<R, T>;
36
+ deriveCollection<R extends {}>(callback: (sourceValue: T, abort: AbortSignal) => Promise<R>): DerivedCollection<R, T>;
23
37
  }
24
38
  /**
25
39
  * Check if a value is a collection signal
@@ -28,5 +42,5 @@ declare class Collection<T extends {}, U extends {}> {
28
42
  * @param {unknown} value - Value to check
29
43
  * @returns {boolean} - True if value is a collection signal, false otherwise
30
44
  */
31
- declare const isCollection: <T extends {}, U extends {}>(value: unknown) => value is Collection<T, U>;
32
- export { Collection, type CollectionSource, type CollectionCallback, isCollection, TYPE_COLLECTION, };
45
+ declare const isCollection: <T extends {}, U extends {}>(value: unknown) => value is DerivedCollection<T, U>;
46
+ export { type Collection, type CollectionSource, type CollectionCallback, DerivedCollection, isCollection, TYPE_COLLECTION, };
@@ -1,6 +1,6 @@
1
1
  import { type UnknownArray } from '../diff';
2
2
  import { type Cleanup, type Listener, type Notifications } from '../system';
3
- import { Collection } from './collection';
3
+ import { DerivedCollection } from './collection';
4
4
  import { State } from './state';
5
5
  type ArrayToRecord<T extends UnknownArray> = {
6
6
  [key: string]: T extends Array<infer U extends {}> ? U : never;
@@ -11,7 +11,7 @@ declare class List<T extends {}> {
11
11
  #private;
12
12
  constructor(initialValue: T[], keyConfig?: KeyConfig<T>);
13
13
  get [Symbol.toStringTag](): 'List';
14
- get [Symbol.isConcatSpreadable](): boolean;
14
+ get [Symbol.isConcatSpreadable](): true;
15
15
  [Symbol.iterator](): IterableIterator<State<T>>;
16
16
  get length(): number;
17
17
  get(): T[];
@@ -27,8 +27,8 @@ declare class List<T extends {}> {
27
27
  sort(compareFn?: (a: T, b: T) => number): void;
28
28
  splice(start: number, deleteCount?: number, ...items: T[]): T[];
29
29
  on<K extends keyof Notifications>(type: K, listener: Listener<K>): Cleanup;
30
- deriveCollection<R extends {}>(callback: (sourceValue: T) => R): Collection<R, T>;
31
- deriveCollection<R extends {}>(callback: (sourceValue: T, abort: AbortSignal) => Promise<R>): Collection<R, T>;
30
+ deriveCollection<R extends {}>(callback: (sourceValue: T) => R): DerivedCollection<R, T>;
31
+ deriveCollection<R extends {}>(callback: (sourceValue: T, abort: AbortSignal) => Promise<R>): DerivedCollection<R, T>;
32
32
  }
33
33
  /**
34
34
  * Check if the provided value is a List instance
@@ -0,0 +1,39 @@
1
+ import { type Guard } from '../errors';
2
+ declare const TYPE_REF = "Ref";
3
+ /**
4
+ * Create a new ref signal.
5
+ *
6
+ * @since 0.17.1
7
+ */
8
+ declare class Ref<T extends {}> {
9
+ #private;
10
+ /**
11
+ * Create a new ref signal.
12
+ *
13
+ * @param {T} value - Reference to external object
14
+ * @param {Guard<T>} guard - Optional guard function to validate the value
15
+ * @throws {NullishSignalValueError} - If the value is null or undefined
16
+ * @throws {InvalidSignalValueError} - If the value is invalid
17
+ */
18
+ constructor(value: T, guard?: Guard<T>);
19
+ get [Symbol.toStringTag](): string;
20
+ /**
21
+ * Get the value of the ref signal.
22
+ *
23
+ * @returns {T} - Object reference
24
+ */
25
+ get(): T;
26
+ /**
27
+ * Notify watchers of relevant changes in the external reference
28
+ */
29
+ notify(): void;
30
+ }
31
+ /**
32
+ * Check if the provided value is a Ref instance
33
+ *
34
+ * @since 0.17.1
35
+ * @param {unknown} value - Value to check
36
+ * @returns {boolean} - True if the value is a Ref instance, false otherwise
37
+ */
38
+ declare const isRef: <T extends {}>(value: unknown) => value is Ref<T>;
39
+ export { TYPE_REF, Ref, isRef };
@@ -1,4 +1,5 @@
1
1
  import { type MutableSignal } from './signal';
2
+ type Guard<T> = (value: unknown) => value is T;
2
3
  declare class CircularDependencyError extends Error {
3
4
  constructor(where: string);
4
5
  }
@@ -8,6 +9,9 @@ declare class DuplicateKeyError extends Error {
8
9
  declare class InvalidCallbackError extends TypeError {
9
10
  constructor(where: string, value: unknown);
10
11
  }
12
+ declare class InvalidCollectionSourceError extends TypeError {
13
+ constructor(where: string, value: unknown);
14
+ }
11
15
  declare class InvalidSignalValueError extends TypeError {
12
16
  constructor(where: string, value: unknown);
13
17
  }
@@ -17,7 +21,8 @@ declare class NullishSignalValueError extends TypeError {
17
21
  declare class ReadonlySignalError extends Error {
18
22
  constructor(what: string, value: unknown);
19
23
  }
24
+ declare const createError: (reason: unknown) => Error;
20
25
  declare const validateCallback: (where: string, value: unknown, guard?: (value: unknown) => boolean) => void;
21
26
  declare const validateSignalValue: (where: string, value: unknown, guard?: (value: unknown) => boolean) => void;
22
27
  declare const guardMutableSignal: <T extends {}>(what: string, value: unknown, signal: unknown) => signal is MutableSignal<T>;
23
- export { CircularDependencyError, DuplicateKeyError, InvalidCallbackError, InvalidSignalValueError, NullishSignalValueError, ReadonlySignalError, validateCallback, validateSignalValue, guardMutableSignal, };
28
+ export { type Guard, CircularDependencyError, DuplicateKeyError, InvalidCallbackError, InvalidCollectionSourceError, InvalidSignalValueError, NullishSignalValueError, ReadonlySignalError, createError, validateCallback, validateSignalValue, guardMutableSignal, };
@@ -14,6 +14,5 @@ declare const isRecordOrArray: <T extends Record<string | number, unknown> | Rea
14
14
  declare const isUniformArray: <T>(value: unknown, guard?: (item: T) => item is T & {}) => value is T[];
15
15
  declare const hasMethod: <T extends object & Record<string, (...args: unknown[]) => unknown>>(obj: T, methodName: string) => obj is T & Record<string, (...args: unknown[]) => unknown>;
16
16
  declare const isAbortError: (error: unknown) => boolean;
17
- declare const toError: (reason: unknown) => Error;
18
17
  declare const valueString: (value: unknown) => string;
19
- export { UNSET, isString, isNumber, isSymbol, isFunction, isAsyncFunction, isSyncFunction, isNonNullObject, isObjectOfType, isRecord, isRecordOrArray, isUniformArray, hasMethod, isAbortError, toError, valueString, };
18
+ export { UNSET, isString, isNumber, isSymbol, isFunction, isAsyncFunction, isSyncFunction, isNonNullObject, isObjectOfType, isRecord, isRecordOrArray, isUniformArray, hasMethod, isAbortError, valueString, };