@everystate/core 1.0.2 → 1.0.4
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/README.md +53 -9
- package/cli.js +40 -0
- package/index.d.ts +91 -0
- package/package.json +14 -6
- 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/README.md
CHANGED
|
@@ -37,6 +37,59 @@ store.subscribe('user.*', ({ path, value }) => {
|
|
|
37
37
|
unsub();
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
## Self-test (CLI, opt-in)
|
|
41
|
+
|
|
42
|
+
Run the bundled **zero-dependency** self-test locally to verify core behavior.
|
|
43
|
+
It is **opt-in** and never runs automatically on install:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# via npx (no install needed)
|
|
47
|
+
npx everystate-self-test
|
|
48
|
+
|
|
49
|
+
# if installed locally
|
|
50
|
+
everystate-self-test
|
|
51
|
+
|
|
52
|
+
# or directly
|
|
53
|
+
node node_modules/@everystate/core/self-test.js
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
You can also run the npm script from the package folder:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npm --prefix node_modules/@everystate/core run self-test
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Integration tests (@everystate/test)
|
|
63
|
+
|
|
64
|
+
The `tests/` folder contains a separate integration suite that uses
|
|
65
|
+
`@everystate/test` (not zero-dep). This is an intentional tradeoff:
|
|
66
|
+
the **self-test** stays lightweight, while integration tests remain available
|
|
67
|
+
for deeper validation.
|
|
68
|
+
|
|
69
|
+
Run the integration suite (opt-in):
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npm install @everystate/test
|
|
73
|
+
node node_modules/@everystate/core/tests/core.test.js
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Short form (from the package folder):
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
cd node_modules/@everystate/core
|
|
80
|
+
npm run test:integration
|
|
81
|
+
# or short alias
|
|
82
|
+
npm run test:i
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Or, from your project root:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
npm --prefix node_modules/@everystate/core run test:integration
|
|
89
|
+
# or short alias
|
|
90
|
+
npm --prefix node_modules/@everystate/core run test:i
|
|
91
|
+
```
|
|
92
|
+
|
|
40
93
|
## What is EveryState?
|
|
41
94
|
|
|
42
95
|
EveryState is a reactive state management library where:
|
|
@@ -64,15 +117,6 @@ EveryState makes state **addressable, observable, and testable** without special
|
|
|
64
117
|
|
|
65
118
|
## Ecosystem
|
|
66
119
|
|
|
67
|
-
- `@everystate/core`: Core state engine (you are here)
|
|
68
|
-
- `@everystate/css`: Reactive styling and design tokens
|
|
69
|
-
- `@everystate/perf`: Performance monitoring overlay
|
|
70
|
-
- `@everystate/react`: React hooks adapter
|
|
71
|
-
- `@everystate/router`: SPA routing as state
|
|
72
|
-
- `@everystate/test`: Zero-dependency testing
|
|
73
|
-
- `@everystate/view`: DOM-as-state with surgical updates
|
|
74
|
-
|
|
75
|
-
|
|
76
120
|
| Package | Description | License |
|
|
77
121
|
|---|---|---|
|
|
78
122
|
| [@everystate/aliases](https://www.npmjs.com/package/@everystate/aliases) | Ergonomic single-character and short-name DOM aliases for vanilla JS | MIT |
|
package/cli.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @everystate/core CLI (opt-in)
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* npx everystate-self-test
|
|
8
|
+
* everystate-self-test
|
|
9
|
+
* everystate-self-test --self-test
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
const wantsHelp = args.includes('-h') || args.includes('--help');
|
|
14
|
+
const wantsSelfTest =
|
|
15
|
+
args.length === 0 ||
|
|
16
|
+
args.includes('self-test') ||
|
|
17
|
+
args.includes('--self-test') ||
|
|
18
|
+
args.includes('test') ||
|
|
19
|
+
args.includes('--test');
|
|
20
|
+
|
|
21
|
+
if (wantsHelp) {
|
|
22
|
+
console.log(`@everystate/core self-test (opt-in)\n\nUsage:\n everystate-self-test [--self-test]\n\nNotes:\n - This command is opt-in and never runs automatically.\n - It runs a zero-dependency self-test bundled with the package.\n\nFlags:\n --self-test Run the bundled self-test (default)\n -h, --help Show this help message\n`);
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!wantsSelfTest) {
|
|
27
|
+
console.error('Unknown arguments. Run with --help for usage.');
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
(async () => {
|
|
32
|
+
try {
|
|
33
|
+
console.log('@everystate/core: running opt-in self-test...');
|
|
34
|
+
await import('./self-test.js');
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.error('Self-test failed to run.');
|
|
37
|
+
console.error(error);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
})();
|
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,12 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystate/core",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
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",
|
|
7
7
|
"types": "index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"everystate-self-test": "cli.js"
|
|
10
|
+
},
|
|
8
11
|
"scripts": {
|
|
9
|
-
"test": "node self-test.js"
|
|
12
|
+
"test": "node self-test.js",
|
|
13
|
+
"self-test": "node self-test.js",
|
|
14
|
+
"test:integration": "node tests/core.test.js",
|
|
15
|
+
"test:i": "node tests/core.test.js"
|
|
10
16
|
},
|
|
11
17
|
"keywords": [
|
|
12
18
|
"everystate",
|
|
@@ -33,9 +39,11 @@
|
|
|
33
39
|
},
|
|
34
40
|
"homepage": "https://github.com/ImsirovicAjdin/everystate-core#readme",
|
|
35
41
|
"files": [
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
42
|
+
"*.js",
|
|
43
|
+
"*.d.ts",
|
|
44
|
+
"*.md",
|
|
45
|
+
"*.html",
|
|
46
|
+
"cli.js",
|
|
47
|
+
"tests/"
|
|
40
48
|
]
|
|
41
49
|
}
|
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/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);
|