Documenting client/assert.js and client/fsm.js

main
Zed A. Shaw 2 years ago
parent 85830853ad
commit a58a4d0310
  1. 4
      api/payments/stripe.js
  2. 31
      client/assert.js
  3. 164
      client/fsm.js

@ -2,7 +2,7 @@ import { Product, Payment } from '../../lib/models.js';
import { API, developer_admin } from "../../lib/api.js"; import { API, developer_admin } from "../../lib/api.js";
import logging from '../../lib/logging.js'; import logging from '../../lib/logging.js';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { product as product_config, register_enabled } from "../../client/config.js"; import { product_id, register_enabled } from "../../client/config.js";
import assert from 'assert'; import assert from 'assert';
import * as queues from "../../lib/queues.js"; import * as queues from "../../lib/queues.js";
import stripe_create from "stripe"; import stripe_create from "stripe";
@ -12,7 +12,7 @@ const log = logging.create("api/payments/stripe.js");
const stripe = stripe_create(stripe_private.secret); const stripe = stripe_create(stripe_private.secret);
const product = await Product.first({id: product_config.id}); const product = await Product.first({id: product_id});
const rules = { const rules = {
"payment_intent": "required", "payment_intent": "required",

@ -1,13 +1,19 @@
/*
A stupid thing I wrote so I could have the same assert function in the browser that I
have in Node. This should just be standard in JavaScript.
*/
import { log } from "./logging.js"; import { log } from "./logging.js";
/* I can't believe I have to write this just so I can use the assert that should be standard in every javascript. */ /*
This is the error that is thrown when an `assert()` fails.
export class AssertionError extends Error {
/* This is from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error
* which claims you have to do this weird stuff to make your error actually work.
*/ */
export class AssertionError extends Error {
constructor(foo = 'bar', ...params) { /*
This is from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error
which claims you have to do this weird stuff to make your error actually work.
*/
constructor(...params) {
// Pass remaining arguments (including vendor specific ones) to parent constructor // Pass remaining arguments (including vendor specific ones) to parent constructor
super(...params); super(...params);
@ -19,15 +25,22 @@ export class AssertionError extends Error {
this.name = 'AssertionError'; this.name = 'AssertionError';
// Custom debugging information // Custom debugging information
this.foo = foo;
this.date = new Date(); this.date = new Date();
} }
} }
const assert = (test, message) => { /*
log.assert(test, message); If `test` is false then it logs the message and raises the `AssertionError`.
___TODO___: Implement a way to "compile out" these assertions.
+ `test boolean` -- The code only does `if(!test)` so should work with most any falsy thing.
+ `message string` -- The message to log and then attach to the thrown `AssertionError`.
+ ___throws___ `AssertionError(message)`
*/
export const assert = (test, message) => {
if(!test) { if(!test) {
log.assert(test, message);
throw new AssertionError(message); throw new AssertionError(message);
} }
} }

@ -1,8 +1,145 @@
/*
A very simple Finite State Machine system. If you're struggling with managing
a mixture of JavaScript API styles (Promise, callback, events) then this will
help get things organized. The best example is in the `client/components/HLSVideo.svelte` file.
The main advantage an FSM has is it's ability to constrain and organize random events, and it's
ability to fully log what's happening in a stream of random events. The `HLSVideo.svelte` example
is a great demonstration of this because it has to juggle events from two different styles of APIs,
network events, user UI events, and __video__ loading events. Since all of these can come in at
random the FSM will help order them and do the right thing at the right time.
### Theory
One way to look at a Finite State Machine is an ___explicit if-while-loop___.
If you have a `while-loop` that is doing a lot of processing based on
variables, and you're using a lot of `if-statements` or `switch-case`
statements, then the `FSM` simply formalizes exactly __what__ states and when
they can run.
For example, if you have a mess of nested `if-statements`, and each one might
not cover every possible variable, then you'll have bugs as different
branches of the `if-statements` run or don't run. The `FSM` simply says, "The only
allowed states at this point in time are X, Y, and Z. The only events I expect are
event1, event2, and event3. If I get anything else that's an error."
It's a different way to think about data processing, but it is very reliable _and_
easy to debug because you can log every event, state, and all the data while processing
random events.
### FSMs in User Interfaces
Most UIs are simple enough that you can get away with the basic Svelte variables updating
the state. When UIs get more complex--and especially when they trigger network events--you
run into problems with random events requiring different updates to the UI. For example, if
a user clicks on a button that loads a video, but they're already loading a different video,
then you have to deal with the current click, the old video loading state, and the new video
playing state. An FSM would be able to "serialize" the click, old video load, and new video
load and play better than the usual Svelte variables.
In short, if you're struggling with properly updating a user interface as random events come
in from the user and the network, then switch to an FSM.
### Usage
The `class FSM` takes two things: data and a event transition object. The `data` is only
for logging and debugging the FSM, as it gets printed out with each log message. The core
of the FSM is your event handler, which has functions named after each event:
```javascript
class VideoEvents {
async mount(state) {
case "START":
return "COUNTING_DOWN";
default:
return "ERROR";
}
}
```
In this snippet the `class VideoEvents` has a `async mount` event that works like this:
+ This `mount()` event takes a single `state` which is just a string for the state the FSM is currently in.
+ You then figure out what to do based on this `state`, typically using a `switch/case`.
+ Based on what you've done, you return the next state as another string.
+ The `FSM` then waits for a new event, and calls that function with this new state.
To trigger this `mount` event you would then write:
```javascript
await fsm.do("mount");
```
You can pass additional arguments to the `do` function and they will be passed to the
event as additional arguments:
```javascript
// if fsm is in "START" state
await fsm.do("mount", 1, 2, 3);
// then becomes
ev.mount("START", 1, 2, 3);
```
### Logging Events
The `FSM` class will already call `log.debug` on many things, but if you want to do your own logging
you can use the `onTransition` callback:
```javascript
fsm = new FSM(video_config, new VideoEvents());
fsm.onTransition(fsm => {
video_config.state = fsm.state
log.info("I'm logging something else too", the_other_thing);
});
```
This is a slight modification of how the FSM is used in `HLSVideo.svelte`. Remember that the `video_config` is only passed to `FSM()` so that it gets logged while the `FSM` is running. This is _incredibly_ useful when debugging why your `FSM` is doing something.
In the `onTransition` callback I'm simply updating the `state` in `video_config` and then I added a little `log.info` to show you could log anything else you wanted there.
### Internal Transitions
If I'm remembering my graduate courses right, this is called an "epsilon transition", but it's
basically triggering another event inside the current event, effectively changing state without
waiting for a new event. I could be wrong on the terminology, but you do this by returning an
`Array[state, cb()]` pair. The `FSM` will then set state to `state` and call your callback so
you can do further processing.
___BUG___: This could be improved but for now it works. Look at `HLSVideo.svelte:detect_video` for an example of doing this.
### State Names
The first state is "START" by default, but after that you can use any string. I prefer to make
the states UPPERCASE so they aren't confused with events, which are `lowercase()` function
names in the event handler class (`VideoEvents` above).
*/
import assert from "./assert.js"; import assert from "./assert.js";
import { log } from "./logging.js"; import { log } from "./logging.js";
/*
A very simple Finite State Machine class. Nothing fancy, just a state
and some transition callbacks based on the function names found in `this.events`.
*/
export class FSM { export class FSM {
/*
Sets up this FSM to use the `events` callback as the transitions, and then
"START" as the first state. It does ___not___ call any initial transitions
so you have to do the first call to `FSM.do()`. The `data` isn't used
internally and only passed to logging functions for debugging.
### Event Class Signature
The `events` can be anything, and you construct it so it will have your own data.
The only requirement is that the functions have a `name(state)` signature and that
`state` is going to be a string. It should then return the next state string for
the next event to receive.
+ `data Anything` -- Whatever you want to log while debugging.
+ `events Event Object` -- An object/class that can handle event calls sent to it.
*/
constructor(data, events) { constructor(data, events) {
// data is really only for logging/debugging // data is really only for logging/debugging
this.data = data; this.data = data;
@ -11,10 +148,25 @@ export class FSM {
this.on_state = undefined; this.on_state = undefined;
} }
/*
A callback that runs whenever an event handler returns a state. The `cb` callback
receives this `FSM` so it can debug it or do more to it.
___WARNING___: This is called called even if the state didn't change, which
might confuse people if they think it's only when a "transition" happens.
+ `cb(FSM)` -- Callback after the transition. Receives this `FSM`.
*/
onTransition(cb) { onTransition(cb) {
this.transition_cb = cb; this.transition_cb = cb;
} }
/*
Mostly used internally to perform the transition then call the `onTransition` callback,
but sometimes you might need to force a new state externally.
+ `next_state string` -- The new state name.
*/
transition(next_state) { transition(next_state) {
this.state = next_state; this.state = next_state;
@ -23,10 +175,22 @@ export class FSM {
} }
} }
/*
All event names available in the `this.events` class.
*/
event_names() { event_names() {
return Object.getOwnPropertyNames(Object.getPrototypeOf(this.events)).filter(k => k !== "constructor"); return Object.getOwnPropertyNames(Object.getPrototypeOf(this.events)).filter(k => k !== "constructor");
} }
/*
The core of this `FSM` is the `do()` function. It handles finding the next
event by it's name, loading it, and calling it with the given additional
arguments.
+ `event string` -- The __name__ of the target event, not the actual function. It's a string.
+ `...args` -- Additional arguments to pass to the event.
+ ___return___ `this.state string` -- The current state of this `FSM`.
*/
async do(event, ...args) { async do(event, ...args) {
const evhandler = this.events[event]; const evhandler = this.events[event];
assert(evhandler !== undefined, `Invalid event ${event}. Available ones are '${this.event_names()}'.`); assert(evhandler !== undefined, `Invalid event ${event}. Available ones are '${this.event_names()}'.`);

Loading…
Cancel
Save