@plures/praxis 1.4.4 → 2.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/README.md +164 -1067
- package/dist/browser/chunk-IUEKGHQN.js +373 -0
- package/dist/browser/factory/index.d.ts +2 -1
- package/dist/browser/index.d.ts +7 -4
- package/dist/browser/index.js +18 -6
- package/dist/browser/integrations/svelte.d.ts +4 -3
- package/dist/browser/project/index.d.ts +2 -1
- package/dist/browser/{reactive-engine.svelte-DgVTqHLc.d.ts → reactive-engine.svelte-BwWadvAW.d.ts} +2 -1
- package/dist/browser/rule-result-DcXWe9tn.d.ts +206 -0
- package/dist/browser/{rules-i1LHpnGd.d.ts → rules-BaWMqxuG.d.ts} +2 -205
- package/dist/browser/unified/index.d.ts +239 -0
- package/dist/browser/unified/index.js +20 -0
- package/dist/node/chunk-IUEKGHQN.js +373 -0
- package/dist/node/cli/index.js +1 -1
- package/dist/node/index.cjs +377 -0
- package/dist/node/index.d.cts +4 -2
- package/dist/node/index.d.ts +4 -2
- package/dist/node/index.js +19 -7
- package/dist/node/integrations/svelte.d.cts +3 -2
- package/dist/node/integrations/svelte.d.ts +3 -2
- package/dist/node/integrations/svelte.js +2 -2
- package/dist/node/{reactive-engine.svelte-DekxqFu0.d.ts → reactive-engine.svelte-BBZLMzus.d.ts} +3 -79
- package/dist/node/{reactive-engine.svelte-Cg0Yc2Hs.d.cts → reactive-engine.svelte-Cbq_V20o.d.cts} +3 -79
- package/dist/node/rule-result-B9GMivAn.d.cts +80 -0
- package/dist/node/rule-result-Bo3sFMmN.d.ts +80 -0
- package/dist/node/unified/index.cjs +494 -0
- package/dist/node/unified/index.d.cts +240 -0
- package/dist/node/unified/index.d.ts +240 -0
- package/dist/node/unified/index.js +21 -0
- package/docs/README.md +58 -102
- package/docs/archive/1.x/CONVERSATIONS_IMPLEMENTATION.md +207 -0
- package/docs/archive/1.x/DECISION_LEDGER_IMPLEMENTATION.md +109 -0
- package/docs/archive/1.x/DECISION_LEDGER_SUMMARY.md +424 -0
- package/docs/archive/1.x/ELEVATION_SUMMARY.md +249 -0
- package/docs/archive/1.x/FEATURE_SUMMARY.md +238 -0
- package/docs/archive/1.x/GOLDEN_PATH_IMPLEMENTATION.md +280 -0
- package/docs/archive/1.x/IMPLEMENTATION.md +166 -0
- package/docs/archive/1.x/IMPLEMENTATION_COMPLETE.md +389 -0
- package/docs/archive/1.x/IMPLEMENTATION_SUMMARY.md +59 -0
- package/docs/archive/1.x/INTEGRATION_ENHANCEMENT_SUMMARY.md +238 -0
- package/docs/archive/1.x/KNO_ENG_REFACTORING_SUMMARY.md +198 -0
- package/docs/archive/1.x/MONOREPO_SUMMARY.md +158 -0
- package/docs/archive/1.x/README.md +28 -0
- package/docs/archive/1.x/SVELTE_INTEGRATION_SUMMARY.md +415 -0
- package/docs/archive/1.x/TASK_1_COMPLETE.md +235 -0
- package/docs/archive/1.x/TASK_1_SUMMARY.md +281 -0
- package/docs/archive/1.x/VERSION_0.2.0_RELEASE_NOTES.md +288 -0
- package/docs/archive/1.x/ValidationChecklist.md +7 -0
- package/package.json +13 -1
- package/src/index.browser.ts +20 -0
- package/src/index.ts +21 -0
- package/src/unified/__tests__/unified-qa.test.ts +761 -0
- package/src/unified/__tests__/unified.test.ts +396 -0
- package/src/unified/core.ts +534 -0
- package/src/unified/index.ts +32 -0
- package/src/unified/rules.ts +66 -0
- package/src/unified/types.ts +148 -0
- package/dist/node/{chunk-ZO2LU4G4.js → chunk-WFRHXZBP.js} +3 -3
- package/dist/node/{validate-5PSWJTIC.js → validate-BY7JNY7H.js} +1 -1
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Praxis Unified Reactive Layer — Core
|
|
3
|
+
*
|
|
4
|
+
* createApp() → query() / mutate()
|
|
5
|
+
*
|
|
6
|
+
* The developer defines a schema + rules. Praxis handles:
|
|
7
|
+
* - Reactive state (backed by Unum graph DB)
|
|
8
|
+
* - Automatic rule evaluation on state changes
|
|
9
|
+
* - Constraint enforcement on mutations
|
|
10
|
+
* - Chronos logging for every state change
|
|
11
|
+
* - Liveness monitoring (detect broken plumbing)
|
|
12
|
+
* - Svelte-compatible store contract
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
PraxisAppConfig,
|
|
17
|
+
PathSchema,
|
|
18
|
+
QueryOptions,
|
|
19
|
+
ReactiveRef,
|
|
20
|
+
MutationResult,
|
|
21
|
+
UnifiedRule,
|
|
22
|
+
UnifiedConstraint,
|
|
23
|
+
} from './types.js';
|
|
24
|
+
import type { PraxisFact, PraxisDiagnostics } from '../core/protocol.js';
|
|
25
|
+
import { RuleResult } from '../core/rule-result.js';
|
|
26
|
+
|
|
27
|
+
// ── Internal State ──────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
interface PathState<T = unknown> {
|
|
30
|
+
schema: PathSchema<T>;
|
|
31
|
+
value: T;
|
|
32
|
+
subscribers: Set<(value: T) => void>;
|
|
33
|
+
lastUpdated: number;
|
|
34
|
+
updateCount: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface RuleState {
|
|
38
|
+
rule: UnifiedRule;
|
|
39
|
+
lastResult: RuleResult | null;
|
|
40
|
+
/** Tags this rule has emitted — used for auto-retraction on skip/noop */
|
|
41
|
+
emittedTags: Set<string>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Timeline Entry (Chronos-compatible) ─────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
interface TimelineEntry {
|
|
47
|
+
id: string;
|
|
48
|
+
timestamp: number;
|
|
49
|
+
path: string;
|
|
50
|
+
kind: 'mutation' | 'rule-eval' | 'constraint-check' | 'liveness';
|
|
51
|
+
data: Record<string, unknown>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Praxis App Instance ─────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
export interface PraxisApp {
|
|
57
|
+
/** Reactive query — returns a Svelte-compatible store */
|
|
58
|
+
query: <T>(path: string, opts?: QueryOptions<T>) => ReactiveRef<T>;
|
|
59
|
+
/** Write to the graph — validates through constraints first */
|
|
60
|
+
mutate: (path: string, value: unknown) => MutationResult;
|
|
61
|
+
/** Batch multiple mutations atomically */
|
|
62
|
+
batch: (fn: (mutate: (path: string, value: unknown) => void) => void) => MutationResult;
|
|
63
|
+
/** Current facts */
|
|
64
|
+
facts: () => PraxisFact[];
|
|
65
|
+
/** Current constraint violations */
|
|
66
|
+
violations: () => PraxisDiagnostics[];
|
|
67
|
+
/** Timeline (Chronos entries) */
|
|
68
|
+
timeline: () => TimelineEntry[];
|
|
69
|
+
/** Force re-evaluate all rules */
|
|
70
|
+
evaluate: () => void;
|
|
71
|
+
/** Cleanup */
|
|
72
|
+
destroy: () => void;
|
|
73
|
+
/** Liveness status — which paths are stale */
|
|
74
|
+
liveness: () => Record<string, { stale: boolean; lastUpdated: number; elapsed: number }>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Implementation ──────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
let _idCounter = 0;
|
|
80
|
+
function nextId(): string {
|
|
81
|
+
return `px:${Date.now()}-${++_idCounter}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create a Praxis application.
|
|
86
|
+
*
|
|
87
|
+
* This is the single entry point. It creates the reactive graph,
|
|
88
|
+
* wires rules and constraints, starts Chronos logging, and returns
|
|
89
|
+
* query() and mutate() — the only two functions a developer needs.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```ts
|
|
93
|
+
* import { createApp, definePath, defineRule } from '@plures/praxis';
|
|
94
|
+
*
|
|
95
|
+
* const Sprint = definePath<SprintInfo | null>('sprint/current', null);
|
|
96
|
+
* const Loading = definePath<boolean>('sprint/loading', false);
|
|
97
|
+
*
|
|
98
|
+
* const app = createApp({
|
|
99
|
+
* name: 'sprint-log',
|
|
100
|
+
* schema: [Sprint, Loading],
|
|
101
|
+
* rules: [sprintBehindRule, capacityRule],
|
|
102
|
+
* constraints: [noCloseWithoutHoursConstraint],
|
|
103
|
+
* });
|
|
104
|
+
*
|
|
105
|
+
* // In a Svelte component:
|
|
106
|
+
* const sprint = app.query<SprintInfo | null>('sprint/current');
|
|
107
|
+
* // $sprint is reactive — updates automatically
|
|
108
|
+
*
|
|
109
|
+
* // To write:
|
|
110
|
+
* app.mutate('sprint/current', sprintData);
|
|
111
|
+
* // Constraints validated, rules re-evaluated, Chronos logged — all automatic
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
export function createApp(config: PraxisAppConfig): PraxisApp {
|
|
115
|
+
// ── Path Registry ──
|
|
116
|
+
const paths = new Map<string, PathState>();
|
|
117
|
+
for (const schema of config.schema) {
|
|
118
|
+
paths.set(schema.path, {
|
|
119
|
+
schema,
|
|
120
|
+
value: schema.initial,
|
|
121
|
+
subscribers: new Set(),
|
|
122
|
+
lastUpdated: 0,
|
|
123
|
+
updateCount: 0,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Facts ──
|
|
128
|
+
let facts: PraxisFact[] = [];
|
|
129
|
+
const factMap = new Map<string, PraxisFact>(); // tag → latest fact (LWW)
|
|
130
|
+
|
|
131
|
+
// ── Timeline ──
|
|
132
|
+
const timeline: TimelineEntry[] = [];
|
|
133
|
+
const maxTimeline = 10000;
|
|
134
|
+
|
|
135
|
+
function recordTimeline(path: string, kind: TimelineEntry['kind'], data: Record<string, unknown>) {
|
|
136
|
+
const entry: TimelineEntry = {
|
|
137
|
+
id: nextId(),
|
|
138
|
+
timestamp: Date.now(),
|
|
139
|
+
path,
|
|
140
|
+
kind,
|
|
141
|
+
data,
|
|
142
|
+
};
|
|
143
|
+
timeline.push(entry);
|
|
144
|
+
if (timeline.length > maxTimeline) {
|
|
145
|
+
timeline.splice(0, timeline.length - maxTimeline);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Rules ──
|
|
150
|
+
const ruleStates: RuleState[] = (config.rules ?? []).map(rule => ({
|
|
151
|
+
rule,
|
|
152
|
+
lastResult: null,
|
|
153
|
+
emittedTags: new Set<string>(),
|
|
154
|
+
}));
|
|
155
|
+
|
|
156
|
+
// ── Constraints ──
|
|
157
|
+
const constraints: UnifiedConstraint[] = config.constraints ?? [];
|
|
158
|
+
|
|
159
|
+
// ── Liveness ──
|
|
160
|
+
const livenessConfig = config.liveness;
|
|
161
|
+
const initTime = Date.now();
|
|
162
|
+
let livenessTimer: ReturnType<typeof setTimeout> | null = null;
|
|
163
|
+
|
|
164
|
+
if (livenessConfig) {
|
|
165
|
+
const timeout = livenessConfig.timeoutMs ?? 5000;
|
|
166
|
+
livenessTimer = setTimeout(() => {
|
|
167
|
+
for (const expectedPath of livenessConfig.expect) {
|
|
168
|
+
const state = paths.get(expectedPath);
|
|
169
|
+
if (!state || state.updateCount === 0) {
|
|
170
|
+
const elapsed = Date.now() - initTime;
|
|
171
|
+
recordTimeline(expectedPath, 'liveness', {
|
|
172
|
+
stale: true,
|
|
173
|
+
elapsed,
|
|
174
|
+
message: `Path "${expectedPath}" never updated after ${elapsed}ms`,
|
|
175
|
+
});
|
|
176
|
+
livenessConfig.onStale?.(expectedPath, elapsed);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}, timeout);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Helpers ──
|
|
183
|
+
|
|
184
|
+
function getPathValues(watchPaths: string[]): Record<string, unknown> {
|
|
185
|
+
const values: Record<string, unknown> = {};
|
|
186
|
+
for (const p of watchPaths) {
|
|
187
|
+
const state = paths.get(p);
|
|
188
|
+
values[p] = state ? state.value : undefined;
|
|
189
|
+
}
|
|
190
|
+
return values;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function notify(path: string, value: unknown) {
|
|
194
|
+
const state = paths.get(path);
|
|
195
|
+
if (!state) return;
|
|
196
|
+
for (const cb of state.subscribers) {
|
|
197
|
+
try {
|
|
198
|
+
cb(value as any);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
console.error(`[praxis] Subscriber error for "${path}":`, err);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function checkConstraints(path: string, value: unknown): PraxisDiagnostics[] {
|
|
206
|
+
const violations: PraxisDiagnostics[] = [];
|
|
207
|
+
for (const c of constraints) {
|
|
208
|
+
if (!c.watch.includes(path)) continue;
|
|
209
|
+
// Build values with the proposed new value
|
|
210
|
+
const values = getPathValues(c.watch);
|
|
211
|
+
values[path] = value;
|
|
212
|
+
try {
|
|
213
|
+
const result = c.validate(values);
|
|
214
|
+
if (result !== true) {
|
|
215
|
+
violations.push({
|
|
216
|
+
kind: 'constraint-violation',
|
|
217
|
+
message: result,
|
|
218
|
+
data: { constraintId: c.id, path, description: c.description },
|
|
219
|
+
});
|
|
220
|
+
recordTimeline(path, 'constraint-check', {
|
|
221
|
+
constraintId: c.id,
|
|
222
|
+
violated: true,
|
|
223
|
+
message: result,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
} catch (err) {
|
|
227
|
+
violations.push({
|
|
228
|
+
kind: 'constraint-violation',
|
|
229
|
+
message: `Constraint "${c.id}" threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
230
|
+
data: { constraintId: c.id, error: err },
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return violations;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function evaluateRules() {
|
|
238
|
+
const newFacts: PraxisFact[] = [];
|
|
239
|
+
const retractions: string[] = [];
|
|
240
|
+
|
|
241
|
+
for (const rs of ruleStates) {
|
|
242
|
+
const values = getPathValues(rs.rule.watch);
|
|
243
|
+
try {
|
|
244
|
+
const result = rs.rule.evaluate(values, [...facts]);
|
|
245
|
+
rs.lastResult = result;
|
|
246
|
+
|
|
247
|
+
recordTimeline(rs.rule.watch[0] ?? '*', 'rule-eval', {
|
|
248
|
+
ruleId: rs.rule.id,
|
|
249
|
+
kind: result.kind,
|
|
250
|
+
reason: result.reason,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
switch (result.kind) {
|
|
254
|
+
case 'emit':
|
|
255
|
+
newFacts.push(...result.facts);
|
|
256
|
+
// Track which tags this rule has emitted
|
|
257
|
+
for (const f of result.facts) {
|
|
258
|
+
rs.emittedTags.add(f.tag);
|
|
259
|
+
}
|
|
260
|
+
break;
|
|
261
|
+
case 'retract':
|
|
262
|
+
retractions.push(...result.retractTags);
|
|
263
|
+
// Also clear tracked tags that are being retracted
|
|
264
|
+
for (const tag of result.retractTags) {
|
|
265
|
+
rs.emittedTags.delete(tag);
|
|
266
|
+
}
|
|
267
|
+
break;
|
|
268
|
+
case 'noop':
|
|
269
|
+
case 'skip':
|
|
270
|
+
// Auto-retract: if a rule previously emitted facts and now
|
|
271
|
+
// skips/noops, those facts should be cleaned up. Otherwise
|
|
272
|
+
// stale facts persist when preconditions become unmet.
|
|
273
|
+
if (rs.emittedTags.size > 0) {
|
|
274
|
+
retractions.push(...rs.emittedTags);
|
|
275
|
+
rs.emittedTags.clear();
|
|
276
|
+
}
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
} catch (err) {
|
|
280
|
+
console.error(`[praxis] Rule "${rs.rule.id}" error:`, err);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Apply retractions
|
|
285
|
+
if (retractions.length > 0) {
|
|
286
|
+
const retractSet = new Set(retractions);
|
|
287
|
+
for (const tag of retractSet) {
|
|
288
|
+
factMap.delete(tag);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Merge new facts (LWW)
|
|
293
|
+
for (const f of newFacts) {
|
|
294
|
+
factMap.set(f.tag, f);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
facts = Array.from(factMap.values());
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── Public API ──
|
|
301
|
+
|
|
302
|
+
function query<T>(path: string, opts?: QueryOptions<T>): ReactiveRef<T> {
|
|
303
|
+
// Ensure path exists
|
|
304
|
+
let state = paths.get(path);
|
|
305
|
+
if (!state) {
|
|
306
|
+
// Auto-create with undefined initial — allows querying unschema'd paths
|
|
307
|
+
state = {
|
|
308
|
+
schema: { path, initial: undefined as T },
|
|
309
|
+
value: undefined as T,
|
|
310
|
+
subscribers: new Set(),
|
|
311
|
+
lastUpdated: 0,
|
|
312
|
+
updateCount: 0,
|
|
313
|
+
};
|
|
314
|
+
paths.set(path, state);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Create a subscribable that filters/maps if opts provided
|
|
318
|
+
const ref: ReactiveRef<T> = {
|
|
319
|
+
get current() {
|
|
320
|
+
const s = paths.get(path);
|
|
321
|
+
return applyQueryOpts((s?.value ?? state!.schema.initial) as T, opts);
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
subscribe(cb: (value: T) => void): () => void {
|
|
325
|
+
// Wrap callback to apply query opts
|
|
326
|
+
const wrappedCb = (rawValue: unknown) => {
|
|
327
|
+
const processed = applyQueryOpts(rawValue as T, opts);
|
|
328
|
+
cb(processed);
|
|
329
|
+
};
|
|
330
|
+
const s = paths.get(path)!;
|
|
331
|
+
s.subscribers.add(wrappedCb as any);
|
|
332
|
+
|
|
333
|
+
// Immediate callback with current value (Svelte store contract)
|
|
334
|
+
try {
|
|
335
|
+
cb(ref.current);
|
|
336
|
+
} catch (err) {
|
|
337
|
+
console.error(`[praxis] query("${path}") subscriber init error:`, err);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return () => {
|
|
341
|
+
s.subscribers.delete(wrappedCb as any);
|
|
342
|
+
};
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
return ref;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function applyQueryOpts<T>(value: T, opts?: QueryOptions<T>): T {
|
|
350
|
+
if (!opts || !Array.isArray(value)) return value;
|
|
351
|
+
let result: any[] = [...(value as any[])];
|
|
352
|
+
if (opts.where) result = result.filter(opts.where as any);
|
|
353
|
+
if (opts.sort) result.sort(opts.sort);
|
|
354
|
+
if (opts.select) result = result.map(opts.select as any);
|
|
355
|
+
if (opts.limit) result = result.slice(0, opts.limit);
|
|
356
|
+
return result as unknown as T;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function mutateInternal(path: string, value: unknown): { violations: PraxisDiagnostics[]; emittedFacts: PraxisFact[] } {
|
|
360
|
+
// 1. Check constraints
|
|
361
|
+
const violations = checkConstraints(path, value);
|
|
362
|
+
if (violations.length > 0) {
|
|
363
|
+
return { violations, emittedFacts: [] };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// 2. Write to graph
|
|
367
|
+
const state = paths.get(path);
|
|
368
|
+
if (!state) {
|
|
369
|
+
// Auto-create path
|
|
370
|
+
paths.set(path, {
|
|
371
|
+
schema: { path, initial: value },
|
|
372
|
+
value,
|
|
373
|
+
subscribers: new Set(),
|
|
374
|
+
lastUpdated: Date.now(),
|
|
375
|
+
updateCount: 1,
|
|
376
|
+
});
|
|
377
|
+
} else {
|
|
378
|
+
const before = state.value;
|
|
379
|
+
state.value = value;
|
|
380
|
+
state.lastUpdated = Date.now();
|
|
381
|
+
state.updateCount++;
|
|
382
|
+
|
|
383
|
+
// 3. Log to timeline
|
|
384
|
+
recordTimeline(path, 'mutation', {
|
|
385
|
+
before: summarize(before),
|
|
386
|
+
after: summarize(value),
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// 4. Notify subscribers
|
|
391
|
+
notify(path, value);
|
|
392
|
+
|
|
393
|
+
// 5. Re-evaluate rules
|
|
394
|
+
const factsBefore = facts.length;
|
|
395
|
+
evaluateRules();
|
|
396
|
+
const emittedFacts = facts.slice(factsBefore);
|
|
397
|
+
|
|
398
|
+
return { violations: [], emittedFacts };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function mutate(path: string, value: unknown): MutationResult {
|
|
402
|
+
const { violations, emittedFacts } = mutateInternal(path, value);
|
|
403
|
+
return {
|
|
404
|
+
accepted: violations.length === 0,
|
|
405
|
+
violations,
|
|
406
|
+
facts: emittedFacts,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function batchMutate(fn: (m: (path: string, value: unknown) => void) => void): MutationResult {
|
|
411
|
+
const allViolations: PraxisDiagnostics[] = [];
|
|
412
|
+
const pendingWrites: Array<{ path: string; value: unknown }> = [];
|
|
413
|
+
|
|
414
|
+
// Collect all mutations
|
|
415
|
+
fn((path, value) => {
|
|
416
|
+
// Check constraints eagerly
|
|
417
|
+
const violations = checkConstraints(path, value);
|
|
418
|
+
if (violations.length > 0) {
|
|
419
|
+
allViolations.push(...violations);
|
|
420
|
+
} else {
|
|
421
|
+
pendingWrites.push({ path, value });
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// If any constraint failed, reject the whole batch
|
|
426
|
+
if (allViolations.length > 0) {
|
|
427
|
+
return { accepted: false, violations: allViolations, facts: [] };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Apply all writes
|
|
431
|
+
for (const { path, value } of pendingWrites) {
|
|
432
|
+
const state = paths.get(path);
|
|
433
|
+
if (state) {
|
|
434
|
+
const before = state.value;
|
|
435
|
+
state.value = value;
|
|
436
|
+
state.lastUpdated = Date.now();
|
|
437
|
+
state.updateCount++;
|
|
438
|
+
recordTimeline(path, 'mutation', {
|
|
439
|
+
before: summarize(before),
|
|
440
|
+
after: summarize(value),
|
|
441
|
+
});
|
|
442
|
+
} else {
|
|
443
|
+
paths.set(path, {
|
|
444
|
+
schema: { path, initial: value },
|
|
445
|
+
value,
|
|
446
|
+
subscribers: new Set(),
|
|
447
|
+
lastUpdated: Date.now(),
|
|
448
|
+
updateCount: 1,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Notify all at once
|
|
454
|
+
for (const { path, value } of pendingWrites) {
|
|
455
|
+
notify(path, value);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Single rule evaluation pass
|
|
459
|
+
const factsBefore = facts.length;
|
|
460
|
+
evaluateRules();
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
accepted: true,
|
|
464
|
+
violations: [],
|
|
465
|
+
facts: facts.slice(factsBefore),
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function getLiveness(): Record<string, { stale: boolean; lastUpdated: number; elapsed: number }> {
|
|
470
|
+
const result: Record<string, { stale: boolean; lastUpdated: number; elapsed: number }> = {};
|
|
471
|
+
const now = Date.now();
|
|
472
|
+
const watchPaths = livenessConfig?.expect ?? [];
|
|
473
|
+
for (const p of watchPaths) {
|
|
474
|
+
const state = paths.get(p);
|
|
475
|
+
const lastUpdated = state?.lastUpdated ?? 0;
|
|
476
|
+
const elapsed = lastUpdated > 0 ? now - lastUpdated : now - initTime;
|
|
477
|
+
result[p] = {
|
|
478
|
+
stale: !state || state.updateCount === 0,
|
|
479
|
+
lastUpdated,
|
|
480
|
+
elapsed,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
return result;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function destroy() {
|
|
487
|
+
if (livenessTimer) clearTimeout(livenessTimer);
|
|
488
|
+
for (const state of paths.values()) {
|
|
489
|
+
state.subscribers.clear();
|
|
490
|
+
}
|
|
491
|
+
paths.clear();
|
|
492
|
+
facts = [];
|
|
493
|
+
factMap.clear();
|
|
494
|
+
timeline.length = 0;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return {
|
|
498
|
+
query,
|
|
499
|
+
mutate,
|
|
500
|
+
batch: batchMutate,
|
|
501
|
+
facts: () => [...facts],
|
|
502
|
+
violations: () => {
|
|
503
|
+
const allViolations: PraxisDiagnostics[] = [];
|
|
504
|
+
for (const c of constraints) {
|
|
505
|
+
const values = getPathValues(c.watch);
|
|
506
|
+
try {
|
|
507
|
+
const result = c.validate(values);
|
|
508
|
+
if (result !== true) {
|
|
509
|
+
allViolations.push({
|
|
510
|
+
kind: 'constraint-violation',
|
|
511
|
+
message: result,
|
|
512
|
+
data: { constraintId: c.id },
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
} catch { /* skip */ }
|
|
516
|
+
}
|
|
517
|
+
return allViolations;
|
|
518
|
+
},
|
|
519
|
+
timeline: () => [...timeline],
|
|
520
|
+
evaluate: evaluateRules,
|
|
521
|
+
destroy,
|
|
522
|
+
liveness: getLiveness,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Summarize a value for timeline logging (avoid huge payloads) */
|
|
527
|
+
function summarize(value: unknown): unknown {
|
|
528
|
+
if (value === null || value === undefined) return value;
|
|
529
|
+
if (typeof value !== 'object') return value;
|
|
530
|
+
if (Array.isArray(value)) return `[Array(${value.length})]`;
|
|
531
|
+
const keys = Object.keys(value as object);
|
|
532
|
+
if (keys.length > 10) return `{Object(${keys.length} keys)}`;
|
|
533
|
+
return value;
|
|
534
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Praxis Unified — Public API
|
|
3
|
+
*
|
|
4
|
+
* This is the only import developers need:
|
|
5
|
+
*
|
|
6
|
+
* import { createApp, definePath, defineRule, defineConstraint } from '@plures/praxis/unified';
|
|
7
|
+
*
|
|
8
|
+
* Everything else — stores, subscriptions, logging, validation — is automatic.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { createApp } from './core.js';
|
|
12
|
+
export type { PraxisApp } from './core.js';
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
definePath,
|
|
16
|
+
type PathSchema,
|
|
17
|
+
type QueryOptions,
|
|
18
|
+
type ReactiveRef,
|
|
19
|
+
type MutationResult,
|
|
20
|
+
type UnifiedRule,
|
|
21
|
+
type UnifiedConstraint,
|
|
22
|
+
type LivenessConfig,
|
|
23
|
+
type PraxisAppConfig,
|
|
24
|
+
} from './types.js';
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
defineRule,
|
|
28
|
+
defineConstraint,
|
|
29
|
+
defineModule,
|
|
30
|
+
RuleResult,
|
|
31
|
+
fact,
|
|
32
|
+
} from './rules.js';
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Praxis Unified — Rule DSL helpers
|
|
3
|
+
*
|
|
4
|
+
* Developers define rules as plain objects with watch paths.
|
|
5
|
+
* No manual subscriptions, no wirePraxis(), no context mapping.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { UnifiedRule, UnifiedConstraint } from './types.js';
|
|
9
|
+
import { RuleResult, fact } from '../core/rule-result.js';
|
|
10
|
+
|
|
11
|
+
export { RuleResult, fact };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Define a rule that watches graph paths and auto-evaluates.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* const sprintBehind = defineRule({
|
|
18
|
+
* id: 'sprint.behind',
|
|
19
|
+
* watch: ['sprint/current'],
|
|
20
|
+
* evaluate: (values) => {
|
|
21
|
+
* const sprint = values['sprint/current'];
|
|
22
|
+
* if (!sprint) return RuleResult.skip('No sprint');
|
|
23
|
+
* const pace = sprint.currentDay / sprint.totalDays;
|
|
24
|
+
* const work = sprint.completedHours / sprint.totalHours;
|
|
25
|
+
* if (work >= pace) return RuleResult.retract(['sprint.behind']);
|
|
26
|
+
* return RuleResult.emit([fact('sprint.behind', { pace, work })]);
|
|
27
|
+
* }
|
|
28
|
+
* });
|
|
29
|
+
*/
|
|
30
|
+
export function defineRule(rule: UnifiedRule): UnifiedRule {
|
|
31
|
+
return rule;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Define a constraint that validates mutations before they're applied.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* const noCloseWithoutHours = defineConstraint({
|
|
39
|
+
* id: 'no-close-without-hours',
|
|
40
|
+
* description: 'Cannot close a work item with 0 completed hours',
|
|
41
|
+
* watch: ['sprint/items'],
|
|
42
|
+
* validate: (values) => {
|
|
43
|
+
* const items = values['sprint/items'] ?? [];
|
|
44
|
+
* const bad = items.find(i => i.state === 'Closed' && !i.completedWork);
|
|
45
|
+
* if (bad) return `Item #${bad.id} cannot be closed with 0 hours`;
|
|
46
|
+
* return true;
|
|
47
|
+
* }
|
|
48
|
+
* });
|
|
49
|
+
*/
|
|
50
|
+
export function defineConstraint(constraint: UnifiedConstraint): UnifiedConstraint {
|
|
51
|
+
return constraint;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Compose multiple rules into a named module.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* const sprintModule = defineModule('sprint-health', [
|
|
59
|
+
* sprintBehindRule,
|
|
60
|
+
* capacityRule,
|
|
61
|
+
* endNearRule,
|
|
62
|
+
* ]);
|
|
63
|
+
*/
|
|
64
|
+
export function defineModule(name: string, rules: UnifiedRule[]): { name: string; rules: UnifiedRule[] } {
|
|
65
|
+
return { name, rules };
|
|
66
|
+
}
|