From a58a4d0310f1797ad5f368f61fc5b39ec8f6bf89 Mon Sep 17 00:00:00 2001 From: "Zed A. Shaw" Date: Tue, 27 Dec 2022 12:00:18 +0700 Subject: [PATCH] Documenting client/assert.js and client/fsm.js --- api/payments/stripe.js | 4 +- client/assert.js | 31 +++++--- client/fsm.js | 164 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 11 deletions(-) diff --git a/api/payments/stripe.js b/api/payments/stripe.js index ea71b5f..0c8243b 100644 --- a/api/payments/stripe.js +++ b/api/payments/stripe.js @@ -2,7 +2,7 @@ import { Product, Payment } from '../../lib/models.js'; import { API, developer_admin } from "../../lib/api.js"; import logging from '../../lib/logging.js'; 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 * as queues from "../../lib/queues.js"; 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 product = await Product.first({id: product_config.id}); +const product = await Product.first({id: product_id}); const rules = { "payment_intent": "required", diff --git a/client/assert.js b/client/assert.js index 2f5ca8f..d44dcbf 100644 --- a/client/assert.js +++ b/client/assert.js @@ -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"; -/* 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. - */ - 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 super(...params); @@ -19,15 +25,22 @@ export class AssertionError extends Error { this.name = 'AssertionError'; // Custom debugging information - this.foo = foo; 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) { + log.assert(test, message); throw new AssertionError(message); } } diff --git a/client/fsm.js b/client/fsm.js index 649c944..68f7e35 100644 --- a/client/fsm.js +++ b/client/fsm.js @@ -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 { 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 { + /* + 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) { // data is really only for logging/debugging this.data = data; @@ -11,10 +148,25 @@ export class FSM { 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) { 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) { this.state = next_state; @@ -23,10 +175,22 @@ export class FSM { } } + /* + All event names available in the `this.events` class. + */ event_names() { 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) { const evhandler = this.events[event]; assert(evhandler !== undefined, `Invalid event ${event}. Available ones are '${this.event_names()}'.`);