@figliolia/galena 1.0.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/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@figliolia/galena",
3
+ "version": "1.0.0",
4
+ "description": "A performant state management library supporting mutable state, batched updates, middleware and a rich development API",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist",
9
+ "src/*"
10
+ ],
11
+ "author": "Alex Figliolia",
12
+ "license": "MIT",
13
+ "homepage": "https://github.com/alexfigliolia/galena#readme",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/alexfigliolia/galena.git"
17
+ },
18
+ "keywords": [
19
+ "state",
20
+ "state management",
21
+ "mutable",
22
+ "extendable",
23
+ "event",
24
+ "emitter",
25
+ "flux",
26
+ "island",
27
+ "batch",
28
+ "performance"
29
+ ],
30
+ "scripts": {
31
+ "test": "jest",
32
+ "coverage": "jest --env=jsdom --coverage --testResultsProcessor ./node_modules/jest-junit",
33
+ "build": "rm -rf dist && tsc --project tsconfig.build.json && tsc-alias -p tsconfig.build.json",
34
+ "lint": "tsc --noemit && eslint ./ --fix"
35
+ },
36
+ "dependencies": {
37
+ "@figliolia/event-emitter": "^1.0.6"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^16.7.13",
41
+ "@typescript-eslint/eslint-plugin": "^5.59.1",
42
+ "@typescript-eslint/parser": "^5.59.1",
43
+ "eslint": "^8.39.0",
44
+ "eslint-config-airbnb": "^19.0.4",
45
+ "eslint-config-airbnb-typescript": "^17.0.0",
46
+ "eslint-config-prettier": "^8.8.0",
47
+ "eslint-import-resolver-typescript": "^3.5.5",
48
+ "eslint-plugin-import": "^2.27.5",
49
+ "eslint-plugin-prettier": "^4.2.1",
50
+ "eslint-plugin-react": "^7.32.2",
51
+ "eslint-plugin-simple-import-sort": "^10.0.0",
52
+ "prettier": "^2.8.8",
53
+ "ts-node": "^10.9.1",
54
+ "tsc-alias": "^1.8.6",
55
+ "typescript": "^4.4.2"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public"
59
+ }
60
+ }
@@ -0,0 +1,253 @@
1
+ import { AutoIncrementingID } from "@figliolia/event-emitter";
2
+
3
+ import type { Middleware } from "Middleware/Middleware";
4
+
5
+ import { State } from "Galena/State";
6
+
7
+ /**
8
+ * ## Galena
9
+ *
10
+ * A performant global state solution that scales
11
+ *
12
+ * ### Creating State
13
+ *
14
+ * ```typescript
15
+ * // AppState.ts
16
+ * import { Galena } from "galena";
17
+ *
18
+ * const AppState = new Galena([...middleware]);
19
+ *
20
+ * const NavigationState = AppState.composeState("navigation", {
21
+ * currentRoute: "/",
22
+ * userID: "12345",
23
+ * permittedRoutes: ["/*"]
24
+ * });
25
+ * ```
26
+ *
27
+ * ### Subscribing to State Changes
28
+ * #### Using the Galena Instance
29
+ * ```typescript
30
+ * import { AppState } from "./AppState";
31
+ *
32
+ * AppState.subscribe(appState => {
33
+ * const navState = appState.get("navigation");
34
+ * const { currentRoute } = navState.state;
35
+ * // do something with state changes!
36
+ * });
37
+ * ```
38
+ * #### Using the State Instance
39
+ * ```typescript
40
+ * NavigationState.subscribe(navigation => {
41
+ * const { currentRoute } = navigation.state
42
+ * // do something with state changes!
43
+ * });
44
+ * ```
45
+ *
46
+ * #### Using Global Subscriptions
47
+ * ```typescript
48
+ * NavigationState.subscribeAll(galenaInstance => {
49
+ * const { currentRoute } = galenaInstance.get("navigation").state
50
+ * // do something with state changes!
51
+ * });
52
+ * ```
53
+ *
54
+ * ### Mutating State
55
+ * ```typescript
56
+ * NavigationState.update(state => {
57
+ * state.currentRoute = "/profile";
58
+ * // You can mutate state without creating new objects!
59
+ * });
60
+ * ```
61
+ */
62
+ export class Galena<
63
+ T extends Record<string, State<any>> = Record<string, State<any>>
64
+ > {
65
+ public readonly state = {} as T;
66
+ private readonly middleware: Middleware[] = [];
67
+ private readonly IDs = new AutoIncrementingID();
68
+ private readonly subscriptions = new Map<
69
+ string,
70
+ [state: string, ID: string][]
71
+ >();
72
+ constructor(middleware: Middleware[] = []) {
73
+ this.middleware = middleware;
74
+ }
75
+
76
+ /**
77
+ * Compose State
78
+ *
79
+ * Creates a new `State` instance and returns it. Your new state
80
+ * becomes immediately available on your `Galena` instance and
81
+ * is wired into your middleware. All existing subscriptions to
82
+ * state will automatically receive updates when your new unit of
83
+ * state updates
84
+ */
85
+ public composeState<
86
+ S extends any,
87
+ M extends typeof State<S> = typeof State<S>
88
+ >(
89
+ name: string,
90
+ initialState: S,
91
+ // @ts-ignore
92
+ Model: M = State<S>
93
+ ) {
94
+ const state = new Model(name, initialState);
95
+ state.registerMiddleware(...this.middleware);
96
+ this.mutable[name] = state;
97
+ this.reIndexSubscriptions(name);
98
+ return state as InstanceType<M>;
99
+ }
100
+
101
+ /**
102
+ * Get
103
+ *
104
+ * Returns a unit of `State` by name
105
+ */
106
+ public get<K extends keyof T>(name: K): T[K] {
107
+ return this.state[name];
108
+ }
109
+
110
+ /**
111
+ * Mutable
112
+ *
113
+ * Returns a mutable state instance
114
+ */
115
+ private get mutable() {
116
+ return this.state as Record<string, State>;
117
+ }
118
+
119
+ /**
120
+ * Update
121
+ *
122
+ * Runs a mutation on the specified unit of state
123
+ */
124
+ public update<K extends keyof T>(
125
+ name: K,
126
+ mutation: Parameters<T[K]["update"]>["0"]
127
+ ) {
128
+ return this.get(name).update(mutation);
129
+ }
130
+
131
+ /**
132
+ * Background Update
133
+ *
134
+ * Runs a higher priority mutation on the specified unit of
135
+ * state
136
+ */
137
+ public backgroundUpdate<K extends keyof T>(
138
+ name: K,
139
+ mutation: Parameters<T[K]["backgroundUpdate"]>["0"]
140
+ ) {
141
+ return this.get(name).backgroundUpdate(mutation);
142
+ }
143
+
144
+ /**
145
+ * Priority Update
146
+ *
147
+ * Runs an immediate priority mutation on the specified unit
148
+ * of state
149
+ */
150
+ public priorityUpdate<K extends keyof T>(
151
+ name: K,
152
+ mutation: Parameters<T[K]["priorityUpdate"]>["0"]
153
+ ) {
154
+ return this.get(name).priorityUpdate(mutation);
155
+ }
156
+
157
+ /**
158
+ * Subscribe
159
+ *
160
+ * Given the name of a unit of state, this method registers
161
+ * a subscription on the target state instance. The callback
162
+ * you provide will execute each time state changes. Returns
163
+ * a unique identifier for your subscription. To clean up your
164
+ * subscription, call `Galena.unsubscribe()` with the ID returned
165
+ * by this method
166
+ */
167
+ public subscribe<K extends keyof T>(
168
+ name: K,
169
+ mutation: Parameters<T[K]["subscribe"]>["0"]
170
+ ) {
171
+ return this.get(name).subscribe(mutation);
172
+ }
173
+
174
+ /**
175
+ * Unsubscribe
176
+ *
177
+ * Given a subscription ID returned from the `subscribe` method,
178
+ * this method removes and cleans up the corresponding subscription
179
+ */
180
+ public unsubscribe<K extends keyof T>(name: K, ID: string) {
181
+ return this.get(name).unsubscribe(ID);
182
+ }
183
+
184
+ /**
185
+ * Subscribe All
186
+ *
187
+ * Registers a callback on each registered `State` instance and
188
+ * is invoked each time your state changes. Using `Galena`'s
189
+ * `subscribeAll` method, although performant, can be less
190
+ * performant than subscribing directly to a target `State`
191
+ * instance using `Galena.subscribe()`. To clean up your
192
+ * subscription, call `Galena.unsubscribeAll()` with the ID
193
+ * returned
194
+ */
195
+ public subscribeAll(callback: (state: Galena<T>) => void) {
196
+ const subscriptionID = this.IDs.get();
197
+ const stateSubscriptions: [state: string, ID: string][] = [];
198
+ for (const key in this.state) {
199
+ stateSubscriptions.push([
200
+ key,
201
+ this.state[key].subscribe(() => {
202
+ callback(this);
203
+ }),
204
+ ]);
205
+ }
206
+ this.subscriptions.set(subscriptionID, stateSubscriptions);
207
+ return subscriptionID;
208
+ }
209
+
210
+ /**
211
+ * Unsubscribe
212
+ *
213
+ * Given a subscription ID returned from the `subscribeAll()` method,
214
+ * this method removes and cleans up the corresponding subscription
215
+ */
216
+ public unsubscribeAll(ID: string) {
217
+ const IDs = this.subscriptions.get(ID);
218
+ if (IDs) {
219
+ for (const [state, ID] of IDs) {
220
+ this.state[state].unsubscribe(ID);
221
+ this.subscriptions.delete(ID);
222
+ }
223
+ }
224
+ }
225
+
226
+ /**
227
+ * ReIndex Subscriptions
228
+ *
229
+ * When units of state are created lazily, this method updates
230
+ * each existing subscription to receive mutations occurring on
231
+ * recently created `State` instances that post-date prior
232
+ * subscriptions
233
+ */
234
+ private reIndexSubscriptions(name: string) {
235
+ for (const [ID, unitSubscriptions] of this.subscriptions) {
236
+ for (const [state, subscriptionID] of unitSubscriptions) {
237
+ const callback = this.state[state]["emitter"]
238
+ .get(state)
239
+ ?.get(subscriptionID);
240
+ if (callback) {
241
+ unitSubscriptions.push([
242
+ name,
243
+ this.state[name].subscribe(() => {
244
+ void callback(this.state);
245
+ }),
246
+ ]);
247
+ this.subscriptions.set(ID, unitSubscriptions);
248
+ break;
249
+ }
250
+ }
251
+ }
252
+ }
253
+ }
@@ -0,0 +1,85 @@
1
+ import type { Task } from "./types";
2
+ import { Priority } from "./types";
3
+
4
+ /**
5
+ * Scheduler
6
+ *
7
+ * Scheduling dispatched events to state consumers is how Galena
8
+ * out-performs just about every state management library out there.
9
+ * The scheduler offers the ability to dispatch state updates on 3
10
+ * priorities:
11
+ *
12
+ * 1. Immediate - Immediate synchronous task execution and propagation of
13
+ * changes to consumers
14
+ * 2. Microtask - Immediate task execution and scheduled propagation of
15
+ * changes to consumers
16
+ * 3. Batched - Immediate task execution and batched propagation of
17
+ * changes to consumers
18
+ *
19
+ * This module manages the propagation of changes to State consumers
20
+ * by implementing the three priorities outlined above
21
+ */
22
+ export class Scheduler<T extends Task = Task> {
23
+ private task: null | T = null;
24
+ private schedule: ReturnType<typeof setTimeout> | null = null;
25
+ constructor() {
26
+ this.executeTasks = this.executeTasks.bind(this);
27
+ }
28
+
29
+ /**
30
+ * Schedule Task
31
+ *
32
+ * Given a task (the emission of state changes to consumers)
33
+ * and a priority, this method executes the task on the priority
34
+ * level specified
35
+ */
36
+ protected scheduleTask(task: T, priority: Priority) {
37
+ this.task = task;
38
+ switch (priority) {
39
+ case Priority.IMMEDIATE:
40
+ return this.executeTasks();
41
+ case Priority.MICROTASK:
42
+ return Promise.resolve().then(() => {
43
+ return this.executeTasks();
44
+ });
45
+ case Priority.BATCHED:
46
+ default:
47
+ if (!this.schedule) {
48
+ this.createSchedule();
49
+ }
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Create Schedule
55
+ *
56
+ * Schedules the execution of the current task after 5 milliseconds
57
+ */
58
+ private createSchedule() {
59
+ this.clearSchedule();
60
+ this.schedule = setTimeout(this.executeTasks, 5);
61
+ }
62
+
63
+ /**
64
+ * Clear Schedule
65
+ *
66
+ * Clears the schedule if it exists
67
+ */
68
+ private clearSchedule() {
69
+ if (this.schedule !== null) {
70
+ clearTimeout(this.schedule);
71
+ this.schedule = null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Execute Tasks
77
+ *
78
+ * Clears the schedule if it exists and executes the current task
79
+ */
80
+ private executeTasks() {
81
+ this.clearSchedule();
82
+ this.task?.();
83
+ this.task = null;
84
+ }
85
+ }
@@ -0,0 +1,311 @@
1
+ import { MiddlewareEvents } from "Middleware/types";
2
+ import type { Middleware } from "Middleware/Middleware";
3
+ import { EventEmitter } from "@figliolia/event-emitter";
4
+ import { Priority, type MutationEvent } from "./types";
5
+ import { Scheduler } from "./Scheduler";
6
+
7
+ /**
8
+ * ### State
9
+ *
10
+ * The root of all reactivity in Galena. State instances can
11
+ * operate in isolation by calling `new State(...args)` or as
12
+ * part of your application's larger global state by using
13
+ * `new Galena().composeState()`.
14
+ *
15
+ * `State` instances operate on the premise of pub-sub and mutability.
16
+ * This provides significant performance improvement over more traditional
17
+ * state management tools because
18
+ *
19
+ * 1. Mutations can occur in O(1) space
20
+ * 2. Mutations can be batched when dispatching updates to subscribers
21
+ *
22
+ * When deciding how many `State` instances are required for your
23
+ * applications needs, we suggest creating and organizing state in
24
+ * accordance with your application logic. Meaning, you might have a
25
+ * `State` instance for navigation/routing, another `State` instance
26
+ * for storing user information, and so on. Performance can improve
27
+ * significantly when state is dispersed amongst multiple instances
28
+ *
29
+ * #### Creating State Instances
30
+ *
31
+ * ```typescript
32
+ * const MyState = new State("MyState", {
33
+ * someData: true,
34
+ * listItems: [1, 2, 3, 4];
35
+ * // ...etc
36
+ * });
37
+ * ```
38
+ *
39
+ * #### Updating State
40
+ * ##### Synchronous updates
41
+ * ```typescript
42
+ * MyState.update((state) => {
43
+ * state.listItems.push(5);
44
+ * });
45
+ * ```
46
+ * ##### Asynchronous updates
47
+ * ```typescript
48
+ * MyState.update(async (state) => {
49
+ * const listItems = await fetch("/list-items");
50
+ * state.listItems = listItems;
51
+ * });
52
+ * ```
53
+ *
54
+ * #### Subscribing to State Changes
55
+ * ```typescript
56
+ * MyState.subscribe(({ state }) => {
57
+ * const { listItems } = state
58
+ * // Do something with your list items!
59
+ * });
60
+ * ```
61
+ */
62
+ export class State<T extends any = any> extends Scheduler {
63
+ public state: T;
64
+ public readonly name: string;
65
+ public readonly initialState: T;
66
+ private readonly middleware: Middleware[] = [];
67
+ private readonly emitter = new EventEmitter<MutationEvent<T>>();
68
+ constructor(name: string, initialState: T) {
69
+ super();
70
+ this.name = name;
71
+ this.state = initialState;
72
+ this.initialState = State.clone(initialState);
73
+ }
74
+
75
+ /**
76
+ * Get State
77
+ *
78
+ * Returns a readonly snapshot of the current state
79
+ */
80
+ public getState() {
81
+ return this.state as Readonly<T>;
82
+ }
83
+
84
+ /**
85
+ * Update
86
+ *
87
+ * Mutates state and notifies any open subscriptions. This method
88
+ * by default uses task batching for optimized performance. In almost
89
+ * every use-case, this method is the correct way to mutate state. If
90
+ * you need to bypass batching for higher-priority state updates, you
91
+ * can use `State.priorityUpdate()` or `State.backgroundUpdate()`
92
+ *
93
+ * ##### Synchronous updates
94
+ * ```typescript
95
+ * MyState.update((state, initialState) => {
96
+ * state.listItems.push(5);
97
+ * });
98
+ * ```
99
+ * ##### Asynchronous updates
100
+ * ```typescript
101
+ * MyState.update(async (state, initialState) => {
102
+ * const listItems = await fetch("/list-items");
103
+ * state.listItems = listItems;
104
+ * });
105
+ * ```
106
+ */
107
+ public update = this.mutation(
108
+ (func: (state: T, initialState: T) => void | Promise<void>) => {
109
+ return func(this.state, this.initialState);
110
+ },
111
+ Priority.BATCHED
112
+ );
113
+
114
+ /**
115
+ * Background Update
116
+ *
117
+ * Mutates state and notifies any open subscriptions. This method
118
+ * bypasses Galena's internal task batching for a more immediate
119
+ * state update and propagation of state to consumers. It utilizes
120
+ * a micro-task that allows for the current call stack to clear
121
+ * ahead of propagating state updates to consumers
122
+ *
123
+ * ##### Synchronous updates
124
+ * ```typescript
125
+ * MyState.backgroundUpdate((state, initialState) => {
126
+ * state.listItems.push(5);
127
+ * });
128
+ * ```
129
+ * ##### Asynchronous updates
130
+ * ```typescript
131
+ * MyState.backgroundUpdate(async (state, initialState) => {
132
+ * const listItems = await fetch("/list-items");
133
+ * state.listItems = listItems;
134
+ * });
135
+ * ```
136
+ */
137
+ public backgroundUpdate = this.mutation(
138
+ (func: (state: T, initialState: T) => void | Promise<void>) => {
139
+ return func(this.state, this.initialState);
140
+ },
141
+ Priority.MICROTASK
142
+ );
143
+
144
+ /**
145
+ * Priority Update
146
+ *
147
+ * Mutates state and notifies any open subscriptions. This method
148
+ * bypasses optimizations for task batching and scheduling. This means
149
+ * that state updates made with this method propagate to subscriptions
150
+ * as immediately as possible. Overusing this method can cause your
151
+ * state updates to perform slower in certain cases. The usage of this
152
+ * method should be conserved for state mutations that need to occur
153
+ * at a certain frame rate
154
+ *
155
+ * ##### Synchronous updates
156
+ * ```typescript
157
+ * MyState.priorityUpdate((state, initialState) => {
158
+ * state.listItems.push(5);
159
+ * });
160
+ * ```
161
+ * ##### Asynchronous updates
162
+ * ```typescript
163
+ * MyState.priorityUpdate(async (state, initialState) => {
164
+ * const listItems = await fetch("/list-items");
165
+ * state.listItems = listItems;
166
+ * });
167
+ * ```
168
+ */
169
+ public priorityUpdate = this.mutation(
170
+ (func: (state: T, initialState: T) => void | Promise<void>) => {
171
+ return func(this.state, this.initialState);
172
+ },
173
+ Priority.IMMEDIATE
174
+ );
175
+
176
+ /**
177
+ * Reset
178
+ *
179
+ * Resets the current state to its initial state
180
+ */
181
+ public reset = this.mutation(() => {
182
+ this.state = State.clone(this.initialState);
183
+ });
184
+
185
+ /**
186
+ * Mutation
187
+ *
188
+ * This method can be used to wrap arbitrary functions that when invoked
189
+ * will:
190
+ * 1. Notify your subscriptions with the latest state
191
+ * 2. Execute any registered middleware (such as loggers or profiling tools)
192
+ *
193
+ * Using this method, developers can compose and extend `Galena`'s internal
194
+ * infrastructure for state mutations to create proprietary models for your
195
+ * state
196
+ *
197
+ * ```typescript
198
+ * import { State } from "galena";
199
+ *
200
+ * // Extend of Galena State
201
+ * class MyState extends State {
202
+ * addListItem = mutation((newListItem) => {
203
+ * this.state.list.push(newListItem);
204
+ * });
205
+ * }
206
+ *
207
+ * // Create an instance
208
+ * const myState = new MyState("myState", { list: [] });
209
+ *
210
+ * // Invoke your custom mutation method
211
+ * myState.addListItem("new-item");
212
+ * ```
213
+ */
214
+ protected mutation<F extends (...args: any[]) => any>(
215
+ func: F,
216
+ priority: Priority = Priority.BATCHED
217
+ ) {
218
+ return (...args: Parameters<F>) => {
219
+ this.lifeCycleEvent(MiddlewareEvents.onBeforeUpdate);
220
+ const returnValue = func(...args);
221
+ if (returnValue instanceof Promise) {
222
+ return returnValue.then((v) => {
223
+ this.scheduleUpdate(priority);
224
+ return v;
225
+ });
226
+ }
227
+ this.scheduleUpdate(priority);
228
+ return returnValue;
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Schedule Update
234
+ *
235
+ * Schedules an update to State subscribers and emits the
236
+ * `onUpdate` lifecycle hook
237
+ */
238
+ private scheduleUpdate(priority: Priority) {
239
+ this.lifeCycleEvent(MiddlewareEvents.onUpdate);
240
+ void this.scheduleTask(() => this.emitter.emit(this.name, this), priority);
241
+ }
242
+
243
+ /**
244
+ * Register Middleware
245
+ *
246
+ * Caches a `Middleware` instance and invokes its
247
+ * lifecycle subscriptions on all state transitions
248
+ */
249
+ public registerMiddleware(...middleware: Middleware[]) {
250
+ this.middleware.push(...middleware);
251
+ }
252
+
253
+ /**
254
+ * Subscribe
255
+ *
256
+ * Registers a subscription on the state instance. The
257
+ * callback you provide will execute each time state changes.
258
+ * Returns a unique identifier for your subscription
259
+ */
260
+ public subscribe(callback: (nextState: State<T>) => void) {
261
+ return this.emitter.on(this.name, callback);
262
+ }
263
+
264
+ /**
265
+ * Unsubscribe
266
+ *
267
+ * Given a subscription ID, removes a registered subscription
268
+ * from the `State` instance
269
+ */
270
+ public unsubscribe(ID: string) {
271
+ return this.emitter.off(this.name, ID);
272
+ }
273
+
274
+ /**
275
+ * Life Cycle Event
276
+ *
277
+ * Triggers a life cycle event for each registered middleware
278
+ */
279
+ private lifeCycleEvent<E extends MiddlewareEvents>(event: E) {
280
+ const maxIndex = this.middleware.length - 1;
281
+ for (let i = maxIndex; i > -1; i--) {
282
+ this.middleware[i][event](this);
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Clone
288
+ *
289
+ * `State` instances accept any value as a form of reactive
290
+ * state. In order to maintain the initial state past any state
291
+ * transitions, this method clones the initial values provided
292
+ * to the `State` constructor and caches them to allow for
293
+ * developers to easily reset their current state back to its
294
+ * initial value
295
+ */
296
+ public static clone<T>(state: T): T {
297
+ if (Array.isArray(state)) {
298
+ return [...state] as T;
299
+ }
300
+ if (state instanceof Set) {
301
+ return new Set(state) as T;
302
+ }
303
+ if (state instanceof Map) {
304
+ return new Map(state) as T;
305
+ }
306
+ if (state && typeof state === "object") {
307
+ return { ...state } as T;
308
+ }
309
+ return state;
310
+ }
311
+ }
@@ -0,0 +1,3 @@
1
+ export { Galena } from "./Galena";
2
+ export { State } from "./State";
3
+ export * from "./types";