@everystate/core 1.0.2 → 1.0.3
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/CONTRIBUTING.md +24 -0
- package/index.d.ts +91 -0
- package/package.json +6 -5
- package/self-test.js +292 -0
- package/tests/core.test.js +237 -0
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Contributing to @everystate/core
|
|
2
|
+
|
|
3
|
+
## Current Contribution Policy
|
|
4
|
+
|
|
5
|
+
**@everystate/core is currently not accepting external contributions.**
|
|
6
|
+
|
|
7
|
+
This project represents a focused approach to event-driven state management. I'm maintaining tight control over the architecture and implementation to keep the library lean, coherent, and production-ready.
|
|
8
|
+
|
|
9
|
+
## How You Can Help
|
|
10
|
+
|
|
11
|
+
While I'm not accepting code contributions, you can still support the project:
|
|
12
|
+
|
|
13
|
+
- **Report bugs** via GitHub issues
|
|
14
|
+
- **Share your experience** using EveryState in your projects
|
|
15
|
+
- **Spread the word** about path-based reactive state
|
|
16
|
+
- **Provide feedback** on the API and documentation
|
|
17
|
+
|
|
18
|
+
## Future Contributions
|
|
19
|
+
|
|
20
|
+
This policy may evolve as the project matures.
|
|
21
|
+
|
|
22
|
+
## Questions?
|
|
23
|
+
|
|
24
|
+
Feel free to open an issue for questions about usage or to report bugs.
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystate/core
|
|
3
|
+
*
|
|
4
|
+
* Path-based reactive state management.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Detail object passed to wildcard and global subscribers */
|
|
8
|
+
export interface ChangeDetail {
|
|
9
|
+
path: string;
|
|
10
|
+
value: any;
|
|
11
|
+
oldValue: any;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Unsubscribe function returned by store.subscribe() */
|
|
15
|
+
export type Unsubscribe = () => void;
|
|
16
|
+
|
|
17
|
+
/** The EveryState store instance */
|
|
18
|
+
export interface EveryStateStore {
|
|
19
|
+
/**
|
|
20
|
+
* Get value at a dot-separated path.
|
|
21
|
+
* Returns entire state if no path is provided.
|
|
22
|
+
*/
|
|
23
|
+
get(path?: string): any;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Set value at a dot-separated path and notify subscribers.
|
|
27
|
+
* @returns The value that was set
|
|
28
|
+
*/
|
|
29
|
+
set(path: string, value: any): any;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Run an async fetcher and manage loading/success/error status at `path.status`, `path.data`, `path.error`.
|
|
33
|
+
* Supports AbortController cancellation.
|
|
34
|
+
*/
|
|
35
|
+
setAsync(path: string, fetcher: (signal: AbortSignal) => Promise<any>): Promise<any>;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Cancel an in-flight async operation at path.
|
|
39
|
+
*/
|
|
40
|
+
cancel(path: string): void;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Batch multiple set() calls. Subscribers fire once per unique path after the batch completes.
|
|
44
|
+
* Supports nesting.
|
|
45
|
+
*/
|
|
46
|
+
batch(fn: () => void): void;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Set multiple paths atomically.
|
|
50
|
+
* Accepts a plain object `{ path: value }`, an array of `[path, value]` pairs, or a Map.
|
|
51
|
+
*/
|
|
52
|
+
setMany(entries: Record<string, any> | [string, any][] | Map<string, any>): void;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Subscribe to changes at a path.
|
|
56
|
+
* - Exact path: `handler(value, detail)` fires when that specific path changes.
|
|
57
|
+
* - Wildcard (`'user.*'`): `handler(detail)` fires when any child of `user` changes.
|
|
58
|
+
* - Global (`'*'`): `handler(detail)` fires on any change.
|
|
59
|
+
* @returns Unsubscribe function
|
|
60
|
+
*/
|
|
61
|
+
subscribe(path: string, handler: (valueOrDetail: any, detail?: ChangeDetail) => void): Unsubscribe;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Destroy the store, abort all async ops, and clear all subscriptions.
|
|
65
|
+
*/
|
|
66
|
+
destroy(): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create a new EveryState store.
|
|
71
|
+
* @param initial - Initial state object (deep-cloned)
|
|
72
|
+
*/
|
|
73
|
+
export function createEveryState(initial?: Record<string, any>): EveryStateStore;
|
|
74
|
+
|
|
75
|
+
/** Query client wrapper for EveryState async patterns */
|
|
76
|
+
export interface QueryClient {
|
|
77
|
+
query(key: string, fetcher: (signal: AbortSignal) => Promise<any>): Promise<any>;
|
|
78
|
+
subscribe(key: string, cb: (value: any) => void): Unsubscribe;
|
|
79
|
+
subscribeToStatus(key: string, cb: (status: string) => void): Unsubscribe;
|
|
80
|
+
subscribeToError(key: string, cb: (error: any) => void): Unsubscribe;
|
|
81
|
+
getData(key: string): any;
|
|
82
|
+
getStatus(key: string): string | undefined;
|
|
83
|
+
getError(key: string): any;
|
|
84
|
+
cancel(key: string): void;
|
|
85
|
+
invalidate(key: string): void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Create a QueryClient that wraps an EveryState store with standard query patterns.
|
|
90
|
+
*/
|
|
91
|
+
export function createQueryClient(store: EveryStateStore): QueryClient;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystate/core",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "EveryState: Lightweight event-driven state management with path-based subscriptions, wildcards, and async support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -33,9 +33,10 @@
|
|
|
33
33
|
},
|
|
34
34
|
"homepage": "https://github.com/ImsirovicAjdin/everystate-core#readme",
|
|
35
35
|
"files": [
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
36
|
+
"*.js",
|
|
37
|
+
"*.d.ts",
|
|
38
|
+
"*.md",
|
|
39
|
+
"*.html",
|
|
40
|
+
"tests/"
|
|
40
41
|
]
|
|
41
42
|
}
|
package/self-test.js
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystate/core self-test
|
|
3
|
+
*
|
|
4
|
+
* Standalone test of core EveryState functionality. No dependencies beyond everyState.js.
|
|
5
|
+
* Runs on `node self-test.js` or as a postinstall hook.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createEveryState } from './everyState.js';
|
|
9
|
+
|
|
10
|
+
let passed = 0;
|
|
11
|
+
let failed = 0;
|
|
12
|
+
|
|
13
|
+
function assert(name, condition) {
|
|
14
|
+
if (condition) {
|
|
15
|
+
passed++;
|
|
16
|
+
console.log(` ✓ ${name}`);
|
|
17
|
+
} else {
|
|
18
|
+
failed++;
|
|
19
|
+
console.error(` ✗ ${name}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
console.log('\n1. get / set');
|
|
24
|
+
const s1 = createEveryState({ count: 0, user: { name: 'Alice', age: 30 } });
|
|
25
|
+
|
|
26
|
+
assert('get: primitive', s1.get('count') === 0);
|
|
27
|
+
assert('get: nested', s1.get('user.name') === 'Alice');
|
|
28
|
+
assert('get: object', s1.get('user')?.name === 'Alice');
|
|
29
|
+
assert('get: entire state', s1.get('')?.count === 0);
|
|
30
|
+
assert('get: missing path → undefined', s1.get('nonexistent') === undefined);
|
|
31
|
+
assert('get: deep missing → undefined', s1.get('user.email') === undefined);
|
|
32
|
+
|
|
33
|
+
s1.set('count', 5);
|
|
34
|
+
assert('set: primitive', s1.get('count') === 5);
|
|
35
|
+
|
|
36
|
+
s1.set('user.name', 'Bob');
|
|
37
|
+
assert('set: nested', s1.get('user.name') === 'Bob');
|
|
38
|
+
|
|
39
|
+
s1.set('user.email', 'bob@example.com');
|
|
40
|
+
assert('set: auto-creates path', s1.get('user.email') === 'bob@example.com');
|
|
41
|
+
|
|
42
|
+
s1.set('deep.nested.path', 'value');
|
|
43
|
+
assert('set: deep auto-create', s1.get('deep.nested.path') === 'value');
|
|
44
|
+
|
|
45
|
+
s1.destroy();
|
|
46
|
+
|
|
47
|
+
console.log('\n2. subscribe: exact path');
|
|
48
|
+
const s2 = createEveryState({ count: 0 });
|
|
49
|
+
let lastValue = null;
|
|
50
|
+
let fireCount = 0;
|
|
51
|
+
|
|
52
|
+
const unsub = s2.subscribe('count', (value) => {
|
|
53
|
+
lastValue = value;
|
|
54
|
+
fireCount++;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
s2.set('count', 1);
|
|
58
|
+
assert('subscribe: fires on change', fireCount === 1);
|
|
59
|
+
assert('subscribe: receives value', lastValue === 1);
|
|
60
|
+
|
|
61
|
+
s2.set('count', 2);
|
|
62
|
+
assert('subscribe: fires again', fireCount === 2);
|
|
63
|
+
assert('subscribe: receives new value', lastValue === 2);
|
|
64
|
+
|
|
65
|
+
unsub();
|
|
66
|
+
s2.set('count', 3);
|
|
67
|
+
assert('unsubscribe: stops firing', fireCount === 2);
|
|
68
|
+
assert('unsubscribe: value unchanged in handler', lastValue === 2);
|
|
69
|
+
assert('set still works after unsub', s2.get('count') === 3);
|
|
70
|
+
|
|
71
|
+
s2.destroy();
|
|
72
|
+
|
|
73
|
+
console.log('\n3. subscribe: wildcard');
|
|
74
|
+
const s3 = createEveryState({ user: { name: 'Alice', age: 30 } });
|
|
75
|
+
let wildcardFires = 0;
|
|
76
|
+
let wildcardDetail = null;
|
|
77
|
+
|
|
78
|
+
s3.subscribe('user.*', (detail) => {
|
|
79
|
+
wildcardFires++;
|
|
80
|
+
wildcardDetail = detail;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
s3.set('user.name', 'Bob');
|
|
84
|
+
assert('wildcard: fires on child change', wildcardFires === 1);
|
|
85
|
+
assert('wildcard: detail has path', wildcardDetail?.path === 'user.name');
|
|
86
|
+
assert('wildcard: detail has value', wildcardDetail?.value === 'Bob');
|
|
87
|
+
|
|
88
|
+
s3.set('user.age', 31);
|
|
89
|
+
assert('wildcard: fires on other child', wildcardFires === 2);
|
|
90
|
+
|
|
91
|
+
s3.destroy();
|
|
92
|
+
|
|
93
|
+
console.log('\n4. subscribe: global');
|
|
94
|
+
const s4 = createEveryState({ a: 1, b: { c: 2 } });
|
|
95
|
+
let globalFires = 0;
|
|
96
|
+
|
|
97
|
+
s4.subscribe('*', () => { globalFires++; });
|
|
98
|
+
|
|
99
|
+
s4.set('a', 10);
|
|
100
|
+
assert('global: fires on root path', globalFires === 1);
|
|
101
|
+
|
|
102
|
+
s4.set('b.c', 20);
|
|
103
|
+
assert('global: fires on nested path', globalFires === 2);
|
|
104
|
+
|
|
105
|
+
s4.destroy();
|
|
106
|
+
|
|
107
|
+
console.log('\n5. batch');
|
|
108
|
+
const s5 = createEveryState({ x: 0, y: 0 });
|
|
109
|
+
let batchFires = 0;
|
|
110
|
+
s5.subscribe('*', () => { batchFires++; });
|
|
111
|
+
|
|
112
|
+
s5.batch(() => {
|
|
113
|
+
s5.set('x', 1);
|
|
114
|
+
s5.set('y', 2);
|
|
115
|
+
});
|
|
116
|
+
assert('batch: fires after (not during)', batchFires === 2);
|
|
117
|
+
assert('batch: x set', s5.get('x') === 1);
|
|
118
|
+
assert('batch: y set', s5.get('y') === 2);
|
|
119
|
+
|
|
120
|
+
// Deduplication
|
|
121
|
+
batchFires = 0;
|
|
122
|
+
s5.batch(() => {
|
|
123
|
+
s5.set('x', 10);
|
|
124
|
+
s5.set('x', 20);
|
|
125
|
+
s5.set('x', 30);
|
|
126
|
+
});
|
|
127
|
+
assert('batch: deduplicates same path (1 fire)', batchFires === 1);
|
|
128
|
+
assert('batch: last write wins', s5.get('x') === 30);
|
|
129
|
+
|
|
130
|
+
// Nested batch
|
|
131
|
+
batchFires = 0;
|
|
132
|
+
s5.batch(() => {
|
|
133
|
+
s5.set('x', 100);
|
|
134
|
+
s5.batch(() => {
|
|
135
|
+
s5.set('y', 200);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
assert('nested batch: both fire after outermost', batchFires === 2);
|
|
139
|
+
assert('nested batch: x', s5.get('x') === 100);
|
|
140
|
+
assert('nested batch: y', s5.get('y') === 200);
|
|
141
|
+
|
|
142
|
+
// No notifications during batch
|
|
143
|
+
const seen = [];
|
|
144
|
+
const s5b = createEveryState({ v: 0 });
|
|
145
|
+
s5b.subscribe('v', (val) => seen.push(val));
|
|
146
|
+
s5b.batch(() => {
|
|
147
|
+
s5b.set('v', 1);
|
|
148
|
+
s5b.set('v', 2);
|
|
149
|
+
});
|
|
150
|
+
assert('batch: no mid-batch notifications', seen.length === 1);
|
|
151
|
+
assert('batch: final value delivered', seen[0] === 2);
|
|
152
|
+
|
|
153
|
+
s5.destroy();
|
|
154
|
+
s5b.destroy();
|
|
155
|
+
|
|
156
|
+
console.log('\n6. setMany');
|
|
157
|
+
const s6 = createEveryState({});
|
|
158
|
+
|
|
159
|
+
// Plain object
|
|
160
|
+
s6.setMany({ 'a.b': 1, 'a.c': 2 });
|
|
161
|
+
assert('setMany object: a.b', s6.get('a.b') === 1);
|
|
162
|
+
assert('setMany object: a.c', s6.get('a.c') === 2);
|
|
163
|
+
|
|
164
|
+
// Array of pairs
|
|
165
|
+
s6.setMany([['x.y', 'hello'], ['x.z', 'world']]);
|
|
166
|
+
assert('setMany array: x.y', s6.get('x.y') === 'hello');
|
|
167
|
+
assert('setMany array: x.z', s6.get('x.z') === 'world');
|
|
168
|
+
|
|
169
|
+
// Map
|
|
170
|
+
s6.setMany(new Map([['m.a', true], ['m.b', false]]));
|
|
171
|
+
assert('setMany Map: m.a', s6.get('m.a') === true);
|
|
172
|
+
assert('setMany Map: m.b', s6.get('m.b') === false);
|
|
173
|
+
|
|
174
|
+
s6.destroy();
|
|
175
|
+
|
|
176
|
+
console.log('\n7. destroy');
|
|
177
|
+
const s7 = createEveryState({ z: 0 });
|
|
178
|
+
s7.destroy();
|
|
179
|
+
|
|
180
|
+
let threw = false;
|
|
181
|
+
try { s7.get('z'); } catch { threw = true; }
|
|
182
|
+
assert('destroy: get throws', threw);
|
|
183
|
+
|
|
184
|
+
threw = false;
|
|
185
|
+
try { s7.set('z', 1); } catch { threw = true; }
|
|
186
|
+
assert('destroy: set throws', threw);
|
|
187
|
+
|
|
188
|
+
threw = false;
|
|
189
|
+
try { s7.batch(() => {}); } catch { threw = true; }
|
|
190
|
+
assert('destroy: batch throws', threw);
|
|
191
|
+
|
|
192
|
+
threw = false;
|
|
193
|
+
try { s7.setMany({ a: 1 }); } catch { threw = true; }
|
|
194
|
+
assert('destroy: setMany throws', threw);
|
|
195
|
+
|
|
196
|
+
threw = false;
|
|
197
|
+
try { s7.subscribe('z', () => {}); } catch { threw = true; }
|
|
198
|
+
assert('destroy: subscribe throws', threw);
|
|
199
|
+
|
|
200
|
+
console.log('\n8. subscribe: detail object');
|
|
201
|
+
const s8 = createEveryState({ count: 10 });
|
|
202
|
+
let detail = null;
|
|
203
|
+
s8.subscribe('count', (value, d) => { detail = d; });
|
|
204
|
+
s8.set('count', 20);
|
|
205
|
+
assert('detail: has path', detail?.path === 'count');
|
|
206
|
+
assert('detail: has value', detail?.value === 20);
|
|
207
|
+
assert('detail: has oldValue', detail?.oldValue === 10);
|
|
208
|
+
|
|
209
|
+
s8.destroy();
|
|
210
|
+
|
|
211
|
+
console.log('\n9. detail: shared across listener types');
|
|
212
|
+
const s9 = createEveryState({ user: { name: 'Alice' } });
|
|
213
|
+
let s9exact = null;
|
|
214
|
+
let s9wildcard = null;
|
|
215
|
+
let s9global = null;
|
|
216
|
+
|
|
217
|
+
s9.subscribe('user.name', (value, d) => { s9exact = d; });
|
|
218
|
+
s9.subscribe('user.*', (d) => { s9wildcard = d; });
|
|
219
|
+
s9.subscribe('*', (d) => { s9global = d; });
|
|
220
|
+
|
|
221
|
+
s9.set('user.name', 'Bob');
|
|
222
|
+
assert('detail shared: exact has detail', s9exact !== null);
|
|
223
|
+
assert('detail shared: wildcard === exact (same ref)', s9wildcard === s9exact);
|
|
224
|
+
assert('detail shared: global === exact (same ref)', s9global === s9exact);
|
|
225
|
+
|
|
226
|
+
s9.destroy();
|
|
227
|
+
|
|
228
|
+
console.log('\n10. unsubscribe: Map cleanup (fast-path recovery)');
|
|
229
|
+
const s10 = createEveryState({ x: 0 });
|
|
230
|
+
let s10fires = 0;
|
|
231
|
+
|
|
232
|
+
const unsub10 = s10.subscribe('x', () => { s10fires++; });
|
|
233
|
+
s10.set('x', 1);
|
|
234
|
+
assert('unsub cleanup: fires while subscribed', s10fires === 1);
|
|
235
|
+
|
|
236
|
+
unsub10();
|
|
237
|
+
s10.set('x', 2);
|
|
238
|
+
assert('unsub cleanup: stops firing after unsub', s10fires === 1);
|
|
239
|
+
assert('unsub cleanup: value still written', s10.get('x') === 2);
|
|
240
|
+
|
|
241
|
+
// Re-subscribe to same path after full cleanup
|
|
242
|
+
let s10fires2 = 0;
|
|
243
|
+
const unsub10b = s10.subscribe('x', () => { s10fires2++; });
|
|
244
|
+
s10.set('x', 3);
|
|
245
|
+
assert('unsub cleanup: re-subscribe works after cleanup', s10fires2 === 1);
|
|
246
|
+
|
|
247
|
+
unsub10b();
|
|
248
|
+
s10.destroy();
|
|
249
|
+
|
|
250
|
+
console.log('\n11. fast-path: no detail allocated without listeners');
|
|
251
|
+
const s11 = createEveryState({ a: 0 });
|
|
252
|
+
let s11fires = 0;
|
|
253
|
+
|
|
254
|
+
// set with zero subscribers — fast-path should skip all dispatch
|
|
255
|
+
s11.set('a', 1);
|
|
256
|
+
s11.set('a', 2);
|
|
257
|
+
s11.set('a', 3);
|
|
258
|
+
assert('fast-path: value written without subscribers', s11.get('a') === 3);
|
|
259
|
+
|
|
260
|
+
// subscribe, fire, unsub, then set again — fast-path should re-engage
|
|
261
|
+
const unsub11 = s11.subscribe('a', () => { s11fires++; });
|
|
262
|
+
s11.set('a', 4);
|
|
263
|
+
assert('fast-path: fires with subscriber', s11fires === 1);
|
|
264
|
+
|
|
265
|
+
unsub11();
|
|
266
|
+
s11fires = 0;
|
|
267
|
+
s11.set('a', 5);
|
|
268
|
+
assert('fast-path: no fire after full unsub', s11fires === 0);
|
|
269
|
+
assert('fast-path: value still written after full unsub', s11.get('a') === 5);
|
|
270
|
+
|
|
271
|
+
s11.destroy();
|
|
272
|
+
|
|
273
|
+
console.log('\n12. wildcard-only: detail allocated for wildcard without exact');
|
|
274
|
+
const s12 = createEveryState({ user: { name: 'Alice' } });
|
|
275
|
+
let s12detail = null;
|
|
276
|
+
|
|
277
|
+
// Only wildcard subscriber, no exact subscriber for user.name
|
|
278
|
+
s12.subscribe('user.*', (d) => { s12detail = d; });
|
|
279
|
+
s12.set('user.name', 'Bob');
|
|
280
|
+
assert('wildcard-only: detail created', s12detail !== null);
|
|
281
|
+
assert('wildcard-only: detail.path correct', s12detail?.path === 'user.name');
|
|
282
|
+
assert('wildcard-only: detail.value correct', s12detail?.value === 'Bob');
|
|
283
|
+
assert('wildcard-only: detail.oldValue correct', s12detail?.oldValue === 'Alice');
|
|
284
|
+
|
|
285
|
+
s12.destroy();
|
|
286
|
+
|
|
287
|
+
// Results
|
|
288
|
+
|
|
289
|
+
console.log(`\n@everystate/core v1.0.0 self-test`);
|
|
290
|
+
console.log(`✓ ${passed} assertions passed${failed ? `, ✗ ${failed} failed` : ''}\n`);
|
|
291
|
+
|
|
292
|
+
if (failed > 0) process.exit(1);
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystate/core — eventTest-based integration tests
|
|
3
|
+
*
|
|
4
|
+
* Merged from test-batch.js and test-batch-dogfood.js.
|
|
5
|
+
* Tests batch, setMany, setAsync, destroy, and core regression tests
|
|
6
|
+
* using @everystate/event-test.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createEventTest, runTests } from '@everystate/event-test';
|
|
10
|
+
import { createEveryState } from '@everystate/core';
|
|
11
|
+
|
|
12
|
+
const results = runTests({
|
|
13
|
+
|
|
14
|
+
// ── batch ─────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
'batch: coalesces — same-path deduplication': () => {
|
|
17
|
+
const t = createEventTest({ count: 0 });
|
|
18
|
+
t.store.batch(() => {
|
|
19
|
+
t.trigger('count', 1);
|
|
20
|
+
t.trigger('count', 2);
|
|
21
|
+
t.trigger('count', 3);
|
|
22
|
+
});
|
|
23
|
+
t.assertPath('count', 3);
|
|
24
|
+
t.assertEventFired('count', 1);
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
'batch: fires once per unique path after flush': () => {
|
|
28
|
+
const t = createEventTest({ user: { name: 'Alice', email: 'a@b.com' } });
|
|
29
|
+
t.store.batch(() => {
|
|
30
|
+
t.trigger('user.name', 'Bob');
|
|
31
|
+
t.trigger('user.email', 'bob@b.com');
|
|
32
|
+
});
|
|
33
|
+
t.assertPath('user.name', 'Bob');
|
|
34
|
+
t.assertPath('user.email', 'bob@b.com');
|
|
35
|
+
t.assertEventFired('user.name', 1);
|
|
36
|
+
t.assertEventFired('user.email', 1);
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
'batch: no notifications during, only after': () => {
|
|
40
|
+
const store = createEveryState({ x: 0 });
|
|
41
|
+
const seen = [];
|
|
42
|
+
store.subscribe('x', (v) => seen.push(v));
|
|
43
|
+
store.batch(() => {
|
|
44
|
+
store.set('x', 1);
|
|
45
|
+
if (seen.length !== 0) throw new Error('Notification fired during batch');
|
|
46
|
+
store.set('x', 2);
|
|
47
|
+
if (seen.length !== 0) throw new Error('Notification fired during batch');
|
|
48
|
+
});
|
|
49
|
+
if (seen.length !== 1) throw new Error(`Expected 1 notification after batch, got ${seen.length}`);
|
|
50
|
+
if (seen[0] !== 2) throw new Error(`Expected final value 2, got ${seen[0]}`);
|
|
51
|
+
store.destroy();
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
'batch: nested — only outermost flushes': () => {
|
|
55
|
+
const t = createEventTest({ a: 0, b: 0 });
|
|
56
|
+
t.store.batch(() => {
|
|
57
|
+
t.trigger('a', 1);
|
|
58
|
+
t.store.batch(() => {
|
|
59
|
+
t.trigger('b', 2);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
t.assertPath('a', 1);
|
|
63
|
+
t.assertPath('b', 2);
|
|
64
|
+
t.assertEventFired('a', 1);
|
|
65
|
+
t.assertEventFired('b', 1);
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
'batch: get() during batch reads committed state (not buffer)': () => {
|
|
69
|
+
const store = createEveryState({ v: 'old' });
|
|
70
|
+
store.batch(() => {
|
|
71
|
+
store.set('v', 'new');
|
|
72
|
+
const read = store.get('v');
|
|
73
|
+
if (read !== 'old') {
|
|
74
|
+
throw new Error(`get() during batch should read committed state, got "${read}"`);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
if (store.get('v') !== 'new') {
|
|
78
|
+
throw new Error('After batch, get() should read new value');
|
|
79
|
+
}
|
|
80
|
+
store.destroy();
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
// ── setMany ───────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
'setMany: plain object': () => {
|
|
86
|
+
const t = createEventTest({});
|
|
87
|
+
t.store.setMany({
|
|
88
|
+
'ui.route.view': 'home',
|
|
89
|
+
'ui.route.path': '/',
|
|
90
|
+
'ui.route.params': {},
|
|
91
|
+
});
|
|
92
|
+
t.assertPath('ui.route.view', 'home');
|
|
93
|
+
t.assertPath('ui.route.path', '/');
|
|
94
|
+
t.assertType('ui.route.view', 'string');
|
|
95
|
+
t.assertType('ui.route.path', 'string');
|
|
96
|
+
t.assertShape('ui.route.params', {});
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
'setMany: array of [path, value] pairs': () => {
|
|
100
|
+
const t = createEventTest({});
|
|
101
|
+
t.store.setMany([
|
|
102
|
+
['a.b', 1],
|
|
103
|
+
['a.c', 2],
|
|
104
|
+
]);
|
|
105
|
+
t.assertPath('a.b', 1);
|
|
106
|
+
t.assertPath('a.c', 2);
|
|
107
|
+
t.assertType('a.b', 'number');
|
|
108
|
+
t.assertType('a.c', 'number');
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
'setMany: Map': () => {
|
|
112
|
+
const t = createEventTest({});
|
|
113
|
+
const m = new Map([['x.y', 'hello'], ['x.z', 'world']]);
|
|
114
|
+
t.store.setMany(m);
|
|
115
|
+
t.assertPath('x.y', 'hello');
|
|
116
|
+
t.assertPath('x.z', 'world');
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
// ── setAsync ──────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
'setAsync: batches loading phase writes': () => {
|
|
122
|
+
const store = createEveryState({});
|
|
123
|
+
let wildcardFires = 0;
|
|
124
|
+
store.subscribe('users.*', () => { wildcardFires++; });
|
|
125
|
+
|
|
126
|
+
const promise = store.setAsync('users', async (signal) => {
|
|
127
|
+
return [{ id: 1, name: 'Alice' }];
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (wildcardFires !== 2) {
|
|
131
|
+
throw new Error(`Loading phase: expected 2 wildcard fires, got ${wildcardFires}`);
|
|
132
|
+
}
|
|
133
|
+
if (store.get('users.status') !== 'loading') {
|
|
134
|
+
throw new Error(`Expected status=loading, got ${store.get('users.status')}`);
|
|
135
|
+
}
|
|
136
|
+
if (store.get('users.error') !== null) {
|
|
137
|
+
throw new Error(`Expected error=null, got ${store.get('users.error')}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
promise.catch(() => {});
|
|
141
|
+
store.destroy();
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
'setAsync: batches success phase writes': async () => {
|
|
145
|
+
const store = createEveryState({});
|
|
146
|
+
await store.setAsync('data', async () => ({ result: 42 }));
|
|
147
|
+
|
|
148
|
+
if (store.get('data.status') !== 'success') {
|
|
149
|
+
throw new Error(`Expected status=success, got ${store.get('data.status')}`);
|
|
150
|
+
}
|
|
151
|
+
if (store.get('data.data')?.result !== 42) {
|
|
152
|
+
throw new Error('Expected data.result=42');
|
|
153
|
+
}
|
|
154
|
+
store.destroy();
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
'setAsync: full wildcard fire count': async () => {
|
|
158
|
+
const store = createEveryState({});
|
|
159
|
+
let wildcardFires = 0;
|
|
160
|
+
store.subscribe('users.*', () => { wildcardFires++; });
|
|
161
|
+
|
|
162
|
+
await store.setAsync('users', async (signal) => {
|
|
163
|
+
return [{ id: 1, name: 'Alice' }];
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Loading: status+error = 2, Success: data+status = 2, Total = 4
|
|
167
|
+
if (wildcardFires !== 4) {
|
|
168
|
+
throw new Error(`Expected 4 wildcard fires, got ${wildcardFires}`);
|
|
169
|
+
}
|
|
170
|
+
if (store.get('users.status') !== 'success') {
|
|
171
|
+
throw new Error(`Expected status=success`);
|
|
172
|
+
}
|
|
173
|
+
if (!Array.isArray(store.get('users.data'))) {
|
|
174
|
+
throw new Error('Expected data to be array');
|
|
175
|
+
}
|
|
176
|
+
store.destroy();
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
// ── destroy ───────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
'destroy: batch throws after destroy': () => {
|
|
182
|
+
const store = createEveryState({ z: 0 });
|
|
183
|
+
store.destroy();
|
|
184
|
+
let threw = false;
|
|
185
|
+
try { store.batch(() => {}); } catch { threw = true; }
|
|
186
|
+
if (!threw) throw new Error('batch() should throw after destroy');
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
'destroy: setMany throws after destroy': () => {
|
|
190
|
+
const store = createEveryState({ z: 0 });
|
|
191
|
+
store.destroy();
|
|
192
|
+
let threw = false;
|
|
193
|
+
try { store.setMany({ a: 1 }); } catch { threw = true; }
|
|
194
|
+
if (!threw) throw new Error('setMany() should throw after destroy');
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
// ── core regression ───────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
'core: basic get/set/subscribe': () => {
|
|
200
|
+
const t = createEventTest({ name: 'Alice' });
|
|
201
|
+
t.trigger('name', 'Bob');
|
|
202
|
+
t.assertPath('name', 'Bob');
|
|
203
|
+
t.assertType('name', 'string');
|
|
204
|
+
t.assertEventFired('name', 1);
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
'core: wildcard subscription': () => {
|
|
208
|
+
const t = createEventTest({ user: { name: 'Alice', age: 30 } });
|
|
209
|
+
t.trigger('user.name', 'Bob');
|
|
210
|
+
t.trigger('user.age', 31);
|
|
211
|
+
t.assertPath('user.name', 'Bob');
|
|
212
|
+
t.assertPath('user.age', 31);
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
'core: nested path auto-creation': () => {
|
|
216
|
+
const t = createEventTest({});
|
|
217
|
+
t.trigger('deep.nested.path', 'value');
|
|
218
|
+
t.assertPath('deep.nested.path', 'value');
|
|
219
|
+
t.assertType('deep.nested.path', 'string');
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
'core: type assertions for type generation': () => {
|
|
223
|
+
const t = createEventTest({
|
|
224
|
+
count: 0,
|
|
225
|
+
user: { name: 'Alice', active: true },
|
|
226
|
+
items: [{ id: 1, text: 'Todo' }],
|
|
227
|
+
});
|
|
228
|
+
t.assertType('count', 'number');
|
|
229
|
+
t.assertShape('user', { name: 'string', active: 'boolean' });
|
|
230
|
+
t.assertArrayOf('items', { id: 'number', text: 'string' });
|
|
231
|
+
|
|
232
|
+
const types = t.getTypeAssertions();
|
|
233
|
+
if (types.length !== 3) throw new Error(`Expected 3 type assertions, got ${types.length}`);
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
if (results.failed > 0) process.exit(1);
|