@unlaxer/tramli 0.1.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.
@@ -0,0 +1,19 @@
1
+ import type { FlowKey } from './flow-key.js';
2
+ /**
3
+ * Accumulator for flow data. Keyed by FlowKey — each key appears at most once.
4
+ *
5
+ * Use dedicated FlowKey instances as keys (e.g., flowKey<OrderRequest>('OrderRequest')),
6
+ * not raw strings. Putting the same key twice silently overwrites the previous value.
7
+ */
8
+ export declare class FlowContext {
9
+ readonly flowId: string;
10
+ readonly createdAt: Date;
11
+ private attributes;
12
+ constructor(flowId: string, createdAt?: Date, attributes?: Map<string, unknown>);
13
+ get<T>(key: FlowKey<T>): T;
14
+ find<T>(key: FlowKey<T>): T | undefined;
15
+ put<T>(key: FlowKey<T>, value: T): void;
16
+ has(key: FlowKey<unknown>): boolean;
17
+ snapshot(): Map<string, unknown>;
18
+ restoreFrom(snapshot: Map<string, unknown>): void;
19
+ }
@@ -0,0 +1,40 @@
1
+ import { FlowError } from './flow-error.js';
2
+ /**
3
+ * Accumulator for flow data. Keyed by FlowKey — each key appears at most once.
4
+ *
5
+ * Use dedicated FlowKey instances as keys (e.g., flowKey<OrderRequest>('OrderRequest')),
6
+ * not raw strings. Putting the same key twice silently overwrites the previous value.
7
+ */
8
+ export class FlowContext {
9
+ flowId;
10
+ createdAt;
11
+ attributes;
12
+ constructor(flowId, createdAt, attributes) {
13
+ this.flowId = flowId;
14
+ this.createdAt = createdAt ?? new Date();
15
+ this.attributes = new Map(attributes ?? []);
16
+ }
17
+ get(key) {
18
+ const value = this.attributes.get(key);
19
+ if (value === undefined)
20
+ throw FlowError.missingContext(key);
21
+ return value;
22
+ }
23
+ find(key) {
24
+ return this.attributes.get(key);
25
+ }
26
+ put(key, value) {
27
+ this.attributes.set(key, value);
28
+ }
29
+ has(key) {
30
+ return this.attributes.has(key);
31
+ }
32
+ snapshot() {
33
+ return new Map(this.attributes);
34
+ }
35
+ restoreFrom(snapshot) {
36
+ this.attributes.clear();
37
+ for (const [k, v] of snapshot)
38
+ this.attributes.set(k, v);
39
+ }
40
+ }
@@ -0,0 +1,66 @@
1
+ import type { FlowKey } from './flow-key.js';
2
+ import type { StateConfig, Transition, StateProcessor, TransitionGuard, BranchProcessor } from './types.js';
3
+ export declare class FlowDefinition<S extends string> {
4
+ readonly name: string;
5
+ readonly stateConfig: Record<S, StateConfig>;
6
+ readonly ttl: number;
7
+ readonly maxGuardRetries: number;
8
+ readonly transitions: Transition<S>[];
9
+ readonly errorTransitions: Map<S, S>;
10
+ readonly initialState: S | null;
11
+ readonly terminalStates: Set<S>;
12
+ private constructor();
13
+ transitionsFrom(state: S): Transition<S>[];
14
+ externalFrom(state: S): Transition<S> | undefined;
15
+ allStates(): S[];
16
+ static builder<S extends string>(name: string, stateConfig: Record<S, StateConfig>): Builder<S>;
17
+ }
18
+ export declare class Builder<S extends string> {
19
+ private readonly name;
20
+ private readonly stateConfig;
21
+ private ttl;
22
+ private maxGuardRetries;
23
+ private readonly transitions;
24
+ private readonly errorTransitions;
25
+ private readonly initiallyAvailableKeys;
26
+ constructor(name: string, stateConfig: Record<S, StateConfig>);
27
+ initiallyAvailable(...keys: FlowKey<unknown>[]): this;
28
+ setTtl(ms: number): this;
29
+ setMaxGuardRetries(max: number): this;
30
+ from(state: S): FromBuilder<S>;
31
+ onError(from: S, to: S): this;
32
+ onAnyError(errorState: S): this;
33
+ /** @internal */
34
+ addTransition(t: Transition<S>): void;
35
+ build(): FlowDefinition<S>;
36
+ private validate;
37
+ private checkReachability;
38
+ private checkPathToTerminal;
39
+ private canReachTerminal;
40
+ private checkDag;
41
+ private hasCycle;
42
+ private checkExternalUniqueness;
43
+ private checkBranchCompleteness;
44
+ private checkRequiresProduces;
45
+ private checkRequiresProducesFrom;
46
+ private checkAutoExternalConflict;
47
+ private checkTerminalNoOutgoing;
48
+ }
49
+ export declare class FromBuilder<S extends string> {
50
+ private readonly builder;
51
+ private readonly fromState;
52
+ constructor(builder: Builder<S>, fromState: S);
53
+ auto(to: S, processor: StateProcessor<S>): Builder<S>;
54
+ external(to: S, guard: TransitionGuard<S>, processor?: StateProcessor<S>): Builder<S>;
55
+ branch(branch: BranchProcessor<S>): BranchBuilder<S>;
56
+ }
57
+ export declare class BranchBuilder<S extends string> {
58
+ private readonly builder;
59
+ private readonly fromState;
60
+ private readonly branch;
61
+ private readonly targets;
62
+ private readonly processors;
63
+ constructor(builder: Builder<S>, fromState: S, branch: BranchProcessor<S>);
64
+ to(state: S, label: string, processor?: StateProcessor<S>): this;
65
+ endBranch(): Builder<S>;
66
+ }
@@ -0,0 +1,346 @@
1
+ import { FlowError } from './flow-error.js';
2
+ export class FlowDefinition {
3
+ name;
4
+ stateConfig;
5
+ ttl; // milliseconds
6
+ maxGuardRetries;
7
+ transitions;
8
+ errorTransitions;
9
+ initialState;
10
+ terminalStates;
11
+ constructor(name, stateConfig, ttl, maxGuardRetries, transitions, errorTransitions) {
12
+ this.name = name;
13
+ this.stateConfig = stateConfig;
14
+ this.ttl = ttl;
15
+ this.maxGuardRetries = maxGuardRetries;
16
+ this.transitions = [...transitions];
17
+ this.errorTransitions = new Map(errorTransitions);
18
+ let initial = null;
19
+ const terminals = new Set();
20
+ for (const [state, cfg] of Object.entries(stateConfig)) {
21
+ if (cfg.initial)
22
+ initial = state;
23
+ if (cfg.terminal)
24
+ terminals.add(state);
25
+ }
26
+ this.initialState = initial;
27
+ this.terminalStates = terminals;
28
+ }
29
+ transitionsFrom(state) {
30
+ return this.transitions.filter(t => t.from === state);
31
+ }
32
+ externalFrom(state) {
33
+ return this.transitions.find(t => t.from === state && t.type === 'external');
34
+ }
35
+ allStates() {
36
+ return Object.keys(this.stateConfig);
37
+ }
38
+ // ─── Builder ─────────────────────────────────────────────
39
+ static builder(name, stateConfig) {
40
+ return new Builder(name, stateConfig);
41
+ }
42
+ }
43
+ export class Builder {
44
+ name;
45
+ stateConfig;
46
+ ttl = 5 * 60 * 1000; // 5 minutes
47
+ maxGuardRetries = 3;
48
+ transitions = [];
49
+ errorTransitions = new Map();
50
+ initiallyAvailableKeys = [];
51
+ constructor(name, stateConfig) {
52
+ this.name = name;
53
+ this.stateConfig = stateConfig;
54
+ }
55
+ initiallyAvailable(...keys) {
56
+ for (const k of keys)
57
+ this.initiallyAvailableKeys.push(k);
58
+ return this;
59
+ }
60
+ setTtl(ms) { this.ttl = ms; return this; }
61
+ setMaxGuardRetries(max) { this.maxGuardRetries = max; return this; }
62
+ from(state) {
63
+ return new FromBuilder(this, state);
64
+ }
65
+ onError(from, to) {
66
+ this.errorTransitions.set(from, to);
67
+ return this;
68
+ }
69
+ onAnyError(errorState) {
70
+ for (const s of Object.keys(this.stateConfig)) {
71
+ if (!this.stateConfig[s].terminal)
72
+ this.errorTransitions.set(s, errorState);
73
+ }
74
+ return this;
75
+ }
76
+ /** @internal */
77
+ addTransition(t) { this.transitions.push(t); }
78
+ build() {
79
+ const def = FlowDefinition.builder(this.name, this.stateConfig);
80
+ // Build via private constructor
81
+ const result = Object.create(FlowDefinition.prototype);
82
+ Object.assign(result, {
83
+ name: this.name,
84
+ stateConfig: this.stateConfig,
85
+ ttl: this.ttl,
86
+ maxGuardRetries: this.maxGuardRetries,
87
+ transitions: [...this.transitions],
88
+ errorTransitions: new Map(this.errorTransitions),
89
+ });
90
+ // Compute initial/terminal
91
+ let initial = null;
92
+ const terminals = new Set();
93
+ for (const [state, cfg] of Object.entries(this.stateConfig)) {
94
+ if (cfg.initial)
95
+ initial = state;
96
+ if (cfg.terminal)
97
+ terminals.add(state);
98
+ }
99
+ result.initialState = initial;
100
+ result.terminalStates = terminals;
101
+ this.validate(result);
102
+ return result;
103
+ }
104
+ validate(def) {
105
+ const errors = [];
106
+ if (!def.initialState) {
107
+ errors.push('No initial state found (exactly one state must have initial=true)');
108
+ }
109
+ this.checkReachability(def, errors);
110
+ this.checkPathToTerminal(def, errors);
111
+ this.checkDag(def, errors);
112
+ this.checkExternalUniqueness(def, errors);
113
+ this.checkBranchCompleteness(def, errors);
114
+ this.checkRequiresProduces(def, errors);
115
+ this.checkAutoExternalConflict(def, errors);
116
+ this.checkTerminalNoOutgoing(def, errors);
117
+ if (errors.length > 0) {
118
+ throw new FlowError('INVALID_FLOW_DEFINITION', `Flow '${this.name}' has ${errors.length} validation error(s):\n - ${errors.join('\n - ')}`);
119
+ }
120
+ }
121
+ checkReachability(def, errors) {
122
+ if (!def.initialState)
123
+ return;
124
+ const visited = new Set();
125
+ const queue = [def.initialState];
126
+ visited.add(def.initialState);
127
+ while (queue.length > 0) {
128
+ const current = queue.shift();
129
+ for (const t of def.transitionsFrom(current)) {
130
+ if (!visited.has(t.to)) {
131
+ visited.add(t.to);
132
+ queue.push(t.to);
133
+ }
134
+ }
135
+ const errTarget = def.errorTransitions.get(current);
136
+ if (errTarget && !visited.has(errTarget)) {
137
+ visited.add(errTarget);
138
+ queue.push(errTarget);
139
+ }
140
+ }
141
+ for (const s of def.allStates()) {
142
+ if (!visited.has(s) && !def.stateConfig[s].terminal) {
143
+ errors.push(`State ${s} is not reachable from ${def.initialState}`);
144
+ }
145
+ }
146
+ }
147
+ checkPathToTerminal(def, errors) {
148
+ if (!def.initialState)
149
+ return;
150
+ const visited = new Set();
151
+ if (!this.canReachTerminal(def, def.initialState, visited)) {
152
+ errors.push(`No path from ${def.initialState} to any terminal state`);
153
+ }
154
+ }
155
+ canReachTerminal(def, state, visited) {
156
+ if (def.stateConfig[state].terminal)
157
+ return true;
158
+ if (visited.has(state))
159
+ return false;
160
+ visited.add(state);
161
+ for (const t of def.transitionsFrom(state)) {
162
+ if (this.canReachTerminal(def, t.to, visited))
163
+ return true;
164
+ }
165
+ const errTarget = def.errorTransitions.get(state);
166
+ return errTarget !== undefined && this.canReachTerminal(def, errTarget, visited);
167
+ }
168
+ checkDag(def, errors) {
169
+ const autoGraph = new Map();
170
+ for (const t of def.transitions) {
171
+ if (t.type === 'auto' || t.type === 'branch') {
172
+ if (!autoGraph.has(t.from))
173
+ autoGraph.set(t.from, new Set());
174
+ autoGraph.get(t.from).add(t.to);
175
+ }
176
+ }
177
+ const visited = new Set();
178
+ const inStack = new Set();
179
+ for (const s of def.allStates()) {
180
+ if (!visited.has(s) && this.hasCycle(autoGraph, s, visited, inStack)) {
181
+ errors.push(`Auto/Branch transitions contain a cycle involving ${s}`);
182
+ break;
183
+ }
184
+ }
185
+ }
186
+ hasCycle(graph, node, visited, inStack) {
187
+ visited.add(node);
188
+ inStack.add(node);
189
+ for (const neighbor of graph.get(node) ?? []) {
190
+ if (inStack.has(neighbor))
191
+ return true;
192
+ if (!visited.has(neighbor) && this.hasCycle(graph, neighbor, visited, inStack))
193
+ return true;
194
+ }
195
+ inStack.delete(node);
196
+ return false;
197
+ }
198
+ checkExternalUniqueness(def, errors) {
199
+ const counts = new Map();
200
+ for (const t of def.transitions) {
201
+ if (t.type === 'external')
202
+ counts.set(t.from, (counts.get(t.from) ?? 0) + 1);
203
+ }
204
+ for (const [state, count] of counts) {
205
+ if (count > 1)
206
+ errors.push(`State ${state} has ${count} external transitions (max 1)`);
207
+ }
208
+ }
209
+ checkBranchCompleteness(def, errors) {
210
+ const allStates = new Set(def.allStates());
211
+ for (const t of def.transitions) {
212
+ if (t.type === 'branch' && t.branchTargets.size > 0) {
213
+ for (const [label, target] of t.branchTargets) {
214
+ if (!allStates.has(target)) {
215
+ errors.push(`Branch target '${label}' -> ${target} is not a valid state`);
216
+ }
217
+ }
218
+ }
219
+ }
220
+ }
221
+ checkRequiresProduces(def, errors) {
222
+ if (!def.initialState)
223
+ return;
224
+ const stateAvailable = new Map();
225
+ this.checkRequiresProducesFrom(def, def.initialState, new Set(this.initiallyAvailableKeys), stateAvailable, errors);
226
+ }
227
+ checkRequiresProducesFrom(def, state, available, stateAvailable, errors) {
228
+ if (stateAvailable.has(state)) {
229
+ const existing = stateAvailable.get(state);
230
+ let isSubset = true;
231
+ for (const a of available) {
232
+ if (!existing.has(a)) {
233
+ isSubset = false;
234
+ break;
235
+ }
236
+ }
237
+ if (isSubset)
238
+ return;
239
+ // intersection
240
+ for (const a of [...existing]) {
241
+ if (!available.has(a))
242
+ existing.delete(a);
243
+ }
244
+ }
245
+ else {
246
+ stateAvailable.set(state, new Set(available));
247
+ }
248
+ for (const t of def.transitionsFrom(state)) {
249
+ const newAvailable = new Set(stateAvailable.get(state));
250
+ if (t.guard) {
251
+ for (const req of t.guard.requires) {
252
+ if (!newAvailable.has(req))
253
+ errors.push(`Guard '${t.guard.name}' at ${t.from} requires ${req} but it may not be available`);
254
+ }
255
+ for (const p of t.guard.produces)
256
+ newAvailable.add(p);
257
+ }
258
+ if (t.branch) {
259
+ for (const req of t.branch.requires) {
260
+ if (!newAvailable.has(req))
261
+ errors.push(`Branch '${t.branch.name}' at ${t.from} requires ${req} but it may not be available`);
262
+ }
263
+ }
264
+ if (t.processor) {
265
+ for (const req of t.processor.requires) {
266
+ if (!newAvailable.has(req))
267
+ errors.push(`Processor '${t.processor.name}' at ${t.from} -> ${t.to} requires ${req} but it may not be available`);
268
+ }
269
+ for (const p of t.processor.produces)
270
+ newAvailable.add(p);
271
+ }
272
+ this.checkRequiresProducesFrom(def, t.to, newAvailable, stateAvailable, errors);
273
+ }
274
+ }
275
+ checkAutoExternalConflict(def, errors) {
276
+ for (const state of def.allStates()) {
277
+ const trans = def.transitionsFrom(state);
278
+ const hasAuto = trans.some(t => t.type === 'auto' || t.type === 'branch');
279
+ const hasExternal = trans.some(t => t.type === 'external');
280
+ if (hasAuto && hasExternal) {
281
+ errors.push(`State ${state} has both auto/branch and external transitions — auto takes priority, making external unreachable`);
282
+ }
283
+ }
284
+ }
285
+ checkTerminalNoOutgoing(def, errors) {
286
+ for (const t of def.transitions) {
287
+ if (def.stateConfig[t.from].terminal) {
288
+ errors.push(`Terminal state ${t.from} has an outgoing transition to ${t.to}`);
289
+ }
290
+ }
291
+ }
292
+ }
293
+ export class FromBuilder {
294
+ builder;
295
+ fromState;
296
+ constructor(builder, fromState) {
297
+ this.builder = builder;
298
+ this.fromState = fromState;
299
+ }
300
+ auto(to, processor) {
301
+ this.builder.addTransition({
302
+ from: this.fromState, to, type: 'auto', processor,
303
+ guard: undefined, branch: undefined, branchTargets: new Map(),
304
+ });
305
+ return this.builder;
306
+ }
307
+ external(to, guard, processor) {
308
+ this.builder.addTransition({
309
+ from: this.fromState, to, type: 'external', processor,
310
+ guard, branch: undefined, branchTargets: new Map(),
311
+ });
312
+ return this.builder;
313
+ }
314
+ branch(branch) {
315
+ return new BranchBuilder(this.builder, this.fromState, branch);
316
+ }
317
+ }
318
+ export class BranchBuilder {
319
+ builder;
320
+ fromState;
321
+ branch;
322
+ targets = new Map();
323
+ processors = new Map();
324
+ constructor(builder, fromState, branch) {
325
+ this.builder = builder;
326
+ this.fromState = fromState;
327
+ this.branch = branch;
328
+ }
329
+ to(state, label, processor) {
330
+ this.targets.set(label, state);
331
+ if (processor)
332
+ this.processors.set(label, processor);
333
+ return this;
334
+ }
335
+ endBranch() {
336
+ for (const [label, target] of this.targets) {
337
+ this.builder.addTransition({
338
+ from: this.fromState, to: target, type: 'branch',
339
+ processor: this.processors.get(label),
340
+ guard: undefined, branch: this.branch,
341
+ branchTargets: new Map(this.targets),
342
+ });
343
+ }
344
+ return this.builder;
345
+ }
346
+ }
@@ -0,0 +1,22 @@
1
+ import type { FlowDefinition } from './flow-definition.js';
2
+ import { FlowInstance } from './flow-instance.js';
3
+ import type { InMemoryFlowStore } from './in-memory-flow-store.js';
4
+ /**
5
+ * Generic engine that drives all flow state machines.
6
+ *
7
+ * Exceptions:
8
+ * - FLOW_NOT_FOUND: resumeAndExecute with unknown or completed flowId
9
+ * - INVALID_TRANSITION: resumeAndExecute when no external transition exists
10
+ * - MAX_CHAIN_DEPTH: auto-chain exceeded 10 steps
11
+ * - EXPIRED: flow TTL exceeded at resumeAndExecute entry
12
+ *
13
+ * Processor and branch exceptions are caught and routed to error transitions.
14
+ */
15
+ export declare class FlowEngine {
16
+ private readonly store;
17
+ constructor(store: InMemoryFlowStore);
18
+ startFlow<S extends string>(definition: FlowDefinition<S>, sessionId: string, initialData: Map<string, unknown>): Promise<FlowInstance<S>>;
19
+ resumeAndExecute<S extends string>(flowId: string, definition: FlowDefinition<S>, externalData?: Map<string, unknown>): Promise<FlowInstance<S>>;
20
+ private executeAutoChain;
21
+ private handleError;
22
+ }
@@ -0,0 +1,161 @@
1
+ import { FlowContext } from './flow-context.js';
2
+ import { FlowInstance } from './flow-instance.js';
3
+ import { FlowError } from './flow-error.js';
4
+ const MAX_CHAIN_DEPTH = 10;
5
+ /**
6
+ * Generic engine that drives all flow state machines.
7
+ *
8
+ * Exceptions:
9
+ * - FLOW_NOT_FOUND: resumeAndExecute with unknown or completed flowId
10
+ * - INVALID_TRANSITION: resumeAndExecute when no external transition exists
11
+ * - MAX_CHAIN_DEPTH: auto-chain exceeded 10 steps
12
+ * - EXPIRED: flow TTL exceeded at resumeAndExecute entry
13
+ *
14
+ * Processor and branch exceptions are caught and routed to error transitions.
15
+ */
16
+ export class FlowEngine {
17
+ store;
18
+ constructor(store) {
19
+ this.store = store;
20
+ }
21
+ async startFlow(definition, sessionId, initialData) {
22
+ const flowId = crypto.randomUUID();
23
+ const ctx = new FlowContext(flowId);
24
+ for (const [key, value] of initialData)
25
+ ctx.put(key, value);
26
+ const initial = definition.initialState;
27
+ if (!initial)
28
+ throw new FlowError('INVALID_FLOW_DEFINITION', 'No initial state');
29
+ const expiresAt = new Date(Date.now() + definition.ttl);
30
+ const flow = new FlowInstance(flowId, sessionId, definition, ctx, initial, expiresAt);
31
+ this.store.create(flow);
32
+ await this.executeAutoChain(flow);
33
+ this.store.save(flow);
34
+ return flow;
35
+ }
36
+ async resumeAndExecute(flowId, definition, externalData) {
37
+ const flow = this.store.loadForUpdate(flowId);
38
+ if (!flow)
39
+ throw new FlowError('FLOW_NOT_FOUND', `Flow ${flowId} not found or already completed`);
40
+ if (externalData) {
41
+ for (const [key, value] of externalData)
42
+ flow.context.put(key, value);
43
+ }
44
+ if (new Date() > flow.expiresAt) {
45
+ flow.complete('EXPIRED');
46
+ this.store.save(flow);
47
+ return flow;
48
+ }
49
+ const currentState = flow.currentState;
50
+ const transition = definition.externalFrom(currentState);
51
+ if (!transition)
52
+ throw FlowError.invalidTransition(currentState, currentState);
53
+ const guard = transition.guard;
54
+ if (guard) {
55
+ const output = await guard.validate(flow.context);
56
+ switch (output.type) {
57
+ case 'accepted': {
58
+ const backup = flow.context.snapshot();
59
+ if (output.data) {
60
+ for (const [key, value] of output.data)
61
+ flow.context.put(key, value);
62
+ }
63
+ try {
64
+ if (transition.processor)
65
+ await transition.processor.process(flow.context);
66
+ const from = flow.currentState;
67
+ flow.transitionTo(transition.to);
68
+ this.store.recordTransition(flow.id, from, transition.to, guard.name, flow.context);
69
+ }
70
+ catch {
71
+ flow.context.restoreFrom(backup);
72
+ this.handleError(flow, currentState);
73
+ this.store.save(flow);
74
+ return flow;
75
+ }
76
+ break;
77
+ }
78
+ case 'rejected': {
79
+ flow.incrementGuardFailure();
80
+ if (flow.guardFailureCount >= definition.maxGuardRetries) {
81
+ this.handleError(flow, currentState);
82
+ }
83
+ this.store.save(flow);
84
+ return flow;
85
+ }
86
+ case 'expired': {
87
+ flow.complete('EXPIRED');
88
+ this.store.save(flow);
89
+ return flow;
90
+ }
91
+ }
92
+ }
93
+ else {
94
+ const from = flow.currentState;
95
+ flow.transitionTo(transition.to);
96
+ this.store.recordTransition(flow.id, from, transition.to, 'external', flow.context);
97
+ }
98
+ await this.executeAutoChain(flow);
99
+ this.store.save(flow);
100
+ return flow;
101
+ }
102
+ async executeAutoChain(flow) {
103
+ let depth = 0;
104
+ while (depth < MAX_CHAIN_DEPTH) {
105
+ const current = flow.currentState;
106
+ if (flow.definition.stateConfig[current].terminal) {
107
+ flow.complete(current);
108
+ break;
109
+ }
110
+ const transitions = flow.definition.transitionsFrom(current);
111
+ const autoOrBranch = transitions.find(t => t.type === 'auto' || t.type === 'branch');
112
+ if (!autoOrBranch)
113
+ break;
114
+ const backup = flow.context.snapshot();
115
+ try {
116
+ if (autoOrBranch.type === 'auto') {
117
+ if (autoOrBranch.processor)
118
+ await autoOrBranch.processor.process(flow.context);
119
+ const from = flow.currentState;
120
+ flow.transitionTo(autoOrBranch.to);
121
+ this.store.recordTransition(flow.id, from, autoOrBranch.to, autoOrBranch.processor?.name ?? 'auto', flow.context);
122
+ }
123
+ else {
124
+ const branch = autoOrBranch.branch;
125
+ const label = await branch.decide(flow.context);
126
+ const target = autoOrBranch.branchTargets.get(label);
127
+ if (!target) {
128
+ throw new FlowError('UNKNOWN_BRANCH', `Branch '${branch.name}' returned unknown label: ${label}`);
129
+ }
130
+ const specific = transitions.find(t => t.type === 'branch' && t.to === target) ?? autoOrBranch;
131
+ if (specific.processor)
132
+ await specific.processor.process(flow.context);
133
+ const from = flow.currentState;
134
+ flow.transitionTo(target);
135
+ this.store.recordTransition(flow.id, from, target, `${branch.name}:${label}`, flow.context);
136
+ }
137
+ }
138
+ catch {
139
+ flow.context.restoreFrom(backup);
140
+ this.handleError(flow, flow.currentState);
141
+ return;
142
+ }
143
+ depth++;
144
+ }
145
+ if (depth >= MAX_CHAIN_DEPTH)
146
+ throw FlowError.maxChainDepth();
147
+ }
148
+ handleError(flow, fromState) {
149
+ const errorTarget = flow.definition.errorTransitions.get(fromState);
150
+ if (errorTarget) {
151
+ const from = flow.currentState;
152
+ flow.transitionTo(errorTarget);
153
+ this.store.recordTransition(flow.id, from, errorTarget, 'error', flow.context);
154
+ if (flow.definition.stateConfig[errorTarget].terminal)
155
+ flow.complete(errorTarget);
156
+ }
157
+ else {
158
+ flow.complete('TERMINAL_ERROR');
159
+ }
160
+ }
161
+ }
@@ -0,0 +1,8 @@
1
+ export declare class FlowError extends Error {
2
+ readonly code: string;
3
+ constructor(code: string, message: string);
4
+ static invalidTransition(from: string, to: string): FlowError;
5
+ static missingContext(key: string): FlowError;
6
+ static dagCycle(detail: string): FlowError;
7
+ static maxChainDepth(): FlowError;
8
+ }
@@ -0,0 +1,20 @@
1
+ export class FlowError extends Error {
2
+ code;
3
+ constructor(code, message) {
4
+ super(message);
5
+ this.code = code;
6
+ this.name = 'FlowError';
7
+ }
8
+ static invalidTransition(from, to) {
9
+ return new FlowError('INVALID_TRANSITION', `Invalid transition from ${from} to ${to}`);
10
+ }
11
+ static missingContext(key) {
12
+ return new FlowError('MISSING_CONTEXT', `Missing context key: ${key}`);
13
+ }
14
+ static dagCycle(detail) {
15
+ return new FlowError('DAG_CYCLE', `Auto/Branch transitions contain a cycle: ${detail}`);
16
+ }
17
+ static maxChainDepth() {
18
+ return new FlowError('MAX_CHAIN_DEPTH', 'Auto-chain exceeded maximum depth of 10');
19
+ }
20
+ }
@@ -0,0 +1,29 @@
1
+ import type { FlowDefinition } from './flow-definition.js';
2
+ import type { FlowContext } from './flow-context.js';
3
+ export declare class FlowInstance<S extends string> {
4
+ readonly id: string;
5
+ readonly sessionId: string;
6
+ readonly definition: FlowDefinition<S>;
7
+ readonly context: FlowContext;
8
+ private _currentState;
9
+ private _guardFailureCount;
10
+ private _version;
11
+ readonly createdAt: Date;
12
+ readonly expiresAt: Date;
13
+ private _exitState;
14
+ constructor(id: string, sessionId: string, definition: FlowDefinition<S>, context: FlowContext, currentState: S, expiresAt: Date);
15
+ /**
16
+ * Restore a FlowInstance from persisted state.
17
+ * Used by FlowStore implementations to reconstruct instances loaded from storage.
18
+ */
19
+ static restore<S extends string>(id: string, sessionId: string, definition: FlowDefinition<S>, context: FlowContext, currentState: S, createdAt: Date, expiresAt: Date, guardFailureCount: number, version: number, exitState: string | null): FlowInstance<S>;
20
+ get currentState(): S;
21
+ get guardFailureCount(): number;
22
+ get version(): number;
23
+ get exitState(): string | null;
24
+ get isCompleted(): boolean;
25
+ /** @internal */ transitionTo(state: S): void;
26
+ /** @internal */ incrementGuardFailure(): void;
27
+ /** @internal */ complete(exitState: string): void;
28
+ /** @internal */ setVersion(version: number): void;
29
+ }
@@ -0,0 +1,52 @@
1
+ export class FlowInstance {
2
+ id;
3
+ sessionId;
4
+ definition;
5
+ context;
6
+ _currentState;
7
+ _guardFailureCount;
8
+ _version;
9
+ createdAt;
10
+ expiresAt;
11
+ _exitState;
12
+ constructor(id, sessionId, definition, context, currentState, expiresAt) {
13
+ this.id = id;
14
+ this.sessionId = sessionId;
15
+ this.definition = definition;
16
+ this.context = context;
17
+ this._currentState = currentState;
18
+ this._guardFailureCount = 0;
19
+ this._version = 0;
20
+ this.createdAt = new Date();
21
+ this.expiresAt = expiresAt;
22
+ this._exitState = null;
23
+ }
24
+ /**
25
+ * Restore a FlowInstance from persisted state.
26
+ * Used by FlowStore implementations to reconstruct instances loaded from storage.
27
+ */
28
+ static restore(id, sessionId, definition, context, currentState, createdAt, expiresAt, guardFailureCount, version, exitState) {
29
+ const instance = Object.create(FlowInstance.prototype);
30
+ // Use defineProperties to set readonly fields
31
+ Object.defineProperty(instance, 'id', { value: id, writable: false });
32
+ Object.defineProperty(instance, 'sessionId', { value: sessionId, writable: false });
33
+ Object.defineProperty(instance, 'definition', { value: definition, writable: false });
34
+ Object.defineProperty(instance, 'context', { value: context, writable: false });
35
+ Object.defineProperty(instance, 'createdAt', { value: createdAt, writable: false });
36
+ Object.defineProperty(instance, 'expiresAt', { value: expiresAt, writable: false });
37
+ instance._currentState = currentState;
38
+ instance._guardFailureCount = guardFailureCount;
39
+ instance._version = version;
40
+ instance._exitState = exitState;
41
+ return instance;
42
+ }
43
+ get currentState() { return this._currentState; }
44
+ get guardFailureCount() { return this._guardFailureCount; }
45
+ get version() { return this._version; }
46
+ get exitState() { return this._exitState; }
47
+ get isCompleted() { return this._exitState !== null; }
48
+ /** @internal */ transitionTo(state) { this._currentState = state; }
49
+ /** @internal */ incrementGuardFailure() { this._guardFailureCount++; }
50
+ /** @internal */ complete(exitState) { this._exitState = exitState; }
51
+ /** @internal */ setVersion(version) { this._version = version; }
52
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Branded string type for type-safe FlowContext keys.
3
+ *
4
+ * Use dedicated FlowKey instances as keys, not raw strings.
5
+ * Each key maps to exactly one data type in the context.
6
+ */
7
+ export type FlowKey<T> = string & {
8
+ readonly __type: T;
9
+ };
10
+ export declare function flowKey<T>(name: string): FlowKey<T>;
@@ -0,0 +1,3 @@
1
+ export function flowKey(name) {
2
+ return name;
3
+ }
@@ -0,0 +1,18 @@
1
+ import type { FlowContext } from './flow-context.js';
2
+ import type { FlowInstance } from './flow-instance.js';
3
+ export interface TransitionRecord {
4
+ flowId: string;
5
+ from: string | null;
6
+ to: string;
7
+ trigger: string;
8
+ timestamp: Date;
9
+ }
10
+ export declare class InMemoryFlowStore {
11
+ private flows;
12
+ private _transitionLog;
13
+ create(flow: FlowInstance<any>): void;
14
+ loadForUpdate<S extends string>(flowId: string): FlowInstance<S> | undefined;
15
+ save(flow: FlowInstance<any>): void;
16
+ recordTransition(flowId: string, from: string | null, to: string, trigger: string, _ctx: FlowContext): void;
17
+ get transitionLog(): readonly TransitionRecord[];
18
+ }
@@ -0,0 +1,22 @@
1
+ export class InMemoryFlowStore {
2
+ flows = new Map();
3
+ _transitionLog = [];
4
+ create(flow) {
5
+ this.flows.set(flow.id, flow);
6
+ }
7
+ loadForUpdate(flowId) {
8
+ const flow = this.flows.get(flowId);
9
+ if (!flow || flow.isCompleted)
10
+ return undefined;
11
+ return flow;
12
+ }
13
+ save(flow) {
14
+ this.flows.set(flow.id, flow);
15
+ }
16
+ recordTransition(flowId, from, to, trigger, _ctx) {
17
+ this._transitionLog.push({ flowId, from, to, trigger, timestamp: new Date() });
18
+ }
19
+ get transitionLog() {
20
+ return this._transitionLog;
21
+ }
22
+ }
@@ -0,0 +1,12 @@
1
+ export { Tramli } from './tramli.js';
2
+ export { FlowEngine } from './flow-engine.js';
3
+ export { FlowContext } from './flow-context.js';
4
+ export { FlowInstance } from './flow-instance.js';
5
+ export { FlowDefinition, Builder, FromBuilder, BranchBuilder } from './flow-definition.js';
6
+ export { FlowError } from './flow-error.js';
7
+ export { InMemoryFlowStore } from './in-memory-flow-store.js';
8
+ export type { TransitionRecord } from './in-memory-flow-store.js';
9
+ export { MermaidGenerator } from './mermaid-generator.js';
10
+ export { flowKey } from './flow-key.js';
11
+ export type { FlowKey } from './flow-key.js';
12
+ export type { StateConfig, GuardOutput, TransitionType, Transition, StateProcessor, TransitionGuard, BranchProcessor, } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ export { Tramli } from './tramli.js';
2
+ export { FlowEngine } from './flow-engine.js';
3
+ export { FlowContext } from './flow-context.js';
4
+ export { FlowInstance } from './flow-instance.js';
5
+ export { FlowDefinition, Builder, FromBuilder, BranchBuilder } from './flow-definition.js';
6
+ export { FlowError } from './flow-error.js';
7
+ export { InMemoryFlowStore } from './in-memory-flow-store.js';
8
+ export { MermaidGenerator } from './mermaid-generator.js';
9
+ export { flowKey } from './flow-key.js';
@@ -0,0 +1,5 @@
1
+ import type { FlowDefinition } from './flow-definition.js';
2
+ export declare class MermaidGenerator {
3
+ static generate<S extends string>(def: FlowDefinition<S>): string;
4
+ private static transitionLabel;
5
+ }
@@ -0,0 +1,36 @@
1
+ export class MermaidGenerator {
2
+ static generate(def) {
3
+ const lines = ['stateDiagram-v2'];
4
+ if (def.initialState)
5
+ lines.push(` [*] --> ${def.initialState}`);
6
+ const seen = new Set();
7
+ for (const t of def.transitions) {
8
+ const key = `${t.from}->${t.to}`;
9
+ if (seen.has(key))
10
+ continue;
11
+ seen.add(key);
12
+ const label = this.transitionLabel(t);
13
+ lines.push(label ? ` ${t.from} --> ${t.to}: ${label}` : ` ${t.from} --> ${t.to}`);
14
+ }
15
+ for (const [from, to] of def.errorTransitions) {
16
+ const key = `${from}->${to}`;
17
+ if (!seen.has(key)) {
18
+ seen.add(key);
19
+ lines.push(` ${from} --> ${to}: error`);
20
+ }
21
+ }
22
+ for (const s of def.terminalStates) {
23
+ lines.push(` ${s} --> [*]`);
24
+ }
25
+ return lines.join('\n') + '\n';
26
+ }
27
+ static transitionLabel(t) {
28
+ if (t.type === 'auto')
29
+ return t.processor?.name ?? '';
30
+ if (t.type === 'external')
31
+ return t.guard ? `[${t.guard.name}]` : '';
32
+ if (t.type === 'branch')
33
+ return t.branch?.name ?? '';
34
+ return '';
35
+ }
36
+ }
@@ -0,0 +1,8 @@
1
+ import type { StateConfig } from './types.js';
2
+ import { Builder } from './flow-definition.js';
3
+ import { FlowEngine } from './flow-engine.js';
4
+ import type { InMemoryFlowStore } from './in-memory-flow-store.js';
5
+ export declare class Tramli {
6
+ static define<S extends string>(name: string, stateConfig: Record<S, StateConfig>): Builder<S>;
7
+ static engine(store: InMemoryFlowStore): FlowEngine;
8
+ }
package/dist/tramli.js ADDED
@@ -0,0 +1,10 @@
1
+ import { Builder } from './flow-definition.js';
2
+ import { FlowEngine } from './flow-engine.js';
3
+ export class Tramli {
4
+ static define(name, stateConfig) {
5
+ return new Builder(name, stateConfig);
6
+ }
7
+ static engine(store) {
8
+ return new FlowEngine(store);
9
+ }
10
+ }
@@ -0,0 +1,73 @@
1
+ import type { FlowKey } from './flow-key.js';
2
+ import type { FlowContext } from './flow-context.js';
3
+ /** State configuration: terminal and initial flags for each state. */
4
+ export type StateConfig = {
5
+ terminal: boolean;
6
+ initial: boolean;
7
+ };
8
+ /** Guard output — discriminated union (Java: sealed interface GuardOutput). */
9
+ export type GuardOutput = {
10
+ type: 'accepted';
11
+ data?: Map<string, unknown>;
12
+ } | {
13
+ type: 'rejected';
14
+ reason: string;
15
+ } | {
16
+ type: 'expired';
17
+ };
18
+ /** Transition types. */
19
+ export type TransitionType = 'auto' | 'external' | 'branch';
20
+ /** A single transition in the flow definition. */
21
+ export interface Transition<S extends string> {
22
+ from: S;
23
+ to: S;
24
+ type: TransitionType;
25
+ processor?: StateProcessor<S>;
26
+ guard?: TransitionGuard<S>;
27
+ branch?: BranchProcessor<S>;
28
+ branchTargets: Map<string, S>;
29
+ }
30
+ /**
31
+ * Processes a state transition.
32
+ *
33
+ * Processors SHOULD be fast and avoid external I/O.
34
+ * If a processor throws, the engine restores context and routes to error transition.
35
+ */
36
+ export interface StateProcessor<S extends string> {
37
+ name: string;
38
+ requires: FlowKey<unknown>[];
39
+ produces: FlowKey<unknown>[];
40
+ process(ctx: FlowContext): Promise<void> | void;
41
+ }
42
+ /**
43
+ * Guards an external transition. Pure function — must not mutate FlowContext.
44
+ *
45
+ * TTL vs GuardOutput.expired: FlowInstance TTL is checked at resumeAndExecute
46
+ * entry (flow-level). GuardOutput 'expired' is guard-level for business logic.
47
+ */
48
+ export interface TransitionGuard<S extends string> {
49
+ name: string;
50
+ requires: FlowKey<unknown>[];
51
+ produces: FlowKey<unknown>[];
52
+ maxRetries: number;
53
+ validate(ctx: FlowContext): Promise<GuardOutput> | GuardOutput;
54
+ }
55
+ /** Decides which branch to take based on FlowContext state. */
56
+ export interface BranchProcessor<S extends string> {
57
+ name: string;
58
+ requires: FlowKey<unknown>[];
59
+ decide(ctx: FlowContext): Promise<string> | string;
60
+ }
61
+ /**
62
+ * Persistence contract for flow instances.
63
+ *
64
+ * Threading: FlowEngine assumes single-threaded access per flow instance.
65
+ * Atomicity: create/save and recordTransition form a logical unit.
66
+ * If partial writes occur, save is authoritative over the transition log.
67
+ */
68
+ export interface FlowStore {
69
+ create(flow: unknown): void | Promise<void>;
70
+ loadForUpdate<S extends string>(flowId: string): unknown | undefined | Promise<unknown | undefined>;
71
+ save(flow: unknown): void | Promise<void>;
72
+ recordTransition(flowId: string, from: string | null, to: string, trigger: string, ctx: FlowContext): void | Promise<void>;
73
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@unlaxer/tramli",
3
+ "version": "0.1.0",
4
+ "description": "Constrained flow engine — state machines that prevent invalid transitions at build time",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "import": "./dist/index.js",
9
+ "types": "./dist/index.d.ts"
10
+ }
11
+ },
12
+ "files": ["dist"],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "test": "vitest run",
16
+ "prepublishOnly": "npm run build && npm test"
17
+ },
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/opaopa6969/tramli-ts"
25
+ },
26
+ "author": "Hisayuki Ookubo <opaopa6969@gmail.com>",
27
+ "devDependencies": {
28
+ "typescript": "^5.5.0",
29
+ "vitest": "^2.0.0"
30
+ }
31
+ }