@classytic/primitives 0.3.0 → 0.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/CHANGELOG.md +64 -0
- package/dist/state-machine.d.mts +81 -1
- package/dist/state-machine.mjs +69 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,70 @@
|
|
|
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.3.1] - 2026-05-02
|
|
7
|
+
|
|
8
|
+
### Added — state-machine ↔ mongokit/sqlitekit integration
|
|
9
|
+
|
|
10
|
+
State-machine primitives now compose directly with the kit-level CAS surface shipped in mongokit 3.13 and the sqlitekit follow-up. Three additions:
|
|
11
|
+
|
|
12
|
+
- **`StateMachine.validTargets(from)`** — every status the aggregate can move TO from `from`. Useful for UI dropdowns ("which actions are legal right now?") and for fanning a single source state into a multi-source CAS via a repo's `claim({ from: machine.validTargets(current), to })`.
|
|
13
|
+
- **`StateMachine.validSources(to)`** — every status that can transition INTO `to` (reverse adjacency). Pair with a repo's multi-source `claim({ from: machine.validSources(to), to })` when the target is fixed but the caller doesn't yet know the current state — the canonical "cancel from any non-terminal" / "error from any in-flight" pattern. Pre-computed at definition time, O(1) lookup, frozen arrays safe to expose by reference.
|
|
14
|
+
- **`assertAndClaim(machine, repo, id, args)`** — pairs `machine.assertTransition` (sync domain check) with `repo.claim()` (atomic CAS) in one call. The canonical state-machine-backed CAS pattern. Skipping either layer leaves a hole: skip `assertTransition` and bad transitions reach storage; skip `claim` and concurrent writers race.
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { defineStateMachine, assertAndClaim } from '@classytic/primitives/state-machine';
|
|
18
|
+
|
|
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
|
+
// Single-source:
|
|
30
|
+
const updated = await assertAndClaim(ORDER_MACHINE, orderRepo, orderId, {
|
|
31
|
+
from: 'draft',
|
|
32
|
+
to: 'approved',
|
|
33
|
+
patch: { approvedAt: new Date() },
|
|
34
|
+
options: { organizationId: ctx.organizationId, session },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Multi-source via reverse adjacency — "cancel from any legal predecessor":
|
|
38
|
+
await assertAndClaim(ORDER_MACHINE, orderRepo, orderId, {
|
|
39
|
+
from: ORDER_MACHINE.validSources('cancelled'), // ['draft', 'approved']
|
|
40
|
+
to: 'cancelled',
|
|
41
|
+
patch: { cancelledAt: new Date() },
|
|
42
|
+
options: { organizationId: ctx.organizationId },
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
#### `ClaimableRepo<TDoc>` — structural dependency on the kit
|
|
47
|
+
|
|
48
|
+
`assertAndClaim` accepts any object matching `ClaimableRepo<TDoc>` — a structural type that mirrors the mongokit 3.13 `Repository.claim()` signature. Primitives doesn't take a hard peer dep on mongokit; consumers wire the kit they want. Sqlitekit's `claim()` matches the same shape (verified by mongokit/sqlitekit cross-conformance).
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
export interface ClaimableRepo<TDoc> {
|
|
52
|
+
claim(
|
|
53
|
+
id: string,
|
|
54
|
+
transition: {
|
|
55
|
+
field?: string;
|
|
56
|
+
from: unknown | readonly unknown[];
|
|
57
|
+
to: unknown;
|
|
58
|
+
where?: Record<string, unknown>;
|
|
59
|
+
},
|
|
60
|
+
patch?: Record<string, unknown>,
|
|
61
|
+
options?: Record<string, unknown>,
|
|
62
|
+
): Promise<TDoc | null>;
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
#### Why a 0.3.1 (not 0.4.0)
|
|
67
|
+
|
|
68
|
+
Purely additive — no removals, no signature changes. `defineStateMachine` callers from 0.3.0 see two new methods on the returned `StateMachine` (`validTargets`, `validSources`) and one new free function (`assertAndClaim`). Backwards-compatible.
|
|
69
|
+
|
|
6
70
|
## [0.3.0] - 2026-04-29
|
|
7
71
|
|
|
8
72
|
### Removed — `/errors` subpath (BREAKING)
|
package/dist/state-machine.d.mts
CHANGED
|
@@ -95,9 +95,89 @@ interface StateMachine<TStatus extends string> {
|
|
|
95
95
|
* `entityId` is included in the error for traceability.
|
|
96
96
|
*/
|
|
97
97
|
assertTransition(entityId: string, from: TStatus, to: TStatus): void;
|
|
98
|
+
/**
|
|
99
|
+
* Forward lookup — every status the aggregate can move TO from `from`.
|
|
100
|
+
* Useful for UI dropdowns ("which actions are legal right now?") and
|
|
101
|
+
* for fanning a single source state into a multi-source CAS via a
|
|
102
|
+
* repo's `claim({ from: machine.validTargets(current), to })`.
|
|
103
|
+
*/
|
|
104
|
+
validTargets(from: TStatus): readonly TStatus[];
|
|
105
|
+
/**
|
|
106
|
+
* Reverse lookup — every status that can transition INTO `to`. Pair
|
|
107
|
+
* with a repo's multi-source `claim({ from: machine.validSources(to), to })`
|
|
108
|
+
* when the target is fixed but the caller doesn't yet know the
|
|
109
|
+
* current state (or wants to authorize ALL legal predecessors in one
|
|
110
|
+
* round-trip).
|
|
111
|
+
*/
|
|
112
|
+
validSources(to: TStatus): readonly TStatus[];
|
|
98
113
|
/** The raw transition table — exposed read-only for advanced callers. */
|
|
99
114
|
readonly transitions: Readonly<Record<TStatus, readonly TStatus[]>>;
|
|
100
115
|
}
|
|
101
116
|
declare function defineStateMachine<TStatus extends string>(definition: StateMachineDefinition<TStatus>): StateMachine<TStatus>;
|
|
117
|
+
/**
|
|
118
|
+
* Minimal structural type for any kit's `claim()` operation. Lets
|
|
119
|
+
* `assertAndClaim` work against `@classytic/mongokit` (and future
|
|
120
|
+
* sqlitekit / pgkit) without primitives taking a hard peer dep.
|
|
121
|
+
*
|
|
122
|
+
* Mirrors the mongokit 3.13 `Repository.claim()` signature with
|
|
123
|
+
* `unknown`-typed values where the kit-specific shape (ObjectId, etc.)
|
|
124
|
+
* doesn't concern the state-machine layer.
|
|
125
|
+
*/
|
|
126
|
+
interface ClaimableRepo<TDoc> {
|
|
127
|
+
claim(id: string, transition: {
|
|
128
|
+
field?: string;
|
|
129
|
+
from: unknown | readonly unknown[];
|
|
130
|
+
to: unknown;
|
|
131
|
+
where?: Record<string, unknown>;
|
|
132
|
+
}, patch?: Record<string, unknown>, options?: Record<string, unknown>): Promise<TDoc | null>;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Pair a state-machine `assertTransition()` (sync domain check) with a
|
|
136
|
+
* repo's `claim()` (runtime concurrency CAS) in one call. This is the
|
|
137
|
+
* canonical state-machine-backed CAS pattern documented in mongokit
|
|
138
|
+
* 3.13's CLAUDE.md.
|
|
139
|
+
*
|
|
140
|
+
* - `assertTransition` rejects malformed transitions BEFORE we hit the
|
|
141
|
+
* database (compile-time-typed targets, sync, fast).
|
|
142
|
+
* - `claim` rejects concurrent writers AT the database (atomic
|
|
143
|
+
* CAS, returns `null` on race-loss).
|
|
144
|
+
*
|
|
145
|
+
* Skipping either layer leaves a hole: skip `assertTransition` and bad
|
|
146
|
+
* transitions reach storage; skip `claim` and concurrent writers race.
|
|
147
|
+
*
|
|
148
|
+
* `from` accepts a single status OR an array of statuses (multi-source
|
|
149
|
+
* CAS). When the array form is used, `assertTransition` is asserted for
|
|
150
|
+
* EVERY listed source — a single illegal source aborts the call before
|
|
151
|
+
* the round-trip.
|
|
152
|
+
*
|
|
153
|
+
* @example Single-source
|
|
154
|
+
* ```ts
|
|
155
|
+
* const updated = await assertAndClaim(WAVE_MACHINE, repo, waveId, {
|
|
156
|
+
* from: 'planned',
|
|
157
|
+
* to: 'released',
|
|
158
|
+
* patch: { releasedAt: new Date() },
|
|
159
|
+
* options: { organizationId: ctx.organizationId, session },
|
|
160
|
+
* });
|
|
161
|
+
* if (!updated) throw new ConcurrencyError(); // race-loss
|
|
162
|
+
* ```
|
|
163
|
+
*
|
|
164
|
+
* @example Multi-source via `validSources`
|
|
165
|
+
* ```ts
|
|
166
|
+
* const updated = await assertAndClaim(WAVE_MACHINE, repo, waveId, {
|
|
167
|
+
* from: WAVE_MACHINE.validSources('cancelled'), // every legal predecessor
|
|
168
|
+
* to: 'cancelled',
|
|
169
|
+
* patch: { cancelledAt: new Date() },
|
|
170
|
+
* options: { organizationId: ctx.organizationId },
|
|
171
|
+
* });
|
|
172
|
+
* ```
|
|
173
|
+
*/
|
|
174
|
+
declare function assertAndClaim<TDoc, TStatus extends string>(machine: StateMachine<TStatus>, repo: ClaimableRepo<TDoc>, id: string, args: {
|
|
175
|
+
from: TStatus | readonly TStatus[];
|
|
176
|
+
to: TStatus;
|
|
177
|
+
field?: string;
|
|
178
|
+
patch?: Record<string, unknown>;
|
|
179
|
+
where?: Record<string, unknown>;
|
|
180
|
+
options?: Record<string, unknown>;
|
|
181
|
+
}): Promise<TDoc | null>;
|
|
102
182
|
//#endregion
|
|
103
|
-
export { IllegalTransitionError, StateMachine, StateMachineDefinition, TransitionErrorContext, defineStateMachine };
|
|
183
|
+
export { ClaimableRepo, IllegalTransitionError, StateMachine, StateMachineDefinition, TransitionErrorContext, assertAndClaim, defineStateMachine };
|
package/dist/state-machine.mjs
CHANGED
|
@@ -68,7 +68,18 @@ var IllegalTransitionError = class extends Error {
|
|
|
68
68
|
function defineStateMachine(definition) {
|
|
69
69
|
const { name, transitions, errorFactory } = definition;
|
|
70
70
|
const terminal = /* @__PURE__ */ new Set();
|
|
71
|
-
|
|
71
|
+
const reverse = /* @__PURE__ */ new Map();
|
|
72
|
+
for (const [status, allowed] of Object.entries(transitions)) {
|
|
73
|
+
if (allowed.length === 0) terminal.add(status);
|
|
74
|
+
for (const target of allowed) {
|
|
75
|
+
const sources = reverse.get(target);
|
|
76
|
+
if (sources) sources.push(status);
|
|
77
|
+
else reverse.set(target, [status]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const frozenReverse = /* @__PURE__ */ new Map();
|
|
81
|
+
for (const [target, sources] of reverse) frozenReverse.set(target, Object.freeze(sources.slice()));
|
|
82
|
+
const EMPTY = Object.freeze([]);
|
|
72
83
|
const buildError = errorFactory ? errorFactory : (ctx) => new IllegalTransitionError(ctx.entityType, ctx.entityId, ctx.from, ctx.to);
|
|
73
84
|
return {
|
|
74
85
|
name,
|
|
@@ -87,8 +98,64 @@ function defineStateMachine(definition) {
|
|
|
87
98
|
from,
|
|
88
99
|
to
|
|
89
100
|
});
|
|
101
|
+
},
|
|
102
|
+
validTargets(from) {
|
|
103
|
+
return transitions[from] ?? EMPTY;
|
|
104
|
+
},
|
|
105
|
+
validSources(to) {
|
|
106
|
+
return frozenReverse.get(to) ?? EMPTY;
|
|
90
107
|
}
|
|
91
108
|
};
|
|
92
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Pair a state-machine `assertTransition()` (sync domain check) with a
|
|
112
|
+
* repo's `claim()` (runtime concurrency CAS) in one call. This is the
|
|
113
|
+
* canonical state-machine-backed CAS pattern documented in mongokit
|
|
114
|
+
* 3.13's CLAUDE.md.
|
|
115
|
+
*
|
|
116
|
+
* - `assertTransition` rejects malformed transitions BEFORE we hit the
|
|
117
|
+
* database (compile-time-typed targets, sync, fast).
|
|
118
|
+
* - `claim` rejects concurrent writers AT the database (atomic
|
|
119
|
+
* CAS, returns `null` on race-loss).
|
|
120
|
+
*
|
|
121
|
+
* Skipping either layer leaves a hole: skip `assertTransition` and bad
|
|
122
|
+
* transitions reach storage; skip `claim` and concurrent writers race.
|
|
123
|
+
*
|
|
124
|
+
* `from` accepts a single status OR an array of statuses (multi-source
|
|
125
|
+
* CAS). When the array form is used, `assertTransition` is asserted for
|
|
126
|
+
* EVERY listed source — a single illegal source aborts the call before
|
|
127
|
+
* the round-trip.
|
|
128
|
+
*
|
|
129
|
+
* @example Single-source
|
|
130
|
+
* ```ts
|
|
131
|
+
* const updated = await assertAndClaim(WAVE_MACHINE, repo, waveId, {
|
|
132
|
+
* from: 'planned',
|
|
133
|
+
* to: 'released',
|
|
134
|
+
* patch: { releasedAt: new Date() },
|
|
135
|
+
* options: { organizationId: ctx.organizationId, session },
|
|
136
|
+
* });
|
|
137
|
+
* if (!updated) throw new ConcurrencyError(); // race-loss
|
|
138
|
+
* ```
|
|
139
|
+
*
|
|
140
|
+
* @example Multi-source via `validSources`
|
|
141
|
+
* ```ts
|
|
142
|
+
* const updated = await assertAndClaim(WAVE_MACHINE, repo, waveId, {
|
|
143
|
+
* from: WAVE_MACHINE.validSources('cancelled'), // every legal predecessor
|
|
144
|
+
* to: 'cancelled',
|
|
145
|
+
* patch: { cancelledAt: new Date() },
|
|
146
|
+
* options: { organizationId: ctx.organizationId },
|
|
147
|
+
* });
|
|
148
|
+
* ```
|
|
149
|
+
*/
|
|
150
|
+
async function assertAndClaim(machine, repo, id, args) {
|
|
151
|
+
const sources = Array.isArray(args.from) ? args.from : [args.from];
|
|
152
|
+
for (const from of sources) machine.assertTransition(id, from, args.to);
|
|
153
|
+
return repo.claim(id, {
|
|
154
|
+
from: Array.isArray(args.from) ? args.from : args.from,
|
|
155
|
+
to: args.to,
|
|
156
|
+
...args.field !== void 0 ? { field: args.field } : {},
|
|
157
|
+
...args.where !== void 0 ? { where: args.where } : {}
|
|
158
|
+
}, args.patch, args.options);
|
|
159
|
+
}
|
|
93
160
|
//#endregion
|
|
94
|
-
export { IllegalTransitionError, defineStateMachine };
|
|
161
|
+
export { IllegalTransitionError, assertAndClaim, defineStateMachine };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/primitives",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|