@plures/praxis 1.2.41 → 1.4.0
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/dist/browser/{chunk-BBP2F7TT.js → chunk-MJK3IYTJ.js} +123 -5
- package/dist/browser/{chunk-FCEH7WMH.js → chunk-N63K4KWS.js} +1 -1
- package/dist/browser/{engine-65QDGCAN.js → engine-YIEGSX7U.js} +1 -1
- package/dist/browser/index.d.ts +2 -2
- package/dist/browser/index.js +10 -5
- package/dist/browser/integrations/svelte.d.ts +2 -2
- package/dist/browser/integrations/svelte.js +2 -2
- package/dist/browser/{reactive-engine.svelte-Cqd8Mod2.d.ts → reactive-engine.svelte-DjynI82A.d.ts} +83 -4
- package/dist/node/chunk-2IUFZBH3.js +87 -0
- package/dist/node/{chunk-WZ6B3LZ6.js → chunk-7CSWBDFL.js} +3 -56
- package/dist/node/{chunk-32YFEEML.js → chunk-7M3HV4XR.js} +4 -4
- package/dist/node/{chunk-PTH6MD6P.js → chunk-FWOXU4MM.js} +1 -1
- package/dist/node/{chunk-BBP2F7TT.js → chunk-KMJWAFZV.js} +128 -5
- package/dist/node/chunk-PGVSB6NR.js +59 -0
- package/dist/node/cli/index.cjs +1078 -211
- package/dist/node/cli/index.js +21 -2
- package/dist/node/cloud/index.d.cts +1 -1
- package/dist/node/cloud/index.d.ts +1 -1
- package/dist/node/{engine-7CXQV6RC.js → engine-FEN5IYZ5.js} +1 -1
- package/dist/node/index.cjs +1633 -59
- package/dist/node/index.d.cts +769 -5
- package/dist/node/index.d.ts +769 -5
- package/dist/node/index.js +1375 -45
- package/dist/node/integrations/svelte.cjs +123 -5
- package/dist/node/integrations/svelte.d.cts +3 -3
- package/dist/node/integrations/svelte.d.ts +3 -3
- package/dist/node/integrations/svelte.js +3 -3
- package/dist/node/{protocol-BocKczNv.d.ts → protocol-DcyGMmWY.d.cts} +7 -0
- package/dist/node/{protocol-BocKczNv.d.cts → protocol-DcyGMmWY.d.ts} +7 -0
- package/dist/node/{reactive-engine.svelte-CGe8SpVE.d.cts → reactive-engine.svelte-Cg0Yc2Hs.d.cts} +90 -6
- package/dist/node/{reactive-engine.svelte-D-xTDxT5.d.ts → reactive-engine.svelte-DekxqFu0.d.ts} +90 -6
- package/dist/node/{reverse-W7THPV45.js → reverse-YD3CWIGM.js} +3 -2
- package/dist/node/rules-4DAJ4Z4N.js +7 -0
- package/dist/node/server-SYZPDULV.js +361 -0
- package/dist/node/{validate-EN3M4FUR.js → validate-TQGVIG7G.js} +4 -3
- package/package.json +29 -3
- package/src/__tests__/engine-v2.test.ts +532 -0
- package/src/__tests__/expectations.test.ts +364 -0
- package/src/__tests__/factory.test.ts +426 -0
- package/src/__tests__/mcp-server.test.ts +310 -0
- package/src/__tests__/project.test.ts +396 -0
- package/src/cli/index.ts +28 -0
- package/src/core/completeness.ts +274 -0
- package/src/core/engine.ts +47 -5
- package/src/core/pluresdb/store.ts +9 -3
- package/src/core/protocol.ts +7 -0
- package/src/core/rule-result.ts +130 -0
- package/src/core/rules.ts +12 -5
- package/src/core/ui-rules.ts +340 -0
- package/src/expectations/expectations.ts +471 -0
- package/src/expectations/index.ts +29 -0
- package/src/expectations/types.ts +95 -0
- package/src/factory/factory.ts +634 -0
- package/src/factory/index.ts +27 -0
- package/src/factory/types.ts +64 -0
- package/src/index.ts +84 -0
- package/src/mcp/index.ts +33 -0
- package/src/mcp/server.ts +485 -0
- package/src/mcp/types.ts +161 -0
- package/src/project/index.ts +31 -0
- package/src/project/project.ts +423 -0
- package/src/project/types.ts +87 -0
- package/src/vite/completeness-plugin.ts +72 -0
- /package/dist/node/{chunk-R2PSBPKQ.js → chunk-TEMFJOIH.js} +0 -0
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Praxis Rules Factory
|
|
3
|
+
*
|
|
4
|
+
* Predefined rule modules for common application patterns.
|
|
5
|
+
* Each factory returns a PraxisModule with rules, constraints, and contracts.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { inputRules, toastRules, formRules } from '@plures/praxis/factory';
|
|
10
|
+
*
|
|
11
|
+
* const registry = new PraxisRegistry();
|
|
12
|
+
* registry.registerModule(inputRules({ sanitize: ['xss', 'sql-injection'], required: true }));
|
|
13
|
+
* registry.registerModule(toastRules({ requireDiff: true, deduplicate: true }));
|
|
14
|
+
* registry.registerModule(formRules({ validateOnBlur: true, submitGate: true }));
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { PraxisModule, RuleDescriptor, ConstraintDescriptor } from '../core/rules.js';
|
|
19
|
+
import { RuleResult, fact } from '../core/rule-result.js';
|
|
20
|
+
import type {
|
|
21
|
+
InputRulesConfig,
|
|
22
|
+
ToastRulesConfig,
|
|
23
|
+
FormRulesConfig,
|
|
24
|
+
NavigationRulesConfig,
|
|
25
|
+
DataRulesConfig,
|
|
26
|
+
SanitizationType,
|
|
27
|
+
} from './types.js';
|
|
28
|
+
|
|
29
|
+
// ─── Sanitization Patterns ──────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const SANITIZE_PATTERNS: Record<SanitizationType, RegExp> = {
|
|
32
|
+
'sql-injection': /('|"|;|--|\/\*|\*\/|xp_|exec\s|union\s+select|drop\s+table|insert\s+into|delete\s+from)/i,
|
|
33
|
+
'xss': /(<script|javascript:|on\w+\s*=|<iframe|<object|<embed|<img[^>]+onerror)/i,
|
|
34
|
+
'path-traversal': /(\.\.[/\\]|~\/|\/etc\/|\/proc\/)/i,
|
|
35
|
+
'command-injection': /([;&|`$]|\$\(|>\s*\/)/i,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// ─── Input Rules Factory ────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/** Context type for input rules. */
|
|
41
|
+
interface InputContext {
|
|
42
|
+
input?: {
|
|
43
|
+
value?: string;
|
|
44
|
+
field?: string;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create input validation rules module.
|
|
50
|
+
*
|
|
51
|
+
* Generates rules for sanitizing user input, enforcing length limits,
|
|
52
|
+
* and requiring non-empty values.
|
|
53
|
+
*/
|
|
54
|
+
export function inputRules(config: InputRulesConfig = {}): PraxisModule<InputContext> {
|
|
55
|
+
const {
|
|
56
|
+
sanitize = [],
|
|
57
|
+
maxLength = 0,
|
|
58
|
+
required = false,
|
|
59
|
+
fieldName = 'input',
|
|
60
|
+
} = config;
|
|
61
|
+
|
|
62
|
+
const rules: RuleDescriptor<InputContext>[] = [];
|
|
63
|
+
const constraints: ConstraintDescriptor<InputContext>[] = [];
|
|
64
|
+
|
|
65
|
+
// Sanitization rule
|
|
66
|
+
if (sanitize.length > 0) {
|
|
67
|
+
rules.push({
|
|
68
|
+
id: `factory/input.sanitize-${fieldName}`,
|
|
69
|
+
description: `Validates ${fieldName} against ${sanitize.join(', ')} patterns`,
|
|
70
|
+
eventTypes: [`${fieldName}.submit`, `${fieldName}.change`],
|
|
71
|
+
contract: {
|
|
72
|
+
ruleId: `factory/input.sanitize-${fieldName}`,
|
|
73
|
+
behavior: `Checks ${fieldName} for dangerous patterns: ${sanitize.join(', ')}`,
|
|
74
|
+
examples: [
|
|
75
|
+
{ given: `${fieldName} contains safe text`, when: 'input submitted', then: 'input.valid emitted' },
|
|
76
|
+
{ given: `${fieldName} contains <script> tag`, when: 'input submitted', then: 'input.violation emitted' },
|
|
77
|
+
],
|
|
78
|
+
invariants: [
|
|
79
|
+
`Dangerous ${fieldName} patterns must never pass validation`,
|
|
80
|
+
'All violations must include the violation type',
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
impl: (state, events) => {
|
|
84
|
+
const inputEvent = events.find(e =>
|
|
85
|
+
e.tag === `${fieldName}.submit` || e.tag === `${fieldName}.change`,
|
|
86
|
+
);
|
|
87
|
+
if (!inputEvent) return RuleResult.skip('No input event');
|
|
88
|
+
|
|
89
|
+
const value = (inputEvent.payload as { value?: string })?.value ?? state.context.input?.value ?? '';
|
|
90
|
+
const violations: string[] = [];
|
|
91
|
+
|
|
92
|
+
for (const type of sanitize) {
|
|
93
|
+
const pattern = SANITIZE_PATTERNS[type];
|
|
94
|
+
if (pattern && pattern.test(value)) {
|
|
95
|
+
violations.push(type);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (violations.length > 0) {
|
|
100
|
+
return RuleResult.emit([
|
|
101
|
+
fact(`${fieldName}.violation`, {
|
|
102
|
+
field: fieldName,
|
|
103
|
+
violations,
|
|
104
|
+
message: `Input failed sanitization: ${violations.join(', ')}`,
|
|
105
|
+
}),
|
|
106
|
+
]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return RuleResult.emit([
|
|
110
|
+
fact(`${fieldName}.valid`, { field: fieldName, sanitized: true }),
|
|
111
|
+
]);
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Max length constraint
|
|
117
|
+
if (maxLength > 0) {
|
|
118
|
+
constraints.push({
|
|
119
|
+
id: `factory/input.max-length-${fieldName}`,
|
|
120
|
+
description: `${fieldName} must not exceed ${maxLength} characters`,
|
|
121
|
+
contract: {
|
|
122
|
+
ruleId: `factory/input.max-length-${fieldName}`,
|
|
123
|
+
behavior: `Enforces max length of ${maxLength} for ${fieldName}`,
|
|
124
|
+
examples: [
|
|
125
|
+
{ given: `${fieldName} is 10 chars`, when: `maxLength is ${maxLength}`, then: maxLength >= 10 ? 'passes' : 'violation' },
|
|
126
|
+
],
|
|
127
|
+
invariants: [`${fieldName} length must never exceed ${maxLength}`],
|
|
128
|
+
},
|
|
129
|
+
impl: (state) => {
|
|
130
|
+
const value = state.context.input?.value ?? '';
|
|
131
|
+
if (value.length > maxLength) {
|
|
132
|
+
return `${fieldName} exceeds maximum length of ${maxLength} (got ${value.length})`;
|
|
133
|
+
}
|
|
134
|
+
return true;
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Required constraint
|
|
140
|
+
if (required) {
|
|
141
|
+
constraints.push({
|
|
142
|
+
id: `factory/input.required-${fieldName}`,
|
|
143
|
+
description: `${fieldName} is required and must not be empty`,
|
|
144
|
+
contract: {
|
|
145
|
+
ruleId: `factory/input.required-${fieldName}`,
|
|
146
|
+
behavior: `Enforces that ${fieldName} is non-empty`,
|
|
147
|
+
examples: [
|
|
148
|
+
{ given: `${fieldName} is "hello"`, when: 'checked', then: 'passes' },
|
|
149
|
+
{ given: `${fieldName} is empty`, when: 'checked', then: 'violation' },
|
|
150
|
+
],
|
|
151
|
+
invariants: [`${fieldName} must never be empty when required`],
|
|
152
|
+
},
|
|
153
|
+
impl: (state) => {
|
|
154
|
+
const value = state.context.input?.value ?? '';
|
|
155
|
+
if (value.trim().length === 0) {
|
|
156
|
+
return `${fieldName} is required but empty`;
|
|
157
|
+
}
|
|
158
|
+
return true;
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { rules, constraints };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── Toast Rules Factory ────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
/** Context type for toast rules. */
|
|
169
|
+
interface ToastContext {
|
|
170
|
+
diff?: Record<string, unknown> | null;
|
|
171
|
+
toasts?: Array<{ message: string; id: string }>;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Create truthful toast notification rules.
|
|
176
|
+
*
|
|
177
|
+
* Generates rules that ensure toasts only appear with meaningful content,
|
|
178
|
+
* auto-dismiss after a timeout, and avoid duplicates.
|
|
179
|
+
*/
|
|
180
|
+
export function toastRules(config: ToastRulesConfig = {}): PraxisModule<ToastContext> {
|
|
181
|
+
const {
|
|
182
|
+
requireDiff = false,
|
|
183
|
+
autoDismissMs = 0,
|
|
184
|
+
deduplicate = false,
|
|
185
|
+
} = config;
|
|
186
|
+
|
|
187
|
+
const rules: RuleDescriptor<ToastContext>[] = [];
|
|
188
|
+
const constraints: ConstraintDescriptor<ToastContext>[] = [];
|
|
189
|
+
|
|
190
|
+
// Toast emission rule
|
|
191
|
+
rules.push({
|
|
192
|
+
id: 'factory/toast.show',
|
|
193
|
+
description: 'Emits toast notification with content and config',
|
|
194
|
+
eventTypes: ['toast.request'],
|
|
195
|
+
contract: {
|
|
196
|
+
ruleId: 'factory/toast.show',
|
|
197
|
+
behavior: 'Shows toast when requested, respecting diff requirement and auto-dismiss',
|
|
198
|
+
examples: [
|
|
199
|
+
{ given: 'toast requested with message', when: 'toast.request fires', then: 'toast.show emitted' },
|
|
200
|
+
...(requireDiff ? [{ given: 'no diff present', when: 'toast.request fires', then: 'toast skipped' }] : []),
|
|
201
|
+
],
|
|
202
|
+
invariants: [
|
|
203
|
+
'Toast message must be non-empty',
|
|
204
|
+
...(requireDiff ? ['Toast must not appear when diff is empty'] : []),
|
|
205
|
+
],
|
|
206
|
+
},
|
|
207
|
+
impl: (state, events) => {
|
|
208
|
+
const toastEvent = events.find(e => e.tag === 'toast.request');
|
|
209
|
+
if (!toastEvent) return RuleResult.skip('No toast request');
|
|
210
|
+
|
|
211
|
+
const payload = toastEvent.payload as { message?: string; type?: string };
|
|
212
|
+
const message = payload.message ?? '';
|
|
213
|
+
|
|
214
|
+
if (!message) return RuleResult.skip('Empty toast message');
|
|
215
|
+
|
|
216
|
+
if (requireDiff) {
|
|
217
|
+
const diff = state.context.diff;
|
|
218
|
+
if (!diff || Object.keys(diff).length === 0) {
|
|
219
|
+
return RuleResult.skip('No diff — toast suppressed');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return RuleResult.emit([
|
|
224
|
+
fact('toast.show', {
|
|
225
|
+
message,
|
|
226
|
+
type: payload.type ?? 'info',
|
|
227
|
+
autoDismissMs: autoDismissMs > 0 ? autoDismissMs : undefined,
|
|
228
|
+
timestamp: Date.now(),
|
|
229
|
+
}),
|
|
230
|
+
]);
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Deduplication constraint
|
|
235
|
+
if (deduplicate) {
|
|
236
|
+
constraints.push({
|
|
237
|
+
id: 'factory/toast.no-duplicates',
|
|
238
|
+
description: 'Prevents duplicate toast messages',
|
|
239
|
+
contract: {
|
|
240
|
+
ruleId: 'factory/toast.no-duplicates',
|
|
241
|
+
behavior: 'Rejects toast if identical message is already showing',
|
|
242
|
+
examples: [
|
|
243
|
+
{ given: 'same toast already visible', when: 'duplicate toast requested', then: 'violation' },
|
|
244
|
+
],
|
|
245
|
+
invariants: ['No two toasts may have the same message simultaneously'],
|
|
246
|
+
},
|
|
247
|
+
impl: (state) => {
|
|
248
|
+
const toasts = state.context.toasts ?? [];
|
|
249
|
+
const messages = toasts.map(t => t.message);
|
|
250
|
+
const uniqueMessages = new Set(messages);
|
|
251
|
+
if (uniqueMessages.size < messages.length) {
|
|
252
|
+
return 'Duplicate toast detected';
|
|
253
|
+
}
|
|
254
|
+
return true;
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { rules, constraints };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ─── Form Rules Factory ─────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
/** Context type for form rules. */
|
|
265
|
+
interface FormContext {
|
|
266
|
+
form?: {
|
|
267
|
+
fields?: Record<string, { value: unknown; error?: string; touched?: boolean }>;
|
|
268
|
+
valid?: boolean;
|
|
269
|
+
dirty?: boolean;
|
|
270
|
+
submitting?: boolean;
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Create form lifecycle rules.
|
|
276
|
+
*
|
|
277
|
+
* Generates rules for field validation on blur, submit gating,
|
|
278
|
+
* and form state management.
|
|
279
|
+
*/
|
|
280
|
+
export function formRules(config: FormRulesConfig = {}): PraxisModule<FormContext> {
|
|
281
|
+
const {
|
|
282
|
+
validateOnBlur = false,
|
|
283
|
+
submitGate = false,
|
|
284
|
+
formName = 'form',
|
|
285
|
+
} = config;
|
|
286
|
+
|
|
287
|
+
const rules: RuleDescriptor<FormContext>[] = [];
|
|
288
|
+
const constraints: ConstraintDescriptor<FormContext>[] = [];
|
|
289
|
+
|
|
290
|
+
// Validate on blur rule
|
|
291
|
+
if (validateOnBlur) {
|
|
292
|
+
rules.push({
|
|
293
|
+
id: `factory/${formName}.validate-on-blur`,
|
|
294
|
+
description: `Triggers field validation when a ${formName} field loses focus`,
|
|
295
|
+
eventTypes: [`${formName}.blur`],
|
|
296
|
+
contract: {
|
|
297
|
+
ruleId: `factory/${formName}.validate-on-blur`,
|
|
298
|
+
behavior: `Validates the blurred field and emits validation result`,
|
|
299
|
+
examples: [
|
|
300
|
+
{ given: `${formName} field has value`, when: 'field loses focus', then: 'validation result emitted' },
|
|
301
|
+
],
|
|
302
|
+
invariants: ['Validation must run for every blur event on a registered field'],
|
|
303
|
+
},
|
|
304
|
+
impl: (_state, events) => {
|
|
305
|
+
const blurEvent = events.find(e => e.tag === `${formName}.blur`);
|
|
306
|
+
if (!blurEvent) return RuleResult.skip('No blur event');
|
|
307
|
+
|
|
308
|
+
const payload = blurEvent.payload as { field?: string; value?: unknown };
|
|
309
|
+
const field = payload.field ?? 'unknown';
|
|
310
|
+
const value = payload.value;
|
|
311
|
+
|
|
312
|
+
// Basic presence check — real apps would have per-field validators
|
|
313
|
+
const valid = value !== null && value !== undefined && value !== '';
|
|
314
|
+
|
|
315
|
+
return RuleResult.emit([
|
|
316
|
+
fact(`${formName}.field-validated`, {
|
|
317
|
+
field,
|
|
318
|
+
valid,
|
|
319
|
+
error: valid ? null : `${field} is required`,
|
|
320
|
+
}),
|
|
321
|
+
]);
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Submit gate constraint
|
|
327
|
+
if (submitGate) {
|
|
328
|
+
constraints.push({
|
|
329
|
+
id: `factory/${formName}.submit-gate`,
|
|
330
|
+
description: `Prevents ${formName} submission when validation has not passed`,
|
|
331
|
+
contract: {
|
|
332
|
+
ruleId: `factory/${formName}.submit-gate`,
|
|
333
|
+
behavior: `Blocks form submission until all fields are valid`,
|
|
334
|
+
examples: [
|
|
335
|
+
{ given: `${formName} is invalid`, when: 'submit attempted', then: 'violation — submission blocked' },
|
|
336
|
+
{ given: `${formName} is valid`, when: 'submit attempted', then: 'passes' },
|
|
337
|
+
],
|
|
338
|
+
invariants: ['Form must not submit while any field has errors'],
|
|
339
|
+
},
|
|
340
|
+
impl: (state) => {
|
|
341
|
+
const form = state.context.form;
|
|
342
|
+
if (!form) return true; // no form state = allow
|
|
343
|
+
if (form.submitting && !form.valid) {
|
|
344
|
+
return `${formName} cannot submit: validation has not passed`;
|
|
345
|
+
}
|
|
346
|
+
return true;
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Form dirty tracking rule
|
|
352
|
+
rules.push({
|
|
353
|
+
id: `factory/${formName}.dirty-tracking`,
|
|
354
|
+
description: `Tracks whether ${formName} has unsaved changes`,
|
|
355
|
+
eventTypes: [`${formName}.change`, `${formName}.reset`],
|
|
356
|
+
contract: {
|
|
357
|
+
ruleId: `factory/${formName}.dirty-tracking`,
|
|
358
|
+
behavior: 'Emits dirty state when form fields change, clears on reset',
|
|
359
|
+
examples: [
|
|
360
|
+
{ given: 'field value changed', when: 'form.change fires', then: 'form.dirty emitted' },
|
|
361
|
+
{ given: 'form reset', when: 'form.reset fires', then: 'form.dirty retracted' },
|
|
362
|
+
],
|
|
363
|
+
invariants: ['Dirty state must reflect actual field changes'],
|
|
364
|
+
},
|
|
365
|
+
impl: (_state, events) => {
|
|
366
|
+
const resetEvent = events.find(e => e.tag === `${formName}.reset`);
|
|
367
|
+
if (resetEvent) {
|
|
368
|
+
return RuleResult.retract([`${formName}.dirty`], 'Form reset');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const changeEvent = events.find(e => e.tag === `${formName}.change`);
|
|
372
|
+
if (changeEvent) {
|
|
373
|
+
return RuleResult.emit([
|
|
374
|
+
fact(`${formName}.dirty`, { dirty: true }),
|
|
375
|
+
]);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return RuleResult.skip('No form event');
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
return { rules, constraints };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ─── Navigation Rules Factory ───────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
/** Context type for navigation rules. */
|
|
388
|
+
interface NavigationContext {
|
|
389
|
+
dirty?: boolean;
|
|
390
|
+
authenticated?: boolean;
|
|
391
|
+
route?: string;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Create route protection rules.
|
|
396
|
+
*
|
|
397
|
+
* Generates rules for dirty-data navigation guards and
|
|
398
|
+
* authentication-required route protection.
|
|
399
|
+
*/
|
|
400
|
+
export function navigationRules(config: NavigationRulesConfig = {}): PraxisModule<NavigationContext> {
|
|
401
|
+
const {
|
|
402
|
+
dirtyGuard = false,
|
|
403
|
+
authRequired = false,
|
|
404
|
+
} = config;
|
|
405
|
+
|
|
406
|
+
const rules: RuleDescriptor<NavigationContext>[] = [];
|
|
407
|
+
const constraints: ConstraintDescriptor<NavigationContext>[] = [];
|
|
408
|
+
|
|
409
|
+
// Navigation request handler
|
|
410
|
+
rules.push({
|
|
411
|
+
id: 'factory/navigation.handle',
|
|
412
|
+
description: 'Processes navigation requests and emits navigation facts',
|
|
413
|
+
eventTypes: ['navigation.request'],
|
|
414
|
+
contract: {
|
|
415
|
+
ruleId: 'factory/navigation.handle',
|
|
416
|
+
behavior: 'Emits navigation.allowed or navigation.blocked based on guards',
|
|
417
|
+
examples: [
|
|
418
|
+
{ given: 'no guards active', when: 'navigation requested', then: 'navigation.allowed emitted' },
|
|
419
|
+
...(dirtyGuard ? [{ given: 'form is dirty', when: 'navigation requested', then: 'navigation.blocked emitted' }] : []),
|
|
420
|
+
...(authRequired ? [{ given: 'user not authenticated', when: 'navigation requested', then: 'navigation.blocked emitted' }] : []),
|
|
421
|
+
],
|
|
422
|
+
invariants: [
|
|
423
|
+
'Every navigation request must result in either allowed or blocked',
|
|
424
|
+
...(dirtyGuard ? ['Navigation must be blocked when dirty data exists'] : []),
|
|
425
|
+
...(authRequired ? ['Navigation must be blocked when not authenticated'] : []),
|
|
426
|
+
],
|
|
427
|
+
},
|
|
428
|
+
impl: (state, events) => {
|
|
429
|
+
const navEvent = events.find(e => e.tag === 'navigation.request');
|
|
430
|
+
if (!navEvent) return RuleResult.skip('No navigation request');
|
|
431
|
+
|
|
432
|
+
const target = (navEvent.payload as { target?: string })?.target ?? '/';
|
|
433
|
+
const reasons: string[] = [];
|
|
434
|
+
|
|
435
|
+
if (dirtyGuard && state.context.dirty) {
|
|
436
|
+
reasons.push('Unsaved changes will be lost');
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (authRequired && !state.context.authenticated) {
|
|
440
|
+
reasons.push('Authentication required');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (reasons.length > 0) {
|
|
444
|
+
return RuleResult.emit([
|
|
445
|
+
fact('navigation.blocked', { target, reasons }),
|
|
446
|
+
]);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return RuleResult.emit([
|
|
450
|
+
fact('navigation.allowed', { target }),
|
|
451
|
+
]);
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// Dirty guard constraint
|
|
456
|
+
if (dirtyGuard) {
|
|
457
|
+
constraints.push({
|
|
458
|
+
id: 'factory/navigation.dirty-guard',
|
|
459
|
+
description: 'Prevents silent navigation when unsaved changes exist',
|
|
460
|
+
contract: {
|
|
461
|
+
ruleId: 'factory/navigation.dirty-guard',
|
|
462
|
+
behavior: 'Blocks navigation when dirty state is true',
|
|
463
|
+
examples: [
|
|
464
|
+
{ given: 'dirty is true', when: 'navigation attempted', then: 'violation' },
|
|
465
|
+
{ given: 'dirty is false', when: 'navigation attempted', then: 'passes' },
|
|
466
|
+
],
|
|
467
|
+
invariants: ['Must never silently lose unsaved changes'],
|
|
468
|
+
},
|
|
469
|
+
impl: (state) => {
|
|
470
|
+
// This constraint fires if navigation somehow bypasses the rule
|
|
471
|
+
if (state.context.dirty && state.facts.some(f => f.tag === 'navigation.allowed')) {
|
|
472
|
+
return 'Navigation allowed while dirty — unsaved changes may be lost';
|
|
473
|
+
}
|
|
474
|
+
return true;
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return { rules, constraints };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ─── Data Rules Factory ─────────────────────────────────────────────────────
|
|
483
|
+
|
|
484
|
+
/** Context type for data rules. */
|
|
485
|
+
interface DataContext {
|
|
486
|
+
pending?: Record<string, { original: unknown; optimistic: unknown }>;
|
|
487
|
+
cache?: Record<string, { data: unknown; timestamp: number }>;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Create data lifecycle rules.
|
|
492
|
+
*
|
|
493
|
+
* Generates rules for optimistic updates, error rollback, and cache invalidation.
|
|
494
|
+
*/
|
|
495
|
+
export function dataRules(config: DataRulesConfig = {}): PraxisModule<DataContext> {
|
|
496
|
+
const {
|
|
497
|
+
optimisticUpdate = false,
|
|
498
|
+
rollbackOnError = false,
|
|
499
|
+
cacheInvalidation = false,
|
|
500
|
+
entityName = 'data',
|
|
501
|
+
} = config;
|
|
502
|
+
|
|
503
|
+
const rules: RuleDescriptor<DataContext>[] = [];
|
|
504
|
+
const constraints: ConstraintDescriptor<DataContext>[] = [];
|
|
505
|
+
|
|
506
|
+
// Optimistic update rule
|
|
507
|
+
if (optimisticUpdate) {
|
|
508
|
+
rules.push({
|
|
509
|
+
id: `factory/${entityName}.optimistic-update`,
|
|
510
|
+
description: `Applies optimistic update for ${entityName} while request is pending`,
|
|
511
|
+
eventTypes: [`${entityName}.mutate`],
|
|
512
|
+
contract: {
|
|
513
|
+
ruleId: `factory/${entityName}.optimistic-update`,
|
|
514
|
+
behavior: `Immediately emits updated ${entityName} state before server confirmation`,
|
|
515
|
+
examples: [
|
|
516
|
+
{ given: `${entityName} mutation requested`, when: 'mutate event fires', then: 'optimistic state emitted' },
|
|
517
|
+
],
|
|
518
|
+
invariants: [
|
|
519
|
+
'Optimistic state must store original for rollback',
|
|
520
|
+
'Optimistic update must be distinguishable from confirmed state',
|
|
521
|
+
],
|
|
522
|
+
},
|
|
523
|
+
impl: (_state, events) => {
|
|
524
|
+
const mutateEvent = events.find(e => e.tag === `${entityName}.mutate`);
|
|
525
|
+
if (!mutateEvent) return RuleResult.skip('No mutation event');
|
|
526
|
+
|
|
527
|
+
const payload = mutateEvent.payload as { id?: string; data?: unknown };
|
|
528
|
+
return RuleResult.emit([
|
|
529
|
+
fact(`${entityName}.optimistic`, {
|
|
530
|
+
id: payload.id,
|
|
531
|
+
data: payload.data,
|
|
532
|
+
pending: true,
|
|
533
|
+
timestamp: Date.now(),
|
|
534
|
+
}),
|
|
535
|
+
]);
|
|
536
|
+
},
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Rollback on error rule
|
|
541
|
+
if (rollbackOnError) {
|
|
542
|
+
rules.push({
|
|
543
|
+
id: `factory/${entityName}.rollback`,
|
|
544
|
+
description: `Rolls back optimistic ${entityName} update on error`,
|
|
545
|
+
eventTypes: [`${entityName}.error`],
|
|
546
|
+
contract: {
|
|
547
|
+
ruleId: `factory/${entityName}.rollback`,
|
|
548
|
+
behavior: `Reverts to original ${entityName} state when mutation fails`,
|
|
549
|
+
examples: [
|
|
550
|
+
{ given: 'optimistic update was applied', when: 'server returns error', then: 'rollback emitted, optimistic retracted' },
|
|
551
|
+
],
|
|
552
|
+
invariants: [
|
|
553
|
+
'Rollback must restore original state exactly',
|
|
554
|
+
'Optimistic facts must be retracted on rollback',
|
|
555
|
+
],
|
|
556
|
+
},
|
|
557
|
+
impl: (_state, events) => {
|
|
558
|
+
const errorEvent = events.find(e => e.tag === `${entityName}.error`);
|
|
559
|
+
if (!errorEvent) return RuleResult.skip('No error event');
|
|
560
|
+
|
|
561
|
+
const payload = errorEvent.payload as { id?: string; error?: string };
|
|
562
|
+
|
|
563
|
+
// Emit rollback fact and retract optimistic updates
|
|
564
|
+
const result = RuleResult.emit([
|
|
565
|
+
fact(`${entityName}.rollback`, {
|
|
566
|
+
id: payload.id,
|
|
567
|
+
error: payload.error,
|
|
568
|
+
timestamp: Date.now(),
|
|
569
|
+
}),
|
|
570
|
+
]);
|
|
571
|
+
return result;
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Cache invalidation rule
|
|
577
|
+
if (cacheInvalidation) {
|
|
578
|
+
rules.push({
|
|
579
|
+
id: `factory/${entityName}.cache-invalidate`,
|
|
580
|
+
description: `Invalidates ${entityName} cache when data changes are confirmed`,
|
|
581
|
+
eventTypes: [`${entityName}.confirmed`, `${entityName}.deleted`],
|
|
582
|
+
contract: {
|
|
583
|
+
ruleId: `factory/${entityName}.cache-invalidate`,
|
|
584
|
+
behavior: `Emits cache invalidation signal when ${entityName} is confirmed or deleted`,
|
|
585
|
+
examples: [
|
|
586
|
+
{ given: `${entityName} mutation confirmed`, when: 'confirmed event fires', then: 'cache.invalidate emitted' },
|
|
587
|
+
],
|
|
588
|
+
invariants: ['Stale cache entries must be invalidated after confirmed mutations'],
|
|
589
|
+
},
|
|
590
|
+
impl: (_state, events) => {
|
|
591
|
+
const confirmEvent = events.find(e =>
|
|
592
|
+
e.tag === `${entityName}.confirmed` || e.tag === `${entityName}.deleted`,
|
|
593
|
+
);
|
|
594
|
+
if (!confirmEvent) return RuleResult.skip('No confirmation event');
|
|
595
|
+
|
|
596
|
+
const payload = confirmEvent.payload as { id?: string };
|
|
597
|
+
return RuleResult.emit([
|
|
598
|
+
fact(`${entityName}.cache-invalidate`, {
|
|
599
|
+
id: payload.id,
|
|
600
|
+
timestamp: Date.now(),
|
|
601
|
+
}),
|
|
602
|
+
]);
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Data integrity constraint
|
|
608
|
+
constraints.push({
|
|
609
|
+
id: `factory/${entityName}.integrity`,
|
|
610
|
+
description: `Ensures ${entityName} state integrity — no orphaned optimistic updates`,
|
|
611
|
+
contract: {
|
|
612
|
+
ruleId: `factory/${entityName}.integrity`,
|
|
613
|
+
behavior: 'Detects orphaned optimistic updates without pending confirmation',
|
|
614
|
+
examples: [
|
|
615
|
+
{ given: 'optimistic update exists without pending request', when: 'checked', then: 'violation' },
|
|
616
|
+
],
|
|
617
|
+
invariants: [`Every optimistic ${entityName} update must have a corresponding pending request`],
|
|
618
|
+
},
|
|
619
|
+
impl: (state) => {
|
|
620
|
+
const pending = state.context.pending ?? {};
|
|
621
|
+
const optimisticFacts = state.facts.filter(f => f.tag === `${entityName}.optimistic`);
|
|
622
|
+
|
|
623
|
+
for (const optFact of optimisticFacts) {
|
|
624
|
+
const id = (optFact.payload as { id?: string })?.id;
|
|
625
|
+
if (id && !pending[id]) {
|
|
626
|
+
return `Orphaned optimistic update for ${entityName} id=${id} — no pending request`;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return true;
|
|
630
|
+
},
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
return { rules, constraints };
|
|
634
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Praxis Rules Factory
|
|
3
|
+
*
|
|
4
|
+
* Public API for predefined rule modules.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { inputRules, toastRules, formRules } from '@plures/praxis/factory';
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
inputRules,
|
|
14
|
+
toastRules,
|
|
15
|
+
formRules,
|
|
16
|
+
navigationRules,
|
|
17
|
+
dataRules,
|
|
18
|
+
} from './factory.js';
|
|
19
|
+
|
|
20
|
+
export type {
|
|
21
|
+
InputRulesConfig,
|
|
22
|
+
ToastRulesConfig,
|
|
23
|
+
FormRulesConfig,
|
|
24
|
+
NavigationRulesConfig,
|
|
25
|
+
DataRulesConfig,
|
|
26
|
+
SanitizationType,
|
|
27
|
+
} from './types.js';
|