|
| 1 | +## PureMVC Multicore Async Command Utility - Developer Guide |
| 2 | + |
| 3 | +This guide explains how the Async Command utility works and how to use it in a PureMVC Typescript Multicore app. You’ll learn the execution model and see practical, copy‑pasteable examples in Typescript. |
| 4 | + |
| 5 | +### What Problem Does This Solve? |
| 6 | +You often need to execute a series of commands where one or more steps perform asynchronous work (fetch data, wait for timers, write to storage, etc.). Orchestration using notifications alone couples commands together. `AsyncCommand` and `AsyncMacroCommand` let you compose a pipeline where each step can be synchronous or asynchronous, and the next step runs only after the current one completes. |
| 7 | + |
| 8 | +--- |
| 9 | + |
| 10 | +### Key Types |
| 11 | + |
| 12 | +- `IAsyncCommand` — interface extending PureMVC `ICommand` with `setOnComplete(cb)`. |
| 13 | +- `AsyncCommand` — base class for a command that may finish later; call `commandComplete()` when done. |
| 14 | +- `AsyncMacroCommand` — orchestrates a FIFO list of sub‑commands. Supports both sync (`SimpleCommand`) and async (`AsyncCommand`/`AsyncMacroCommand`) sub‑commands. |
| 15 | + |
| 16 | +Imports (ESM): |
| 17 | +```ts |
| 18 | +import { AsyncCommand } from "./src/command/AsyncCommand.js"; |
| 19 | +import { AsyncMacroCommand } from "./src/command/AsyncMacroCommand.js"; |
| 20 | +// Or, if consuming from the published package: |
| 21 | +// import { AsyncCommand, AsyncMacroCommand } from "@puremvc/puremvc-typescript-util-async-command"; |
| 22 | + |
| 23 | +import { |
| 24 | + SimpleCommand, |
| 25 | + INotification, |
| 26 | + Facade, |
| 27 | + ICommand, |
| 28 | +} from "@puremvc/puremvc-typescript-multicore-framework"; |
| 29 | +``` |
| 30 | + |
| 31 | +Note: This repo uses ESM; local relative imports include the `.js` suffix. |
| 32 | + |
| 33 | +--- |
| 34 | + |
| 35 | +### Execution Model and Lifecycle |
| 36 | + |
| 37 | +1. You register a macro (or simple) command with the Controller (usually via your `Facade`). |
| 38 | +2. A notification is sent. The Controller instantiates the mapped command and calls its `execute(notification)`. |
| 39 | +3. For `AsyncMacroCommand`: |
| 40 | + - It stores the `notification` and calls `nextCommand()`. |
| 41 | + - It dequeues the next sub‑command factory, creates the command, and runs it. |
| 42 | + - If the sub‑command is async (`AsyncCommand` or `AsyncMacroCommand`), the macro waits until that sub‑command calls its completion callback. |
| 43 | + - When the queue is empty, the macro calls its own completion callback (if part of a parent macro) and clears references. |
| 44 | +4. For `AsyncCommand`: |
| 45 | + - Do your work in `execute(notification)`. |
| 46 | + - When asynchronous work completes, call `this.commandComplete()`. |
| 47 | + |
| 48 | +If you forget to call `commandComplete()` in an `AsyncCommand`, the pipeline will pause indefinitely at that step. |
| 49 | + |
| 50 | +--- |
| 51 | + |
| 52 | +### Example 1 — A Minimal AsyncCommand using a Timer |
| 53 | + |
| 54 | +```ts |
| 55 | +import { AsyncCommand } from "@puremvc/puremvc-typescript-util-async-command"; |
| 56 | +import { INotification } from "@puremvc/puremvc-typescript-multicore-framework"; |
| 57 | + |
| 58 | +export class DelayCommand extends AsyncCommand { |
| 59 | + public execute(note: INotification): void { |
| 60 | + const ms = (note.body as { delayMs: number }).delayMs; |
| 61 | + |
| 62 | + setTimeout(() => { |
| 63 | + // Do something after the delay, then signal completion |
| 64 | + this.commandComplete(); |
| 65 | + }, ms); |
| 66 | + } |
| 67 | +} |
| 68 | +``` |
| 69 | + |
| 70 | +--- |
| 71 | + |
| 72 | +### Example 2 — AsyncCommand with async/await |
| 73 | + |
| 74 | +Use `try/finally` to ensure `commandComplete()` is always called. |
| 75 | + |
| 76 | +```ts |
| 77 | +import { AsyncCommand } from "@puremvc/puremvc-typescript-util-async-command"; |
| 78 | +import { INotification } from "@puremvc/puremvc-typescript-multicore-framework"; |
| 79 | + |
| 80 | +export class FetchUserCommand extends AsyncCommand { |
| 81 | + public async execute(note: INotification): Promise<void> { |
| 82 | + try { |
| 83 | + const { userId } = note.body as { userId: string }; |
| 84 | + const res = await fetch(`/api/users/${userId}`); |
| 85 | + if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| 86 | + const user = await res.json(); |
| 87 | + |
| 88 | + // Optionally send another notification with the result |
| 89 | + this.sendNotification("USER_FETCHED", { user }); |
| 90 | + } catch (err) { |
| 91 | + this.sendNotification("USER_FETCH_FAILED", { error: String(err) }); |
| 92 | + } finally { |
| 93 | + this.commandComplete(); |
| 94 | + } |
| 95 | + } |
| 96 | +} |
| 97 | +``` |
| 98 | + |
| 99 | +--- |
| 100 | + |
| 101 | +### Example 3 — Composing an AsyncMacroCommand |
| 102 | + |
| 103 | +Create a macro that runs several steps in order. Sub‑commands can be `SimpleCommand`, `AsyncCommand`, or even another `AsyncMacroCommand`. |
| 104 | + |
| 105 | +```ts |
| 106 | +import { AsyncMacroCommand } from "@puremvc/puremvc-typescript-util-async-command"; |
| 107 | +import { SimpleCommand, INotification } from "@puremvc/puremvc-typescript-multicore-framework"; |
| 108 | +import { DelayCommand } from "./DelayCommand.js"; |
| 109 | +import { FetchUserCommand } from "./FetchUserCommand.js"; |
| 110 | + |
| 111 | +class LogStartCommand extends SimpleCommand { |
| 112 | + public execute(note: INotification): void { |
| 113 | + console.log("Pipeline starting", note.body); |
| 114 | + } |
| 115 | +} |
| 116 | + |
| 117 | +class LogDoneCommand extends SimpleCommand { |
| 118 | + public execute(): void { |
| 119 | + console.log("Pipeline complete"); |
| 120 | + } |
| 121 | +} |
| 122 | + |
| 123 | +export class LoadUserPipeline extends AsyncMacroCommand { |
| 124 | + protected initializeAsyncMacroCommand(): void { |
| 125 | + this.addSubCommand(() => new LogStartCommand()); |
| 126 | + this.addSubCommand(() => new DelayCommand()); |
| 127 | + this.addSubCommand(() => new FetchUserCommand()); |
| 128 | + this.addSubCommand(() => new LogDoneCommand()); |
| 129 | + } |
| 130 | +} |
| 131 | +``` |
| 132 | + |
| 133 | +When the macro executes, it will: |
| 134 | +1) log start, 2) delay, 3) fetch the user, 4) log done — each in order, waiting where needed. |
| 135 | + |
| 136 | +--- |
| 137 | + |
| 138 | +### Example 4 — Nested AsyncMacros and Mixed Sync/Async |
| 139 | + |
| 140 | +```ts |
| 141 | +import { AsyncMacroCommand } from "@puremvc/puremvc-typescript-util-async-command"; |
| 142 | +import { SimpleCommand } from "@puremvc/puremvc-typescript-multicore-framework"; |
| 143 | + |
| 144 | +class InitSyncCommand extends SimpleCommand { /* ... */ } |
| 145 | +class LoadAssetsMacro extends AsyncMacroCommand { /* addSubCommand(() => new AsyncStep()) ... */ } |
| 146 | +class WarmupServicesMacro extends AsyncMacroCommand { /* ... */ } |
| 147 | + |
| 148 | +export class AppStartupMacro extends AsyncMacroCommand { |
| 149 | + protected initializeAsyncMacroCommand(): void { |
| 150 | + this.addSubCommand(() => new InitSyncCommand()); |
| 151 | + this.addSubCommand(() => new LoadAssetsMacro()); |
| 152 | + this.addSubCommand(() => new WarmupServicesMacro()); |
| 153 | + } |
| 154 | +} |
| 155 | +``` |
| 156 | + |
| 157 | +`AppStartupMacro` will wait for each nested macro to complete before moving on. |
| 158 | + |
| 159 | +--- |
| 160 | + |
| 161 | +### Integrating with PureMVC’s Controller/Facade |
| 162 | + |
| 163 | +Map a notification to your macro (or command), then send the notification to trigger it. |
| 164 | + |
| 165 | +```ts |
| 166 | +import { Facade } from "@puremvc/puremvc-typescript-multicore-framework"; |
| 167 | +import { LoadUserPipeline } from "./LoadUserPipeline.js"; |
| 168 | + |
| 169 | +export const NOTE_LOAD_USER = "NOTE_LOAD_USER" as const; |
| 170 | + |
| 171 | +export class AppFacade extends Facade { |
| 172 | + public static getInstance(key: string): AppFacade { |
| 173 | + if (!this.instanceMap[key]) this.instanceMap[key] = new AppFacade(key); |
| 174 | + return this.instanceMap[key] as AppFacade; |
| 175 | + } |
| 176 | + |
| 177 | + protected initializeController(): void { |
| 178 | + super.initializeController(); |
| 179 | + this.controller.registerCommand(NOTE_LOAD_USER, LoadUserPipeline); |
| 180 | + } |
| 181 | +} |
| 182 | + |
| 183 | +// Somewhere in your view/mediator/proxy: |
| 184 | +const facade = AppFacade.getInstance("CoreA"); |
| 185 | +facade.sendNotification(NOTE_LOAD_USER, { userId: "123", delayMs: 250 }); |
| 186 | +``` |
| 187 | + |
| 188 | +Notes: |
| 189 | +- The same `INotification` (name, body, type) is passed to each sub‑command in the macro. |
| 190 | +- A sub‑command may send additional notifications as needed, but the pipeline sequencing is independent of those notifications. |
| 191 | + |
| 192 | +--- |
| 193 | + |
| 194 | +### Passing Data Between Steps |
| 195 | + |
| 196 | +All sub‑commands receive the original notification. Include whatever state they need in the notification body: |
| 197 | + |
| 198 | +```ts |
| 199 | +facade.sendNotification("START_PIPELINE", { |
| 200 | + userId: "123", |
| 201 | + options: { warm: true }, |
| 202 | +}); |
| 203 | +``` |
| 204 | + |
| 205 | +If you must build state progressively, you can |
| 206 | +1. Have a sub‑command send a new notification with aggregated data |
| 207 | +2. Write to a Proxy and read from it in later steps |
| 208 | +3. Add properties to the object passed in the `body` of the original note. |
| 209 | + |
| 210 | +--- |
| 211 | + |
| 212 | +### Error Handling Patterns |
| 213 | + |
| 214 | +- Handle errors inside the sub‑command; send an error notification if appropriate. |
| 215 | +- Always call `commandComplete()` in `AsyncCommand` even on error (use `finally`). |
| 216 | +- For macros, consider terminal error policies: either continue to next step, or have a step send a specific notification that leads to aborting the flow (e.g., by not scheduling additional work). |
| 217 | + |
| 218 | +--- |
| 219 | + |
| 220 | +### Example 5 — Multiple async operations in a single command |
| 221 | + |
| 222 | +```ts |
| 223 | +import { AsyncCommand } from "@puremvc/puremvc-typescript-util-async-command"; |
| 224 | +import { INotification } from "@puremvc/puremvc-typescript-multicore-framework"; |
| 225 | + |
| 226 | +export class LoadUserAndPostsCommand extends AsyncCommand { |
| 227 | + public async execute(note: INotification): Promise<void> { |
| 228 | + try { |
| 229 | + const { userId } = note.body as { userId: string }; |
| 230 | + const [userRes, postsRes] = await Promise.all([ |
| 231 | + fetch(`/api/users/${userId}`), |
| 232 | + fetch(`/api/users/${userId}/posts`), |
| 233 | + ]); |
| 234 | + const [user, posts] = await Promise.all([userRes.json(), postsRes.json()]); |
| 235 | + this.sendNotification("USER_AND_POSTS_LOADED", { user, posts }); |
| 236 | + } finally { |
| 237 | + this.commandComplete(); |
| 238 | + } |
| 239 | + } |
| 240 | +} |
| 241 | +``` |
| 242 | + |
| 243 | +--- |
| 244 | + |
| 245 | +### Example 6 — Testing an AsyncMacroCommand with Jest |
| 246 | + |
| 247 | +```ts |
| 248 | +import { Facade } from "@puremvc/puremvc-typescript-multicore-framework"; |
| 249 | +import { LoadUserPipeline } from "../src/LoadUserPipeline.js"; |
| 250 | + |
| 251 | +const NOTE = "TEST_LOAD_USER"; |
| 252 | + |
| 253 | +class TestFacade extends Facade { |
| 254 | + protected initializeController(): void { |
| 255 | + super.initializeController(); |
| 256 | + this.controller.registerCommand(NOTE, LoadUserPipeline); |
| 257 | + } |
| 258 | +} |
| 259 | + |
| 260 | +test("pipeline completes and emits USER_FETCHED", async () => { |
| 261 | + const facade = TestFacade.getInstance("TestCore"); |
| 262 | + |
| 263 | + const events: string[] = []; |
| 264 | + facade.registerMediator({ |
| 265 | + getMediatorName: () => "SpyMediator", |
| 266 | + listNotificationInterests: () => ["USER_FETCHED"], |
| 267 | + handleNotification: n => events.push(n.getName()), |
| 268 | + onRegister: () => {}, |
| 269 | + onRemove: () => {}, |
| 270 | + } as any); |
| 271 | + |
| 272 | + facade.sendNotification(NOTE, { userId: "1", delayMs: 0 }); |
| 273 | + |
| 274 | + // Wait for async queue to flush — in real tests, prefer explicit promises |
| 275 | + await new Promise(r => setTimeout(r, 50)); |
| 276 | + |
| 277 | + expect(events).toContain("USER_FETCHED"); |
| 278 | +}); |
| 279 | +``` |
| 280 | + |
| 281 | +Tips: |
| 282 | +- Prefer exposing deterministic hooks (e.g., a Proxy state) and awaiting on explicit signals in tests. |
| 283 | +- If you test a single `AsyncCommand`, you can instantiate it directly and call `setOnComplete` with a test callback before calling `execute`. |
| 284 | + |
| 285 | +--- |
| 286 | + |
| 287 | +### Common Pitfalls |
| 288 | + |
| 289 | +- Forgetting to call `commandComplete()` in an `AsyncCommand` → pipeline stalls. |
| 290 | +- Throwing from `execute` without catching → still call `commandComplete()` in `finally`. |
| 291 | +- Accidentally overriding `AsyncMacroCommand.execute` in your subclass → don’t; override `initializeAsyncMacroCommand()` and add sub‑commands there. |
| 292 | +- Using relative imports without `.js` suffix in ESM builds → add the `.js` suffix for local files. |
| 293 | + |
| 294 | +--- |
| 295 | + |
| 296 | +### API Summary (from this utility) |
| 297 | + |
| 298 | +```ts |
| 299 | +// Signatures (ambient declarations for reference) |
| 300 | + |
| 301 | +// IAsyncCommand |
| 302 | +declare interface IAsyncCommand extends ICommand { |
| 303 | + setOnComplete(value: () => void): void; |
| 304 | +} |
| 305 | + |
| 306 | +// AsyncCommand |
| 307 | +declare class AsyncCommand extends SimpleCommand implements IAsyncCommand { |
| 308 | + public setOnComplete(value: () => void): void; |
| 309 | + protected commandComplete(): void; // call when your async work is done |
| 310 | +} |
| 311 | + |
| 312 | +// AsyncMacroCommand |
| 313 | +declare class AsyncMacroCommand implements IAsyncCommand { |
| 314 | + protected initializeAsyncMacroCommand(): void; // override to add sub-commands |
| 315 | + protected addSubCommand(factory: () => ICommand): void; // FIFO |
| 316 | + public setOnComplete(value: () => void): void; |
| 317 | + public execute(note: INotification): void; // starts the pipeline |
| 318 | +} |
| 319 | +``` |
| 320 | + |
| 321 | +--- |
| 322 | + |
| 323 | +### FAQ |
| 324 | + |
| 325 | +Q: Can a sub‑command send notifications while the macro is running? |
| 326 | +A: Yes. Notifications are independent of sequencing. The macro only advances when an async sub‑command signals completion or when a sync sub‑command returns from `execute`. |
| 327 | + |
| 328 | +Q: Can I pass different data to each sub‑command? |
| 329 | +A: All sub‑commands receive the same `INotification`. If you need evolving state, use a Proxy or send additional notifications. |
| 330 | + |
| 331 | +Q: Can I short‑circuit the pipeline? |
| 332 | +A: The macro runs through its queue. A sub‑command may choose to send a notification that results in different application flow (e.g., not scheduling the next macro), but there’s no built‑in “cancel remaining steps” API. You can model cancellation by designing a sub‑command that clears or ignores follow‑up work. |
| 333 | + |
| 334 | +--- |
| 335 | + |
| 336 | +### Conclusion |
| 337 | + |
| 338 | +Use `AsyncCommand` for steps that complete later, and `AsyncMacroCommand` to compose them into deterministic pipelines. This utility keeps command‑to‑command coupling low, while preserving the PureMVC notification model and Controller mappings. |
0 commit comments