@classytic/primitives 0.1.1 → 0.2.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/CHANGELOG.md CHANGED
@@ -3,6 +3,43 @@
3
3
  Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
4
  adhering to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## [0.2.0]
7
+
8
+ ### Added
9
+
10
+ - **`/state-machine` subpath** — `defineStateMachine<TStatus>({ name, transitions, errorFactory? })`
11
+ declarative state-transition primitive. Replaces hand-rolled
12
+ `if (status === 'X') throw` blocks across kernels:
13
+
14
+ ```typescript
15
+ import { defineStateMachine } from '@classytic/primitives/state-machine';
16
+
17
+ const ORDER_MACHINE = defineStateMachine<OrderStatus>({
18
+ name: 'Order',
19
+ transitions: {
20
+ draft: ['approved', 'cancelled'],
21
+ approved: ['shipped', 'cancelled'],
22
+ shipped: [],
23
+ cancelled: [],
24
+ },
25
+ });
26
+
27
+ ORDER_MACHINE.assertTransition('order-1', 'draft', 'approved'); // OK
28
+ ORDER_MACHINE.assertTransition('order-1', 'shipped', 'draft'); // throws
29
+ ORDER_MACHINE.canTransition('draft', 'shipped'); // false
30
+ ORDER_MACHINE.isTerminal('shipped'); // true
31
+ ```
32
+
33
+ Generic over the status string-union — `Record<TStatus, readonly TStatus[]>`
34
+ on `transitions` enforces exhaustiveness at the call site. Kernels with their
35
+ own typed transition error class (e.g. flow's `InvalidTransitionError`) wire
36
+ it via `errorFactory` so the thrown type stays domain-consistent. Standalone
37
+ callers get the bundled `IllegalTransitionError` (carries `entityType` /
38
+ `entityId` / `from` / `to` + `code: 'illegal_transition'` + `status: 422`).
39
+
40
+ First consumer: `@classytic/flow` `PROCUREMENT_MACHINE` (procurement-order
41
+ approve / cancel transitions).
42
+
6
43
  ## [0.1.1]
7
44
 
8
45
  ### Added
@@ -0,0 +1,103 @@
1
+ //#region src/state-machine.d.ts
2
+ /**
3
+ * Declarative state-machine primitive.
4
+ *
5
+ * Replaces hand-rolled `if (status === 'X') throw` blocks scattered
6
+ * across domain services. Each aggregate declares one transition table;
7
+ * the helper provides `canTransition` / `assertTransition` / `isTerminal`
8
+ * accessors that the services share.
9
+ *
10
+ * Generic over the status string-union so calls stay type-safe:
11
+ * `defineStateMachine<ProcurementStatus>({ name: 'ProcurementOrder', transitions: { … } })`
12
+ * The TS compiler enforces exhaustiveness — every status string in the
13
+ * union MUST have an entry in `transitions` (terminal statuses map to `[]`).
14
+ *
15
+ * @example
16
+ * import { defineStateMachine } from '@classytic/primitives/state-machine';
17
+ *
18
+ * type OrderStatus = 'draft' | 'approved' | 'shipped' | 'cancelled';
19
+ * const ORDER_MACHINE = defineStateMachine<OrderStatus>({
20
+ * name: 'Order',
21
+ * transitions: {
22
+ * draft: ['approved', 'cancelled'],
23
+ * approved: ['shipped', 'cancelled'],
24
+ * shipped: [],
25
+ * cancelled: [],
26
+ * },
27
+ * });
28
+ *
29
+ * ORDER_MACHINE.assertTransition('order-1', 'draft', 'approved'); // OK
30
+ * ORDER_MACHINE.assertTransition('order-1', 'shipped', 'draft'); // throws
31
+ * ORDER_MACHINE.canTransition('draft', 'shipped'); // false
32
+ * ORDER_MACHINE.isTerminal('shipped'); // true
33
+ *
34
+ * Hosts that already have their own typed transition error can swap it
35
+ * in via `errorFactory`:
36
+ *
37
+ * @example
38
+ * import { InvalidTransitionError } from './errors.js';
39
+ * const FLOW_MACHINE = defineStateMachine<MoveStatus>({
40
+ * name: 'StockMove',
41
+ * transitions: { … },
42
+ * errorFactory: ({ entityType, entityId, from, to }) =>
43
+ * new InvalidTransitionError(entityType, entityId, from, to),
44
+ * });
45
+ */
46
+ /**
47
+ * Default error thrown by `assertTransition` when no `errorFactory` is
48
+ * supplied. Carries the structured fields (`entityType`, `entityId`,
49
+ * `from`, `to`) plus a stable `code` so hosts can pattern-match on
50
+ * either `instanceof` or `error.code === 'illegal_transition'`.
51
+ */
52
+ declare class IllegalTransitionError extends Error {
53
+ readonly code: "illegal_transition";
54
+ readonly status = 422;
55
+ readonly entityType: string;
56
+ readonly entityId: string;
57
+ readonly from: string;
58
+ readonly to: string;
59
+ constructor(entityType: string, entityId: string, from: string, to: string);
60
+ }
61
+ interface TransitionErrorContext {
62
+ readonly entityType: string;
63
+ readonly entityId: string;
64
+ readonly from: string;
65
+ readonly to: string;
66
+ }
67
+ interface StateMachineDefinition<TStatus extends string> {
68
+ /** Aggregate name surfaced in the thrown error's `entityType`. */
69
+ name: string;
70
+ /**
71
+ * Adjacency list — for each status, the set of statuses you can
72
+ * legally transition TO. Terminal statuses map to an empty array.
73
+ * `Record<TStatus, …>` enforces exhaustiveness at the call site.
74
+ */
75
+ transitions: Record<TStatus, readonly TStatus[]>;
76
+ /**
77
+ * Optional custom error factory. When omitted, `assertTransition`
78
+ * throws `IllegalTransitionError`. Hosts that own a domain error
79
+ * (e.g. flow's `InvalidTransitionError`) wire their own here so the
80
+ * thrown type stays consistent with the rest of the package.
81
+ */
82
+ errorFactory?: (ctx: TransitionErrorContext) => Error;
83
+ }
84
+ interface StateMachine<TStatus extends string> {
85
+ /** The aggregate name (`StockMove`, `ProcurementOrder`, etc.). */
86
+ readonly name: string;
87
+ /** All terminal statuses (no outgoing transitions). */
88
+ readonly terminal: ReadonlySet<TStatus>;
89
+ /** True when `from → to` is in the transition table. */
90
+ canTransition(from: TStatus, to: TStatus): boolean;
91
+ /** True when `status` is terminal. */
92
+ isTerminal(status: TStatus): boolean;
93
+ /**
94
+ * Throw a transition error when `from → to` isn't allowed.
95
+ * `entityId` is included in the error for traceability.
96
+ */
97
+ assertTransition(entityId: string, from: TStatus, to: TStatus): void;
98
+ /** The raw transition table — exposed read-only for advanced callers. */
99
+ readonly transitions: Readonly<Record<TStatus, readonly TStatus[]>>;
100
+ }
101
+ declare function defineStateMachine<TStatus extends string>(definition: StateMachineDefinition<TStatus>): StateMachine<TStatus>;
102
+ //#endregion
103
+ export { IllegalTransitionError, StateMachine, StateMachineDefinition, TransitionErrorContext, defineStateMachine };
@@ -0,0 +1,94 @@
1
+ //#region src/state-machine.ts
2
+ /**
3
+ * Declarative state-machine primitive.
4
+ *
5
+ * Replaces hand-rolled `if (status === 'X') throw` blocks scattered
6
+ * across domain services. Each aggregate declares one transition table;
7
+ * the helper provides `canTransition` / `assertTransition` / `isTerminal`
8
+ * accessors that the services share.
9
+ *
10
+ * Generic over the status string-union so calls stay type-safe:
11
+ * `defineStateMachine<ProcurementStatus>({ name: 'ProcurementOrder', transitions: { … } })`
12
+ * The TS compiler enforces exhaustiveness — every status string in the
13
+ * union MUST have an entry in `transitions` (terminal statuses map to `[]`).
14
+ *
15
+ * @example
16
+ * import { defineStateMachine } from '@classytic/primitives/state-machine';
17
+ *
18
+ * type OrderStatus = 'draft' | 'approved' | 'shipped' | 'cancelled';
19
+ * const ORDER_MACHINE = defineStateMachine<OrderStatus>({
20
+ * name: 'Order',
21
+ * transitions: {
22
+ * draft: ['approved', 'cancelled'],
23
+ * approved: ['shipped', 'cancelled'],
24
+ * shipped: [],
25
+ * cancelled: [],
26
+ * },
27
+ * });
28
+ *
29
+ * ORDER_MACHINE.assertTransition('order-1', 'draft', 'approved'); // OK
30
+ * ORDER_MACHINE.assertTransition('order-1', 'shipped', 'draft'); // throws
31
+ * ORDER_MACHINE.canTransition('draft', 'shipped'); // false
32
+ * ORDER_MACHINE.isTerminal('shipped'); // true
33
+ *
34
+ * Hosts that already have their own typed transition error can swap it
35
+ * in via `errorFactory`:
36
+ *
37
+ * @example
38
+ * import { InvalidTransitionError } from './errors.js';
39
+ * const FLOW_MACHINE = defineStateMachine<MoveStatus>({
40
+ * name: 'StockMove',
41
+ * transitions: { … },
42
+ * errorFactory: ({ entityType, entityId, from, to }) =>
43
+ * new InvalidTransitionError(entityType, entityId, from, to),
44
+ * });
45
+ */
46
+ /**
47
+ * Default error thrown by `assertTransition` when no `errorFactory` is
48
+ * supplied. Carries the structured fields (`entityType`, `entityId`,
49
+ * `from`, `to`) plus a stable `code` so hosts can pattern-match on
50
+ * either `instanceof` or `error.code === 'illegal_transition'`.
51
+ */
52
+ var IllegalTransitionError = class extends Error {
53
+ code = "illegal_transition";
54
+ status = 422;
55
+ entityType;
56
+ entityId;
57
+ from;
58
+ to;
59
+ constructor(entityType, entityId, from, to) {
60
+ super(`Invalid transition for ${entityType} ${entityId}: ${from} → ${to}`);
61
+ this.name = "IllegalTransitionError";
62
+ this.entityType = entityType;
63
+ this.entityId = entityId;
64
+ this.from = from;
65
+ this.to = to;
66
+ }
67
+ };
68
+ function defineStateMachine(definition) {
69
+ const { name, transitions, errorFactory } = definition;
70
+ const terminal = /* @__PURE__ */ new Set();
71
+ for (const [status, allowed] of Object.entries(transitions)) if (allowed.length === 0) terminal.add(status);
72
+ const buildError = errorFactory ? errorFactory : (ctx) => new IllegalTransitionError(ctx.entityType, ctx.entityId, ctx.from, ctx.to);
73
+ return {
74
+ name,
75
+ terminal,
76
+ transitions,
77
+ canTransition(from, to) {
78
+ return transitions[from]?.includes(to) ?? false;
79
+ },
80
+ isTerminal(status) {
81
+ return terminal.has(status);
82
+ },
83
+ assertTransition(entityId, from, to) {
84
+ if (!transitions[from]?.includes(to)) throw buildError({
85
+ entityType: name,
86
+ entityId,
87
+ from,
88
+ to
89
+ });
90
+ }
91
+ };
92
+ }
93
+ //#endregion
94
+ export { IllegalTransitionError, defineStateMachine };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/primitives",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Framework-agnostic TypeScript primitives shared across Classytic packages — Money, events, pagination, tenant config, and other building blocks.",
5
5
  "author": "Classytic",
6
6
  "license": "MIT",
@@ -97,6 +97,10 @@
97
97
  "types": "./dist/person.d.mts",
98
98
  "default": "./dist/person.mjs"
99
99
  },
100
+ "./state-machine": {
101
+ "types": "./dist/state-machine.d.mts",
102
+ "default": "./dist/state-machine.mjs"
103
+ },
100
104
  "./package.json": "./package.json"
101
105
  },
102
106
  "files": [