@plures/praxis 1.1.1 → 1.1.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/README.md +68 -7
- package/dist/browser/chunk-R45WXWKH.js +345 -0
- package/dist/browser/index.d.ts +171 -11
- package/dist/browser/index.js +279 -277
- package/dist/browser/integrations/svelte.d.ts +3 -1
- package/dist/browser/integrations/svelte.js +7 -0
- package/dist/browser/{engine-BjdqxeXG.d.ts → reactive-engine.svelte-C9OpcTHf.d.ts} +87 -1
- package/dist/node/chunk-R45WXWKH.js +345 -0
- package/dist/node/components/index.d.cts +2 -2
- package/dist/node/components/index.d.ts +2 -2
- package/dist/node/index.cjs +343 -8
- package/dist/node/index.d.cts +108 -15
- package/dist/node/index.d.ts +108 -15
- package/dist/node/index.js +279 -278
- package/dist/node/integrations/svelte.cjs +357 -2
- package/dist/node/integrations/svelte.d.cts +3 -1
- package/dist/node/integrations/svelte.d.ts +3 -1
- package/dist/node/integrations/svelte.js +6 -0
- package/dist/node/{engine-CVJobhHm.d.cts → reactive-engine.svelte-1M4m_C_v.d.cts} +87 -1
- package/dist/node/{engine-1iqLe6_P.d.ts → reactive-engine.svelte-ChNFn4Hj.d.ts} +87 -1
- package/dist/node/{terminal-adapter-XLtCjjb_.d.cts → terminal-adapter-CDzxoLKR.d.cts} +68 -1
- package/dist/node/{terminal-adapter-07HGftGQ.d.ts → terminal-adapter-CWka-yL8.d.ts} +68 -1
- package/package.json +3 -2
- package/src/__tests__/reactive-engine.test.ts +516 -0
- package/src/core/pluresdb/README.md +156 -0
- package/src/core/pluresdb/adapter.ts +165 -0
- package/src/core/pluresdb/index.ts +3 -3
- package/src/core/reactive-engine.svelte.ts +88 -19
- package/src/core/reactive-engine.ts +283 -30
- package/src/index.browser.ts +12 -0
- package/src/index.ts +12 -0
- package/src/integrations/pluresdb.ts +2 -2
- package/src/integrations/svelte.ts +8 -0
|
@@ -71,6 +71,73 @@ declare class InMemoryPraxisDB implements PraxisDB {
|
|
|
71
71
|
* ```
|
|
72
72
|
*/
|
|
73
73
|
declare function createInMemoryDB(): InMemoryPraxisDB;
|
|
74
|
+
/**
|
|
75
|
+
* PluresDB instance type - represents either PluresNode or SQLiteCompatibleAPI
|
|
76
|
+
*/
|
|
77
|
+
type PluresDBInstance = {
|
|
78
|
+
get(key: string): Promise<any>;
|
|
79
|
+
put(key: string, value: any): Promise<void>;
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Configuration options for PluresDBPraxisAdapter
|
|
83
|
+
*/
|
|
84
|
+
interface PluresDBAdapterConfig {
|
|
85
|
+
/** PluresDB instance */
|
|
86
|
+
db: PluresDBInstance;
|
|
87
|
+
/** Polling interval in milliseconds for watch functionality (default: 1000ms) */
|
|
88
|
+
pollInterval?: number;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* PluresDB-backed implementation of PraxisDB
|
|
92
|
+
*
|
|
93
|
+
* Wraps the official PluresDB package from NPM to provide
|
|
94
|
+
* the PraxisDB interface for production use.
|
|
95
|
+
*/
|
|
96
|
+
declare class PluresDBPraxisAdapter implements PraxisDB {
|
|
97
|
+
private db;
|
|
98
|
+
private watchers;
|
|
99
|
+
private pollIntervals;
|
|
100
|
+
private lastValues;
|
|
101
|
+
private pollInterval;
|
|
102
|
+
constructor(config: PluresDBAdapterConfig | PluresDBInstance);
|
|
103
|
+
get<T>(key: string): Promise<T | undefined>;
|
|
104
|
+
set<T>(key: string, value: T): Promise<void>;
|
|
105
|
+
watch<T>(key: string, callback: (val: T) => void): UnsubscribeFn;
|
|
106
|
+
/**
|
|
107
|
+
* Clean up all resources
|
|
108
|
+
*/
|
|
109
|
+
dispose(): void;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Create a PluresDB-backed PraxisDB instance
|
|
113
|
+
*
|
|
114
|
+
* Wraps the official PluresDB package from NPM.
|
|
115
|
+
*
|
|
116
|
+
* @param config PluresDB instance or configuration object
|
|
117
|
+
* @returns PluresDBPraxisAdapter instance
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* ```typescript
|
|
121
|
+
* import { PluresNode } from 'pluresdb';
|
|
122
|
+
* import { createPluresDB } from '@plures/praxis';
|
|
123
|
+
*
|
|
124
|
+
* const pluresdb = new PluresNode({ autoStart: true });
|
|
125
|
+
* const db = createPluresDB(pluresdb);
|
|
126
|
+
*
|
|
127
|
+
* await db.set('user:1', { name: 'Alice' });
|
|
128
|
+
* const user = await db.get('user:1');
|
|
129
|
+
* ```
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* ```typescript
|
|
133
|
+
* // With custom polling interval
|
|
134
|
+
* const db = createPluresDB({
|
|
135
|
+
* db: pluresdb,
|
|
136
|
+
* pollInterval: 500, // Poll every 500ms
|
|
137
|
+
* });
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
declare function createPluresDB(config: PluresDBAdapterConfig | PluresDBInstance): PluresDBPraxisAdapter;
|
|
74
141
|
|
|
75
142
|
/**
|
|
76
143
|
* Terminal Node Runtime Adapter
|
|
@@ -228,4 +295,4 @@ declare function createMockExecutor(responses: Record<string, {
|
|
|
228
295
|
error?: string;
|
|
229
296
|
}>): CommandExecutor;
|
|
230
297
|
|
|
231
|
-
export { type CommandExecutor as C, InMemoryPraxisDB as I, type PraxisDB as P, TerminalAdapter as T, type UnsubscribeFn as U, type TerminalExecutionResult as a, type TerminalNodeState as b, createTerminalAdapter as c, type TerminalAdapterOptions as d, createMockExecutor as e,
|
|
298
|
+
export { type CommandExecutor as C, InMemoryPraxisDB as I, type PraxisDB as P, TerminalAdapter as T, type UnsubscribeFn as U, type TerminalExecutionResult as a, type TerminalNodeState as b, createTerminalAdapter as c, type TerminalAdapterOptions as d, createMockExecutor as e, type PluresDBInstance as f, type PluresDBAdapterConfig as g, createInMemoryDB as h, PluresDBPraxisAdapter as i, createPluresDB as j, runTerminalCommand as r };
|
|
@@ -71,6 +71,73 @@ declare class InMemoryPraxisDB implements PraxisDB {
|
|
|
71
71
|
* ```
|
|
72
72
|
*/
|
|
73
73
|
declare function createInMemoryDB(): InMemoryPraxisDB;
|
|
74
|
+
/**
|
|
75
|
+
* PluresDB instance type - represents either PluresNode or SQLiteCompatibleAPI
|
|
76
|
+
*/
|
|
77
|
+
type PluresDBInstance = {
|
|
78
|
+
get(key: string): Promise<any>;
|
|
79
|
+
put(key: string, value: any): Promise<void>;
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Configuration options for PluresDBPraxisAdapter
|
|
83
|
+
*/
|
|
84
|
+
interface PluresDBAdapterConfig {
|
|
85
|
+
/** PluresDB instance */
|
|
86
|
+
db: PluresDBInstance;
|
|
87
|
+
/** Polling interval in milliseconds for watch functionality (default: 1000ms) */
|
|
88
|
+
pollInterval?: number;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* PluresDB-backed implementation of PraxisDB
|
|
92
|
+
*
|
|
93
|
+
* Wraps the official PluresDB package from NPM to provide
|
|
94
|
+
* the PraxisDB interface for production use.
|
|
95
|
+
*/
|
|
96
|
+
declare class PluresDBPraxisAdapter implements PraxisDB {
|
|
97
|
+
private db;
|
|
98
|
+
private watchers;
|
|
99
|
+
private pollIntervals;
|
|
100
|
+
private lastValues;
|
|
101
|
+
private pollInterval;
|
|
102
|
+
constructor(config: PluresDBAdapterConfig | PluresDBInstance);
|
|
103
|
+
get<T>(key: string): Promise<T | undefined>;
|
|
104
|
+
set<T>(key: string, value: T): Promise<void>;
|
|
105
|
+
watch<T>(key: string, callback: (val: T) => void): UnsubscribeFn;
|
|
106
|
+
/**
|
|
107
|
+
* Clean up all resources
|
|
108
|
+
*/
|
|
109
|
+
dispose(): void;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Create a PluresDB-backed PraxisDB instance
|
|
113
|
+
*
|
|
114
|
+
* Wraps the official PluresDB package from NPM.
|
|
115
|
+
*
|
|
116
|
+
* @param config PluresDB instance or configuration object
|
|
117
|
+
* @returns PluresDBPraxisAdapter instance
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* ```typescript
|
|
121
|
+
* import { PluresNode } from 'pluresdb';
|
|
122
|
+
* import { createPluresDB } from '@plures/praxis';
|
|
123
|
+
*
|
|
124
|
+
* const pluresdb = new PluresNode({ autoStart: true });
|
|
125
|
+
* const db = createPluresDB(pluresdb);
|
|
126
|
+
*
|
|
127
|
+
* await db.set('user:1', { name: 'Alice' });
|
|
128
|
+
* const user = await db.get('user:1');
|
|
129
|
+
* ```
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* ```typescript
|
|
133
|
+
* // With custom polling interval
|
|
134
|
+
* const db = createPluresDB({
|
|
135
|
+
* db: pluresdb,
|
|
136
|
+
* pollInterval: 500, // Poll every 500ms
|
|
137
|
+
* });
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
declare function createPluresDB(config: PluresDBAdapterConfig | PluresDBInstance): PluresDBPraxisAdapter;
|
|
74
141
|
|
|
75
142
|
/**
|
|
76
143
|
* Terminal Node Runtime Adapter
|
|
@@ -228,4 +295,4 @@ declare function createMockExecutor(responses: Record<string, {
|
|
|
228
295
|
error?: string;
|
|
229
296
|
}>): CommandExecutor;
|
|
230
297
|
|
|
231
|
-
export { type CommandExecutor as C, InMemoryPraxisDB as I, type PraxisDB as P, TerminalAdapter as T, type UnsubscribeFn as U, type TerminalExecutionResult as a, type TerminalNodeState as b, createTerminalAdapter as c, type TerminalAdapterOptions as d, createMockExecutor as e,
|
|
298
|
+
export { type CommandExecutor as C, InMemoryPraxisDB as I, type PraxisDB as P, TerminalAdapter as T, type UnsubscribeFn as U, type TerminalExecutionResult as a, type TerminalNodeState as b, createTerminalAdapter as c, type TerminalAdapterOptions as d, createMockExecutor as e, type PluresDBInstance as f, type PluresDBAdapterConfig as g, createInMemoryDB as h, PluresDBPraxisAdapter as i, createPluresDB as j, runTerminalCommand as r };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plures/praxis",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
4
4
|
"description": "The Full Plures Application Framework - declarative schemas, logic engine, component generation, and local-first data",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/node/index.js",
|
|
@@ -116,7 +116,8 @@
|
|
|
116
116
|
},
|
|
117
117
|
"dependencies": {
|
|
118
118
|
"commander": "^14.0.2",
|
|
119
|
-
"js-yaml": "^4.1.1"
|
|
119
|
+
"js-yaml": "^4.1.1",
|
|
120
|
+
"pluresdb": "^1.0.1"
|
|
120
121
|
},
|
|
121
122
|
"peerDependencies": {
|
|
122
123
|
"svelte": "^5.46.0"
|
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Framework-Agnostic Reactive Engine
|
|
3
|
+
*
|
|
4
|
+
* Tests the reactive engine implementation using JavaScript Proxies
|
|
5
|
+
* for automatic state tracking and change notifications.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
9
|
+
import {
|
|
10
|
+
ReactiveLogicEngine,
|
|
11
|
+
createReactiveEngine,
|
|
12
|
+
type StateChangeCallback,
|
|
13
|
+
} from '../core/reactive-engine.js';
|
|
14
|
+
|
|
15
|
+
interface TestContext {
|
|
16
|
+
count: number;
|
|
17
|
+
name: string;
|
|
18
|
+
nested: {
|
|
19
|
+
value: number;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('Framework-Agnostic Reactive Engine', () => {
|
|
24
|
+
describe('ReactiveLogicEngine', () => {
|
|
25
|
+
it('should create an engine with initial state', () => {
|
|
26
|
+
const engine = new ReactiveLogicEngine<TestContext>({
|
|
27
|
+
initialContext: {
|
|
28
|
+
count: 0,
|
|
29
|
+
name: 'test',
|
|
30
|
+
nested: { value: 10 },
|
|
31
|
+
},
|
|
32
|
+
initialFacts: [],
|
|
33
|
+
initialMeta: {},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(engine.context.count).toBe(0);
|
|
37
|
+
expect(engine.context.name).toBe('test');
|
|
38
|
+
expect(engine.context.nested.value).toBe(10);
|
|
39
|
+
expect(engine.facts).toEqual([]);
|
|
40
|
+
expect(engine.meta).toEqual({});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should provide access to state via getters', () => {
|
|
44
|
+
const engine = createReactiveEngine<TestContext>({
|
|
45
|
+
initialContext: { count: 5, name: 'hello', nested: { value: 20 } },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const state = engine.state;
|
|
49
|
+
expect(state.context.count).toBe(5);
|
|
50
|
+
expect(state.context.name).toBe('hello');
|
|
51
|
+
expect(state.facts).toEqual([]);
|
|
52
|
+
expect(state.meta).toEqual({});
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('Reactivity', () => {
|
|
57
|
+
it('should notify subscribers when context changes', () => {
|
|
58
|
+
const engine = createReactiveEngine<TestContext>({
|
|
59
|
+
initialContext: { count: 0, name: 'test', nested: { value: 10 } },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const states: any[] = [];
|
|
63
|
+
engine.subscribe((state) => {
|
|
64
|
+
states.push({ ...state.context });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Initial state
|
|
68
|
+
expect(states.length).toBe(1);
|
|
69
|
+
expect(states[0].count).toBe(0);
|
|
70
|
+
|
|
71
|
+
// Mutate context
|
|
72
|
+
engine.apply((state) => {
|
|
73
|
+
state.context.count = 5;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Should have notified
|
|
77
|
+
expect(states.length).toBe(2);
|
|
78
|
+
expect(states[1].count).toBe(5);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should notify subscribers when facts change', () => {
|
|
82
|
+
const engine = createReactiveEngine({
|
|
83
|
+
initialContext: { count: 0, name: 'test', nested: { value: 10 } },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const factArrays: any[][] = [];
|
|
87
|
+
engine.subscribe((state) => {
|
|
88
|
+
factArrays.push([...state.facts]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(factArrays.length).toBe(1);
|
|
92
|
+
expect(factArrays[0]).toEqual([]);
|
|
93
|
+
|
|
94
|
+
// Add a fact
|
|
95
|
+
engine.apply((state) => {
|
|
96
|
+
state.facts.push({ tag: 'TestFact', payload: { value: 1 } });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(factArrays.length).toBe(2);
|
|
100
|
+
expect(factArrays[1]).toEqual([{ tag: 'TestFact', payload: { value: 1 } }]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should notify subscribers when meta changes', () => {
|
|
104
|
+
const engine = createReactiveEngine({
|
|
105
|
+
initialContext: { count: 0, name: 'test', nested: { value: 10 } },
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const metas: any[] = [];
|
|
109
|
+
engine.subscribe((state) => {
|
|
110
|
+
metas.push({ ...state.meta });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(metas.length).toBe(1);
|
|
114
|
+
expect(metas[0]).toEqual({});
|
|
115
|
+
|
|
116
|
+
// Set meta value
|
|
117
|
+
engine.apply((state) => {
|
|
118
|
+
state.meta.timestamp = Date.now();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(metas.length).toBe(2);
|
|
122
|
+
expect(metas[1].timestamp).toBeDefined();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should only notify when value actually changes', () => {
|
|
126
|
+
const engine = createReactiveEngine({
|
|
127
|
+
initialContext: { count: 0, name: 'test', nested: { value: 10 } },
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const callback = vi.fn();
|
|
131
|
+
engine.subscribe(callback);
|
|
132
|
+
|
|
133
|
+
expect(callback).toHaveBeenCalledTimes(1); // Initial call
|
|
134
|
+
|
|
135
|
+
// Set to same value
|
|
136
|
+
engine.apply((state) => {
|
|
137
|
+
state.context.count = 0;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Should not notify because value didn't change
|
|
141
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
142
|
+
|
|
143
|
+
// Set to different value
|
|
144
|
+
engine.apply((state) => {
|
|
145
|
+
state.context.count = 1;
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Should notify
|
|
149
|
+
expect(callback).toHaveBeenCalledTimes(2);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should support nested object mutations', () => {
|
|
153
|
+
const engine = createReactiveEngine({
|
|
154
|
+
initialContext: { count: 0, name: 'test', nested: { value: 10 } },
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const callback = vi.fn();
|
|
158
|
+
engine.subscribe(callback);
|
|
159
|
+
|
|
160
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
161
|
+
|
|
162
|
+
// Mutate nested value
|
|
163
|
+
engine.apply((state) => {
|
|
164
|
+
state.context.nested.value = 20;
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(callback).toHaveBeenCalledTimes(2);
|
|
168
|
+
expect(engine.context.nested.value).toBe(20);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('Subscription Management', () => {
|
|
173
|
+
it('should return unsubscribe function', () => {
|
|
174
|
+
const engine = createReactiveEngine({
|
|
175
|
+
initialContext: { count: 0, name: 'test', nested: { value: 10 } },
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const callback = vi.fn();
|
|
179
|
+
const unsubscribe = engine.subscribe(callback);
|
|
180
|
+
|
|
181
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
182
|
+
|
|
183
|
+
engine.apply((state) => {
|
|
184
|
+
state.context.count = 1;
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(callback).toHaveBeenCalledTimes(2);
|
|
188
|
+
|
|
189
|
+
// Unsubscribe
|
|
190
|
+
unsubscribe();
|
|
191
|
+
|
|
192
|
+
// Should not notify after unsubscribe
|
|
193
|
+
engine.apply((state) => {
|
|
194
|
+
state.context.count = 2;
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(callback).toHaveBeenCalledTimes(2);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should support multiple subscribers', () => {
|
|
201
|
+
const engine = createReactiveEngine({
|
|
202
|
+
initialContext: { count: 0, name: 'test', nested: { value: 10 } },
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const callback1 = vi.fn();
|
|
206
|
+
const callback2 = vi.fn();
|
|
207
|
+
const callback3 = vi.fn();
|
|
208
|
+
|
|
209
|
+
engine.subscribe(callback1);
|
|
210
|
+
engine.subscribe(callback2);
|
|
211
|
+
engine.subscribe(callback3);
|
|
212
|
+
|
|
213
|
+
expect(callback1).toHaveBeenCalledTimes(1);
|
|
214
|
+
expect(callback2).toHaveBeenCalledTimes(1);
|
|
215
|
+
expect(callback3).toHaveBeenCalledTimes(1);
|
|
216
|
+
|
|
217
|
+
engine.apply((state) => {
|
|
218
|
+
state.context.count = 5;
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect(callback1).toHaveBeenCalledTimes(2);
|
|
222
|
+
expect(callback2).toHaveBeenCalledTimes(2);
|
|
223
|
+
expect(callback3).toHaveBeenCalledTimes(2);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should handle errors in subscriber callbacks gracefully', () => {
|
|
227
|
+
const engine = createReactiveEngine({
|
|
228
|
+
initialContext: { count: 0, name: 'test', nested: { value: 10 } },
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
232
|
+
|
|
233
|
+
const callback1 = vi.fn(() => {
|
|
234
|
+
throw new Error('Test error');
|
|
235
|
+
});
|
|
236
|
+
const callback2 = vi.fn();
|
|
237
|
+
|
|
238
|
+
engine.subscribe(callback1);
|
|
239
|
+
engine.subscribe(callback2);
|
|
240
|
+
|
|
241
|
+
expect(callback1).toHaveBeenCalledTimes(1);
|
|
242
|
+
expect(callback2).toHaveBeenCalledTimes(1);
|
|
243
|
+
|
|
244
|
+
// Should not throw, should log error, and should still call callback2
|
|
245
|
+
engine.apply((state) => {
|
|
246
|
+
state.context.count = 1;
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(consoleError).toHaveBeenCalled();
|
|
250
|
+
expect(callback1).toHaveBeenCalledTimes(2);
|
|
251
|
+
expect(callback2).toHaveBeenCalledTimes(2);
|
|
252
|
+
|
|
253
|
+
consoleError.mockRestore();
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe('Derived Values', () => {
|
|
258
|
+
it('should create derived values that update reactively', () => {
|
|
259
|
+
const engine = createReactiveEngine({
|
|
260
|
+
initialContext: { count: 0, name: 'test', nested: { value: 10 } },
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const doubled = engine.$derived((state) => state.context.count * 2);
|
|
264
|
+
|
|
265
|
+
const values: number[] = [];
|
|
266
|
+
doubled.subscribe((value) => {
|
|
267
|
+
values.push(value);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
expect(values).toEqual([0]);
|
|
271
|
+
|
|
272
|
+
engine.apply((state) => {
|
|
273
|
+
state.context.count = 5;
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
expect(values).toEqual([0, 10]);
|
|
277
|
+
|
|
278
|
+
engine.apply((state) => {
|
|
279
|
+
state.context.count = 10;
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
expect(values).toEqual([0, 10, 20]);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('should only notify derived subscribers when derived value changes', () => {
|
|
286
|
+
const engine = createReactiveEngine({
|
|
287
|
+
initialContext: { count: 0, name: 'test', nested: { value: 10 } },
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const isPositive = engine.$derived((state) => state.context.count > 0);
|
|
291
|
+
|
|
292
|
+
const values: boolean[] = [];
|
|
293
|
+
isPositive.subscribe((value) => {
|
|
294
|
+
values.push(value);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
expect(values).toEqual([false]);
|
|
298
|
+
|
|
299
|
+
// Change count but isPositive stays false
|
|
300
|
+
engine.apply((state) => {
|
|
301
|
+
state.context.count = -5;
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Should not notify because derived value didn't change
|
|
305
|
+
expect(values).toEqual([false]);
|
|
306
|
+
|
|
307
|
+
// Change to positive
|
|
308
|
+
engine.apply((state) => {
|
|
309
|
+
state.context.count = 5;
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// Should notify
|
|
313
|
+
expect(values).toEqual([false, true]);
|
|
314
|
+
|
|
315
|
+
// Change count but isPositive stays true
|
|
316
|
+
engine.apply((state) => {
|
|
317
|
+
state.context.count = 10;
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// Should not notify
|
|
321
|
+
expect(values).toEqual([false, true]);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('should support complex derived selectors', () => {
|
|
325
|
+
const engine = createReactiveEngine({
|
|
326
|
+
initialContext: { count: 0, name: 'test', nested: { value: 10 } },
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const complex = engine.$derived((state) => ({
|
|
330
|
+
total: state.context.count + state.context.nested.value,
|
|
331
|
+
label: `${state.context.name}: ${state.context.count}`,
|
|
332
|
+
}));
|
|
333
|
+
|
|
334
|
+
const values: any[] = [];
|
|
335
|
+
complex.subscribe((value) => {
|
|
336
|
+
values.push(value);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
expect(values.length).toBe(1);
|
|
340
|
+
expect(values[0]).toEqual({ total: 10, label: 'test: 0' });
|
|
341
|
+
|
|
342
|
+
engine.apply((state) => {
|
|
343
|
+
state.context.count = 5;
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
expect(values.length).toBe(2);
|
|
347
|
+
expect(values[1]).toEqual({ total: 15, label: 'test: 5' });
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('should handle errors in derived subscriber callbacks', () => {
|
|
351
|
+
const engine = createReactiveEngine({
|
|
352
|
+
initialContext: { count: 0, name: 'test', nested: { value: 10 } },
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
356
|
+
|
|
357
|
+
const doubled = engine.$derived((state) => state.context.count * 2);
|
|
358
|
+
|
|
359
|
+
const callback1 = vi.fn(() => {
|
|
360
|
+
throw new Error('Test error in derived');
|
|
361
|
+
});
|
|
362
|
+
const callback2 = vi.fn();
|
|
363
|
+
|
|
364
|
+
doubled.subscribe(callback1);
|
|
365
|
+
doubled.subscribe(callback2);
|
|
366
|
+
|
|
367
|
+
expect(callback1).toHaveBeenCalledTimes(1);
|
|
368
|
+
expect(callback2).toHaveBeenCalledTimes(1);
|
|
369
|
+
|
|
370
|
+
engine.apply((state) => {
|
|
371
|
+
state.context.count = 5;
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
expect(consoleError).toHaveBeenCalled();
|
|
375
|
+
expect(callback1).toHaveBeenCalledTimes(2);
|
|
376
|
+
expect(callback2).toHaveBeenCalledTimes(2);
|
|
377
|
+
|
|
378
|
+
consoleError.mockRestore();
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
describe('Apply Method', () => {
|
|
383
|
+
it('should allow direct mutation of state', () => {
|
|
384
|
+
const engine = createReactiveEngine({
|
|
385
|
+
initialContext: { count: 0, name: 'test', nested: { value: 10 } },
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
engine.apply((state) => {
|
|
389
|
+
state.context.count = 100;
|
|
390
|
+
state.context.name = 'updated';
|
|
391
|
+
state.context.nested.value = 200;
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
expect(engine.context.count).toBe(100);
|
|
395
|
+
expect(engine.context.name).toBe('updated');
|
|
396
|
+
expect(engine.context.nested.value).toBe(200);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('should work with array operations', () => {
|
|
400
|
+
const engine = createReactiveEngine({
|
|
401
|
+
initialContext: { count: 0, name: 'test', nested: { value: 10 } },
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
engine.apply((state) => {
|
|
405
|
+
state.facts.push({ tag: 'Fact1', payload: {} });
|
|
406
|
+
state.facts.push({ tag: 'Fact2', payload: {} });
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
expect(engine.facts.length).toBe(2);
|
|
410
|
+
expect(engine.facts[0].tag).toBe('Fact1');
|
|
411
|
+
expect(engine.facts[1].tag).toBe('Fact2');
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('should work with object property additions and deletions', () => {
|
|
415
|
+
const engine = createReactiveEngine({
|
|
416
|
+
initialContext: { count: 0, name: 'test', nested: { value: 10 } },
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const callback = vi.fn();
|
|
420
|
+
engine.subscribe(callback);
|
|
421
|
+
|
|
422
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
423
|
+
|
|
424
|
+
engine.apply((state) => {
|
|
425
|
+
(state.meta as any).newProp = 'new value';
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
expect(engine.meta.newProp).toBe('new value');
|
|
429
|
+
expect(callback).toHaveBeenCalledTimes(2);
|
|
430
|
+
|
|
431
|
+
engine.apply((state) => {
|
|
432
|
+
delete state.meta.newProp;
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
expect(engine.meta.newProp).toBeUndefined();
|
|
436
|
+
expect(callback).toHaveBeenCalledTimes(3);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
describe('createReactiveEngine Helper', () => {
|
|
441
|
+
it('should create an engine with minimal options', () => {
|
|
442
|
+
const engine = createReactiveEngine({
|
|
443
|
+
initialContext: { count: 0, name: 'test', nested: { value: 10 } },
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
expect(engine).toBeInstanceOf(ReactiveLogicEngine);
|
|
447
|
+
expect(engine.context.count).toBe(0);
|
|
448
|
+
expect(engine.facts).toEqual([]);
|
|
449
|
+
expect(engine.meta).toEqual({});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('should create an engine with all options', () => {
|
|
453
|
+
const engine = createReactiveEngine({
|
|
454
|
+
initialContext: { count: 5, name: 'hello', nested: { value: 20 } },
|
|
455
|
+
initialFacts: [{ tag: 'InitialFact', payload: {} }],
|
|
456
|
+
initialMeta: { timestamp: 123 },
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
expect(engine.context.count).toBe(5);
|
|
460
|
+
expect(engine.facts).toEqual([{ tag: 'InitialFact', payload: {} }]);
|
|
461
|
+
expect(engine.meta).toEqual({ timestamp: 123 });
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
describe('Integration Example', () => {
|
|
466
|
+
it('should work in a realistic counter scenario', () => {
|
|
467
|
+
interface CounterContext {
|
|
468
|
+
count: number;
|
|
469
|
+
history: number[];
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const engine = createReactiveEngine<CounterContext>({
|
|
473
|
+
initialContext: {
|
|
474
|
+
count: 0,
|
|
475
|
+
history: [0],
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// Create derived value for count display
|
|
480
|
+
const displayValue = engine.$derived((state) =>
|
|
481
|
+
`Count: ${state.context.count} (History: ${state.context.history.join(', ')})`
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
const displays: string[] = [];
|
|
485
|
+
displayValue.subscribe((value) => {
|
|
486
|
+
displays.push(value);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
expect(displays[0]).toBe('Count: 0 (History: 0)');
|
|
490
|
+
|
|
491
|
+
// Increment
|
|
492
|
+
engine.apply((state) => {
|
|
493
|
+
state.context.count += 1;
|
|
494
|
+
state.context.history.push(state.context.count);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
expect(displays[1]).toBe('Count: 1 (History: 0, 1)');
|
|
498
|
+
|
|
499
|
+
// Increment again
|
|
500
|
+
engine.apply((state) => {
|
|
501
|
+
state.context.count += 5;
|
|
502
|
+
state.context.history.push(state.context.count);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
expect(displays[2]).toBe('Count: 6 (History: 0, 1, 6)');
|
|
506
|
+
|
|
507
|
+
// Reset
|
|
508
|
+
engine.apply((state) => {
|
|
509
|
+
state.context.count = 0;
|
|
510
|
+
state.context.history = [0];
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
expect(displays[3]).toBe('Count: 0 (History: 0)');
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
});
|