@bimetal/rules
v0.9.0
Published
Pluggable rule engine for calendar applications: validation, constraints, domain rules
Downloads
530
Maintainers
Readme
@bimetal/rules
Pluggable rule engine for calendar applications. Evaluates rules in the CalendarStore command pipeline.
Installation
npm install @bimetal/rulesQuick Start
import { createCalendarStore } from '@bimetal/data';
import { createRuleEngine, noOverlap, withRules } from '@bimetal/rules';
const store = createCalendarStore();
const engine = createRuleEngine([noOverlap]);
const guarded = withRules(store, engine, config);
await guarded.dispatch(createEvent(event)); // blocked if overlaps
await guarded.dispatch(createEvent(otherEvent)); // throws RuleViolationErrorBuilt-in Rules
| Rule | Type | Description |
|------|------|-------------|
| noOverlap | error | Blocks overlapping events (O(n) check against all existing events) |
| warnOnOverlap | warning | Warns but allows overlapping events |
| workingHoursOnly | error | Events must be within configured working hours |
| minDuration(n) | error | Minimum duration in minutes |
| maxDuration(n) | error | Maximum duration in minutes |
| minGranularityDuration | error | Duration must be at least one granularity slot (from config) |
Custom Rules
import type { CalendarRule } from '@bimetal/rules';
const noWeekends: CalendarRule = {
id: 'custom.noWeekends',
description: 'No events on weekends',
appliesTo: ['CreateCalendarEvent', 'UpdateCalendarEvent'],
evaluate(command, context) {
// ... inspect command and context.readModel
return { passed: true };
// or: { passed: false, severity: 'error', ruleId: this.id, message: '...' }
},
};Domain-aware Rules
Rules that only apply to events with specific domain data:
import { domainRule } from '@bimetal/rules';
interface TrainingDomain {
type: 'reservation' | 'booking';
cancellationDeadlineHours: number;
}
const cancellationRule = domainRule<TrainingDomain>({
id: 'training.cancellationDeadline',
description: 'Cannot cancel within deadline',
domainKey: 'training',
appliesTo: ['DeleteCalendarEvent'],
evaluate(event, domain, command, context) {
const hoursUntilStart = (event.timeRange.start.epochMs - context.now.epochMs) / 3600000;
if (hoursUntilStart < domain.cancellationDeadlineHours) {
return { passed: false, severity: 'error', ruleId: 'training.cancellationDeadline',
message: `Cannot cancel within ${domain.cancellationDeadlineHours}h of start` };
}
return { passed: true };
},
});Store Integration
withRules() wraps a CalendarStore. Errors block, warnings pass through.
const guarded = withRules(store, engine, config, { role: 'trainer' });
// Errors → RuleViolationError thrown
// Warnings → available via guarded.lastWarningsNote: Rules are evaluated against a snapshot of the current state. In multi-writer scenarios, state may change between evaluation and dispatch. On ConcurrencyError, the consumer should retry (rules will be re-evaluated automatically).
Undo and Rules
undo() is routed through dispatch(undoLastChange(...)), so rules with appliesTo: ['UndoLastChange'] are evaluated before undo executes. If no such rules are registered, undo always succeeds. Register an UndoLastChange rule only when you need to guard undo — e.g. preventing undo of events older than a certain age.
Performance Notes
noOverlap checks against all existing events (O(n)). For large datasets or resource-scoped overlap checking, use domainRule() to create filtered variants that only compare events within a specific domain.
RuleContext
Every rule receives full context:
interface RuleContext {
readModel: CalendarReadModel; // current events
config: CoreConfig; // timezone, granularity, working hours
now: CalendarDateTime; // current time
caller?: CallerInfo; // { id?, role?, ... }
}