diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bde4f92..1092bb6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -40,8 +40,8 @@ jobs: - uses: actions/setup-node@v6 with: - node-version: '24' - registry-url: 'https://registry.npmjs.org' + node-version: "24" + registry-url: "https://registry.npmjs.org" - name: Publish to npm # npm trusted publishing uses the GitHub OIDC token minted from diff --git a/README.md b/README.md index 3c3e583..43a477a 100644 --- a/README.md +++ b/README.md @@ -2,42 +2,56 @@ [![Open Bundle](https://bundlejs.com/badge-light.svg)](https://bundlejs.com/?q=@okikio/observables&bundle "Check the total bundle size of @okikio/observables") -[NPM](https://www.npmjs.com/package/@okikio/observables) | [GitHub](https://github.com/okikio/observables#readme) | [JSR](https://jsr.io/@okikio/observables) | [Licence](./LICENSE) - -A **spec-faithful** yet ergonomic TC39-inspired Observable implementation that gives you one consistent way to handle all async data in JavaScript. - -**Observables** are a **push‑based stream abstraction** for events, data, and long‑running operations. Think of them as a **multi‑value Promise** that keeps sending values until you tell it to stop, where a Promise gives you one value eventually, an Observable can give you many values over time: mouse clicks, search results, chat messages, sensor readings. +[NPM](https://www.npmjs.com/package/@okikio/observables) +| +[GitHub](https://github.com/okikio/observables#readme) +| +[JSR](https://jsr.io/@okikio/observables) +| [Licence](./LICENSE) + +A **spec-faithful** yet ergonomic TC39-inspired Observable implementation that +gives you one consistent way to handle all async data in JavaScript. + +**Observables** are a **push‑based stream abstraction** for events, data, and +long‑running operations. Think of them as a **multi‑value Promise** that keeps +sending values until you tell it to stop, where a Promise gives you one value +eventually, an Observable can give you many values over time: mouse clicks, +search results, chat messages, sensor readings. [![Bundle Size](https://deno.bundlejs.com/badge?q=@okikio/observables&treeshake=[{+Observable,+pipe,+map,+filter+}]&style=flat)](https://bundlejs.com/?q=@okikio/observables&treeshake=[{+Observable,+pipe,+map,+filter+}]) -If you've ever built a web app, you know this all too well: user clicks, API responses, WebSocket messages, timers, file uploads, they all arrive at different times and need different handling. Before Observables, we'd all end up with a mess of callbacks, Promise chains, event listeners, and async/await scattered throughout our code. +If you've ever built a web app, you know this all too well: user clicks, API +responses, WebSocket messages, timers, file uploads, they all arrive at +different times and need different handling. Before Observables, we'd all end up +with a mess of callbacks, Promise chains, event listeners, and async/await +scattered throughout our code. -Let's say you're building a search box. You've probably written something like this: +Let's say you're building a search box. You've probably written something like +this: ```ts // We've all been here: callbacks, timers, and manual cleanup 😫 let searchTimeout: number; let lastRequest: Promise | null = null; -searchInput.addEventListener('input', async (event) => { +searchInput.addEventListener("input", async (event) => { const query = event.target.value; - + // Debounce: wait 300ms after user stops typing clearTimeout(searchTimeout); searchTimeout = setTimeout(async () => { - // Cancel previous request somehow? if (lastRequest) { // How do you cancel a fetch? 🤔 } - + if (query.length < 3) return; // Skip short queries - + try { lastRequest = fetch(`/search?q=${query}`); const response = await lastRequest; const results = await response.json(); - + // Update UI, but what if user already typed something new? updateSearchResults(results); } catch (error) { @@ -51,13 +65,18 @@ searchInput.addEventListener('input', async (event) => { // (Spoiler: we all forget this and create memory leaks) ``` -This works but it's fragile, hard to test, and easy to mess up. Plus, you have to remember to clean up event listeners, cancel timers, and handle edge cases manually. +This works but it's fragile, hard to test, and easy to mess up. Plus, you have +to remember to clean up event listeners, cancel timers, and handle edge cases +manually. We've all felt this pain before: -- **Memory Leaks**: Forgot to remove an event listener? Your app slowly eats memory -- **Race Conditions**: User clicks fast, requests arrive out of order, wrong results appear -- **Error Handling**: Network failed? Now you need custom backoff and error recovery +- **Memory Leaks**: Forgot to remove an event listener? Your app slowly eats + memory +- **Race Conditions**: User clicks fast, requests arrive out of order, wrong + results appear +- **Error Handling**: Network failed? Now you need custom backoff and error + recovery - **Backpressure**: Producer too fast for consumer? Memory bloats until crash - **Testing**: Complex async flows become nearly impossible to test reliably - **Maintenance**: Each async pattern needs its own cleanup and error handling @@ -66,32 +85,35 @@ Here's the same search box with Observables: ```ts // Much cleaner: composable and robust ✨ -import { pipe, debounce, filter, switchMap, map } from "@okikio/observables"; +import { debounce, filter, map, pipe, switchMap } from "@okikio/observables"; const searchResults = pipe( - inputEvents, // Stream of input events - debounce(300), // Wait 300ms after user stops typing - filter(query => query.length >= 3), // Skip short queries - switchMap(query => // Cancel previous requests automatically + inputEvents, // Stream of input events + debounce(300), // Wait 300ms after user stops typing + filter((query) => query.length >= 3), // Skip short queries + switchMap((query) => + // Cancel previous requests automatically Observable.from(fetch(`/search?q=${query}`)) ), - map(response => response.json()) // Parse response + map((response) => response.json()), // Parse response ); // Subscribe to results (with automatic cleanup!) using subscription = searchResults.subscribe({ - next: results => updateSearchResults(results), - error: error => handleSearchError(error) + next: (results) => updateSearchResults(results), + error: (error) => handleSearchError(error), }); // Subscription automatically cleaned up when leaving scope ``` -Notice the difference? No manual timers, no cancellation logic, no memory leaks. The operators handle all the complex async coordination for you. +Notice the difference? No manual timers, no cancellation logic, no memory leaks. +The operators handle all the complex async coordination for you. -This library was built by developers who've felt these same frustrations. It focuses on: +This library was built by developers who've felt these same frustrations. It +focuses on: - **Familiarity**: If you know `Array.map()`, you already understand operators -- **Performance**: Built on Web Streams with pre-compiled error handling +- **Performance**: Built on Web Streams with pre-compiled error handling - **Type Safety**: Full TypeScript support with intelligent inference - **Standards**: Follows the TC39 Observable proposal for future compatibility - **Practicality**: <4KB but includes everything you need for real apps @@ -102,7 +124,7 @@ This library was built by developers who've felt these same frustrations. It foc ### Deno ```ts -import { Observable, pipe, map } from "jsr:@okikio/observables"; +import { map, Observable, pipe } from "jsr:@okikio/observables"; ``` Or @@ -133,7 +155,7 @@ npx jsr add @okikio/observables pnpm add jsr:@okikio/observables ``` -Or +Or ```bash yarn add @okikio/observables@jsr:latest @@ -151,34 +173,34 @@ bunx jsr add @okikio/observables You can also use it via a CDN: -```ts -import { Observable, pipe, map } from "https://esm.sh/jsr/@okikio/observables"; +```ts ignore +import { map, Observable, pipe } from "https://esm.sh/jsr/@okikio/observables"; ``` ## Quick Start ```ts -import { Observable, pipe, map, filter, debounce } from "@okikio/observables"; +import { debounce, filter, map, Observable, pipe } from "@okikio/observables"; // Create from anything async -const clicks = new Observable(observer => { - const handler = e => observer.next(e); - button.addEventListener('click', handler); - return () => button.removeEventListener('click', handler); +const clicks = new Observable((observer) => { + const handler = (e) => observer.next(e); + button.addEventListener("click", handler); + return () => button.removeEventListener("click", handler); }); // Transform with operators (like Array.map, but for async data) const doubleClicks = pipe( clicks, - debounce(300), // Wait 300ms between clicks - filter((_, index) => index % 2), // Only odd-numbered clicks - map(event => ({ x: event.clientX, y: event.clientY })) + debounce(300), // Wait 300ms between clicks + filter((_, index) => index % 2), // Only odd-numbered clicks + map((event) => ({ x: event.clientX, y: event.clientY })), ); // Subscribe to results using subscription = doubleClicks.subscribe({ - next: coords => console.log('Double click at:', coords), - error: err => console.error('Error:', err) + next: (coords) => console.log("Double click at:", coords), + error: (err) => console.error("Error:", err), }); // Automatically cleaned up when leaving scope ``` @@ -191,7 +213,8 @@ A couple sites/projects that use `@okikio/observables`: ## API -The API of `@okikio/observables` provides everything you need for reactive programming: +The API of `@okikio/observables` provides everything you need for reactive +programming: ### Core Observable @@ -199,40 +222,40 @@ The API of `@okikio/observables` provides everything you need for reactive progr import { Observable } from "@okikio/observables"; // Create observables -const timer = new Observable(observer => { +const timer = new Observable((observer) => { const id = setInterval(() => observer.next(Date.now()), 1000); return () => clearInterval(id); }); // Factory methods -Observable.of(1, 2, 3); // From values -Observable.from(fetch('/api/data')); // From promises/iterables +Observable.of(1, 2, 3); // From values +Observable.from(fetch("/api/data")); // From promises/iterables ``` ### Operators (19+ included) ```ts -import { pipe, map, filter, debounce, switchMap } from "@okikio/observables"; +import { debounce, filter, map, pipe, switchMap } from "@okikio/observables"; // Transform data as it flows pipe( source, - map(x => x * 2), // Transform each value - filter(x => x > 10), // Keep only values > 10 - debounce(300), // Wait for quiet periods - switchMap(x => fetchData(x)) // Cancel previous requests + map((x) => x * 2), // Transform each value + filter((x) => x > 10), // Keep only values > 10 + debounce(300), // Wait for quiet periods + switchMap((x) => fetchData(x)), // Cancel previous requests ); ``` ### EventBus & EventDispatcher ```ts -import { EventBus, createEventDispatcher } from "@okikio/observables"; +import { createEventDispatcher, EventBus } from "@okikio/observables"; // Simple pub/sub const bus = new EventBus(); -bus.events.subscribe(msg => console.log(msg)); -bus.emit('Hello world!'); +bus.events.subscribe((msg) => console.log(msg)); +bus.emit("Hello world!"); // Type-safe events interface AppEvents { @@ -241,8 +264,8 @@ interface AppEvents { } const events = createEventDispatcher(); -events.emit('userLogin', { userId: '123' }); -events.on('cartUpdate', data => updateUI(data.items)); +events.emit("userLogin", { userId: "123" }); +events.on("cartUpdate", (data) => updateUI(data.items)); ``` ### Error Handling (4 modes) @@ -252,14 +275,14 @@ import { createOperator } from "@okikio/observables"; // Choose your error handling strategy const processor = createOperator({ - errorMode: 'pass-through', // Errors become values (default) + errorMode: "pass-through", // Errors become values (default) // errorMode: 'ignore', // Skip errors silently // errorMode: 'throw', // Fail fast // errorMode: 'manual', // You handle everything - + transform(value, controller) { controller.enqueue(processValue(value)); - } + }, }); ``` @@ -283,55 +306,66 @@ async function example() { ```ts // Process large datasets with backpressure -for await (const chunk of bigDataStream.pull({ - strategy: { highWaterMark: 8 } // Small buffer for large files -})) { +for await ( + const chunk of bigDataStream.pull({ + strategy: { highWaterMark: 8 }, // Small buffer for large files + }) +) { await processChunk(chunk); } ``` -Look through the [tests/](./tests/) and [bench/](./bench/) folders for complex examples and multiple usage patterns. +Look through the [tests/](./tests/) and [bench/](./bench/) folders for complex +examples and multiple usage patterns. ## Advanced Usage ### Smart Search with Cancellation ```ts -import { pipe, debounce, filter, switchMap, map, catchErrors } from "@okikio/observables"; +import { + catchErrors, + debounce, + filter, + map, + pipe, + switchMap, +} from "@okikio/observables"; const searchResults = pipe( searchInput, - debounce(300), // Wait for typing pause - filter(query => query.length > 2), // Skip short queries - switchMap(query => // Cancel old requests automatically + debounce(300), // Wait for typing pause + filter((query) => query.length > 2), // Skip short queries + switchMap((query) => + // Cancel old requests automatically pipe( Observable.from(fetch(`/search?q=${query}`)), - map(res => res.json()), - catchErrors([]) // Return empty array on error + map((res) => res.json()), + catchErrors([]), // Return empty array on error ) - ) + ), ); -searchResults.subscribe(results => updateUI(results)); +searchResults.subscribe((results) => updateUI(results)); ``` ### Real-Time Dashboard ```ts -import { pipe, filter, scan, throttle } from "@okikio/observables"; +import { filter, pipe, scan, throttle } from "@okikio/observables"; const dashboardData = pipe( webSocketEvents, - filter(event => event.type === 'metric'), // Only metric events - scan((acc, event) => ({ // Build running totals + filter((event) => event.type === "metric"), // Only metric events + scan((acc, event) => ({ // Build running totals total: acc.total + event.value, count: acc.count + 1, - average: (acc.total + event.value) / (acc.count + 1) + average: (acc.total + event.value) / (acc.count + 1), }), { total: 0, count: 0, average: 0 }), - throttle(1000) // Update UI max once per second + throttle(1000), // Update UI max once per second ); -dashboardData.subscribe(stats => updateDashboard(stats)); +dashboardData.subscribe((stats) => updateDashboard(stats)); ``` ### Custom Operators @@ -342,102 +376,116 @@ import { createOperator, createStatefulOperator } from "@okikio/observables"; // Simple transformation function double() { return createOperator({ - name: 'double', + name: "double", transform(value, controller) { controller.enqueue(value * 2); - } + }, }); } // Stateful operation function movingAverage(windowSize: number) { return createStatefulOperator({ - name: 'movingAverage', + name: "movingAverage", createState: () => [], - + transform(value, arr, controller) { arr.push(value); if (arr.length > windowSize) arr.shift(); - + const avg = arr.reduce((sum, n) => sum + n, 0) / arr.length; controller.enqueue(avg); - } + }, }); } ``` ## Performance -We built this on Web Streams for good reason, native backpressure and memory efficiency come for free. Here's what you get: +We built this on Web Streams for good reason, native backpressure and memory +efficiency come for free. Here's what you get: -- **Web Streams Foundation**: Handles backpressure automatically, no memory bloat -- **Pre-compiled Error Modes**: Skip runtime checks in hot paths +- **Web Streams Foundation**: Handles backpressure automatically, no memory + bloat +- **Pre-compiled Error Modes**: Skip runtime checks in hot paths - **Tree Shaking**: Import only what you use (most apps need <4KB) - **TypeScript Native**: Zero runtime overhead for type safety Performance varies by use case, but here's how different error modes stack up: -| Error Mode | Performance | When We Use It | -|------------|-------------|-----------------| -| `manual` | Fastest | Hot paths, custom logic | -| `ignore` | Very fast | Filtering bad data | -| `pass-through` | Fast | Error recovery, debugging | -| `throw` | Good | Fail-fast validation | +| Error Mode | Performance | When We Use It | +| -------------- | ----------- | ------------------------- | +| `manual` | Fastest | Hot paths, custom logic | +| `ignore` | Very fast | Filtering bad data | +| `pass-through` | Fast | Error recovery, debugging | +| `throw` | Good | Fail-fast validation | ## Comparison -| Feature | @okikio/observables | RxJS | zen-observable | -|---------|-------------------|------|----------------| -| Bundle Size | <4KB | ~35KB | ~2KB | -| Operators | 19+ | 100+ | 5 | -| Error Modes | 4 modes | 1 mode | 1 mode | -| EventBus | ✅ Built-in | ❌ Separate | ❌ None | -| TC39 Compliance | ✅ Yes | ⚠️ Partial | ✅ Yes | -| TypeScript | ✅ Native | ✅ Yes | ⚠️ Basic | -| Tree Shaking | ✅ Perfect | ⚠️ Partial | ✅ Yes | -| Learning Curve | 🟢 Gentle | 🔴 Steep | 🟢 Gentle | +| Feature | @okikio/observables | RxJS | zen-observable | +| --------------- | ------------------- | ----------- | -------------- | +| Bundle Size | <4KB | ~35KB | ~2KB | +| Operators | 19+ | 100+ | 5 | +| Error Modes | 4 modes | 1 mode | 1 mode | +| EventBus | ✅ Built-in | ❌ Separate | ❌ None | +| TC39 Compliance | ✅ Yes | ⚠️ Partial | ✅ Yes | +| TypeScript | ✅ Native | ✅ Yes | ⚠️ Basic | +| Tree Shaking | ✅ Perfect | ⚠️ Partial | ✅ Yes | +| Learning Curve | 🟢 Gentle | 🔴 Steep | 🟢 Gentle | ## Browser Support -| Chrome | Edge | Firefox | Safari | Node | Deno | Bun | -| ------ | ---- | ------- | ------ | ---- | ---- | --- | +| Chrome | Edge | Firefox | Safari | Node | Deno | Bun | +| ------ | ---- | ------- | ------ | ---- | ---- | ---- | | 80+ | 80+ | 72+ | 13+ | 16+ | 1.0+ | 1.0+ | -> Native support for Observables is excellent. Some advanced features like `Symbol.dispose` require newer environments or polyfills. +> Native support for Observables is excellent. Some advanced features like +> `Symbol.dispose` require newer environments or polyfills. ## FAQ ### What are Observables exactly? -Think of them as Promises that can send multiple values over time. Where a Promise gives you one result eventually, an Observable can keep sending values, like a stream of search results, mouse movements, or WebSocket messages. +Think of them as Promises that can send multiple values over time. Where a +Promise gives you one result eventually, an Observable can keep sending values, +like a stream of search results, mouse movements, or WebSocket messages. ### Why not just use RxJS? -RxJS is powerful but can be overwhelming. We've all been there, 100+ operators, steep learning curve, 35KB bundle size. This library gives you the essential Observable patterns you actually use day-to-day, following the TC39 proposal so you're future-ready. +RxJS is powerful but can be overwhelming. We've all been there, 100+ operators, +steep learning curve, 35KB bundle size. This library gives you the essential +Observable patterns you actually use day-to-day, following the TC39 proposal so +you're future-ready. ### EventBus vs Observable, when do I use which? Good question! Here's how we think about it: -- **Observable**: When you're transforming data one-to-one (API calls, processing user input) -- **EventBus**: When you need one-to-many communication (notifications, cross-component events) +- **Observable**: When you're transforming data one-to-one (API calls, + processing user input) +- **EventBus**: When you need one-to-many communication (notifications, + cross-component events) ### How should I handle errors? Pick the mode that fits your situation: - **`pass-through`**: Errors become values you can recover from -- **`ignore`**: Skip errors silently (great for filtering noisy data) +- **`ignore`**: Skip errors silently (great for filtering noisy data) - **`throw`**: Fail fast for validation - **`manual`**: Handle everything yourself ### Is this actually production ready? -We use it in production. It follows the TC39 proposal, has comprehensive tests, and handles resource management properly. The Web Streams foundation is battle-tested across browsers and runtimes. +We use it in production. It follows the TC39 proposal, has comprehensive tests, +and handles resource management properly. The Web Streams foundation is +battle-tested across browsers and runtimes. ## Contributing -I encourage you to use [deno](https://deno.com/) to contribute to this repo, to setup deno you can install it via [mise](https://mise.jdx.dev/) or [manually](https://deno.land/manual/getting_started/installation). +I encourage you to use [deno](https://deno.com/) to contribute to this repo, to +setup deno you can install it via [mise](https://mise.jdx.dev/) or +[manually](https://deno.land/manual/getting_started/installation). Setup Mise: @@ -464,7 +512,10 @@ Run benchmarks: deno task bench ``` -> **Note**: This project uses [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) standard for commits, so please format your commits using the rules it sets out. +> **Note**: This project uses +> [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) +> standard for commits, so please format your commits using the rules it sets +> out. ## Licence diff --git a/bench/events_bench.ts b/bench/events_bench.ts index 6504cfd..46df732 100644 --- a/bench/events_bench.ts +++ b/bench/events_bench.ts @@ -1,3 +1,4 @@ +// deno-lint-ignore-file no-import-prefix /** * Event system benchmarks. * @@ -7,9 +8,9 @@ * subscription setup on every iteration. */ -import { bench, do_not_optimize, run } from 'npm:mitata'; +import { bench, do_not_optimize, run } from "npm:mitata@^1.0.34"; -import { EventBus, createEventDispatcher } from '../events.ts'; +import { createEventDispatcher, EventBus } from "../events.ts"; let singleSubscriberValue = 0; const singleSubscriberBus = new EventBus(); @@ -19,18 +20,22 @@ const singleSubscriberSub = singleSubscriberBus.subscribe((value) => { let tenSubscriberValue = 0; const tenSubscriberBus = new EventBus(); -const tenSubscriberSubs = Array.from({ length: 10 }, (_, index) => - tenSubscriberBus.subscribe((value) => { - tenSubscriberValue = value + index; - }) +const tenSubscriberSubs = Array.from( + { length: 10 }, + (_, index) => + tenSubscriberBus.subscribe((value) => { + tenSubscriberValue = value + index; + }), ); let hundredSubscriberValue = 0; const hundredSubscriberBus = new EventBus(); -const hundredSubscriberSubs = Array.from({ length: 100 }, (_, index) => - hundredSubscriberBus.subscribe((value) => { - hundredSubscriberValue = value + index; - }) +const hundredSubscriberSubs = Array.from( + { length: 100 }, + (_, index) => + hundredSubscriberBus.subscribe((value) => { + hundredSubscriberValue = value + index; + }), ); const dispatcher = createEventDispatcher<{ @@ -39,33 +44,36 @@ const dispatcher = createEventDispatcher<{ }>(); let dispatcherValue = 0; -const dispatcherSub = dispatcher.on('message', (payload) => { +const dispatcherSub = dispatcher.on("message", (payload) => { dispatcherValue = payload.id; }); -bench('Events: EventBus emit -> 1 subscriber', () => { +bench("Events: EventBus emit -> 1 subscriber", () => { singleSubscriberBus.emit(1); do_not_optimize(singleSubscriberValue); }); -bench('Events: EventBus emit -> 10 subscribers', () => { +bench("Events: EventBus emit -> 10 subscribers", () => { tenSubscriberBus.emit(10); do_not_optimize(tenSubscriberValue); }); -bench('Events: EventBus emit -> 100 subscribers', () => { +bench("Events: EventBus emit -> 100 subscribers", () => { hundredSubscriberBus.emit(100); do_not_optimize(hundredSubscriberValue); }); -bench('Events: typed dispatcher emit -> handler', () => { - dispatcher.emit('message', { id: 42, text: 'ok' }); +bench("Events: typed dispatcher emit -> handler", () => { + dispatcher.emit("message", { id: 42, text: "ok" }); do_not_optimize(dispatcherValue); }); -bench('Events: setup-inclusive bus + 1000 subscribe/unsubscribe cycles', () => { +bench("Events: setup-inclusive bus + 1000 subscribe/unsubscribe cycles", () => { const bus = new EventBus(); - const subscriptions = Array.from({ length: 1000 }, () => bus.subscribe(() => {})); + const subscriptions = Array.from( + { length: 1000 }, + () => bus.subscribe(() => {}), + ); for (const subscription of subscriptions) { subscription.unsubscribe(); @@ -73,7 +81,7 @@ bench('Events: setup-inclusive bus + 1000 subscribe/unsubscribe cycles', () => { do_not_optimize(subscriptions); do_not_optimize(bus); -}).gc('inner'); +}).gc("inner"); await run(); diff --git a/bench/latency_bench.ts b/bench/latency_bench.ts index 3f65d78..d27ad68 100644 --- a/bench/latency_bench.ts +++ b/bench/latency_bench.ts @@ -1,3 +1,4 @@ +// deno-lint-ignore-file no-import-prefix /** * Low-latency benchmarks for primitive operations. * @@ -6,26 +7,26 @@ * single-value operator hop. */ -import { bench, do_not_optimize, run } from 'npm:mitata'; +import { bench, do_not_optimize, run } from "npm:mitata@^1.0.34"; -import { EventBus } from '../events.ts'; -import { isObservableError } from '../error.ts'; -import { Observable } from '../observable.ts'; -import { pipe } from '../helpers/pipe.ts'; -import { filter, map } from '../helpers/operations/core.ts'; +import { EventBus } from "../events.ts"; +import { isObservableError } from "../error.ts"; +import { Observable } from "../observable.ts"; +import { pipe } from "../helpers/pipe.ts"; +import { filter, map } from "../helpers/operations/core.ts"; const noEmissionObservable = new Observable(() => { return () => {}; }); -bench('Latency: subscribe + unsubscribe (no emissions)', () => { +bench("Latency: subscribe + unsubscribe (no emissions)", () => { const subscription = noEmissionObservable.subscribe(() => {}); subscription.unsubscribe(); do_not_optimize(subscription); do_not_optimize(subscription.closed); }); -bench('Latency: Observable.of(1) -> subscribe', () => { +bench("Latency: Observable.of(1) -> subscribe", () => { let lastValue = 0; const subscription = Observable.of(1).subscribe((value) => { lastValue = value; @@ -35,7 +36,7 @@ bench('Latency: Observable.of(1) -> subscribe', () => { subscription.unsubscribe(); }); -bench('Latency: single value through map', () => { +bench("Latency: single value through map", () => { let lastValue = 0; const result = pipe( Observable.of(1), @@ -52,7 +53,7 @@ bench('Latency: single value through map', () => { subscription.unsubscribe(); }); -bench('Latency: single value through map + filter', () => { +bench("Latency: single value through map + filter", () => { let lastValue = 0; const result = pipe( Observable.of(1), @@ -80,7 +81,7 @@ const warmPathSub = warmPathBus.subscribe((value) => { latestEventBusValue = value; }); -bench('Latency: EventBus emit -> 1 subscriber', () => { +bench("Latency: EventBus emit -> 1 subscriber", () => { warmPathBus.emit(7); do_not_optimize(latestEventBusValue); }); diff --git a/bench/memory_bench.ts b/bench/memory_bench.ts index facd5db..5452b61 100644 --- a/bench/memory_bench.ts +++ b/bench/memory_bench.ts @@ -1,21 +1,22 @@ +// deno-lint-ignore-file no-import-prefix /** * Memory allocation and GC pressure benchmarks. - * + * * Measures memory usage patterns, allocation rates, and GC behavior * under various Observable usage scenarios. Critical for understanding * real-world performance characteristics beyond simple execution time. */ -import { bench, run, do_not_optimize } from 'npm:mitata'; -import { Observable } from '../observable.ts'; -import { isObservableError } from '../error.ts'; -import { pipe } from '../helpers/pipe.ts'; -import { map, filter, take, scan } from '../helpers/operations/core.ts'; -import { createQueue, enqueue, dequeue } from '../queue.ts'; +import { bench, do_not_optimize, run } from "npm:mitata@^1.0.34"; +import { Observable } from "../observable.ts"; +import { isObservableError } from "../error.ts"; +import { pipe } from "../helpers/pipe.ts"; +import { map, scan, take } from "../helpers/operations/core.ts"; +import { createQueue, dequeue, enqueue } from "../queue.ts"; // Memory tracking helper -function getMemoryUsage(): number { - if (typeof Deno !== 'undefined' && Deno.memoryUsage) { +function _getMemoryUsage(): number { + if (typeof Deno !== "undefined" && Deno.memoryUsage) { return Deno.memoryUsage().heapUsed; } return 0; @@ -25,200 +26,202 @@ function getMemoryUsage(): number { function* largeDataGenerator(sizeBytes: number) { const chunkSize = 1024; // 1KB chunks const numChunks = Math.floor(sizeBytes / chunkSize); - + for (let i = 0; i < numChunks; i++) { yield new Uint8Array(chunkSize); } } -bench('Memory: Observable creation overhead (1000x)', () => { +bench("Memory: Observable creation overhead (1000x)", () => { const observables: Observable[] = []; - + for (let i = 0; i < 1000; i++) { observables.push(Observable.of(i)); } - + do_not_optimize(observables); -}).gc('inner'); +}).gc("inner"); -bench('Memory: Subscription tracking (1000 subs)', () => { +bench("Memory: Subscription tracking (1000 subs)", () => { const obs = Observable.of(1, 2, 3); const subscriptions = []; - + for (let i = 0; i < 1000; i++) { subscriptions.push(obs.subscribe(() => {})); } - + // Cleanup for (const sub of subscriptions) { sub.unsubscribe(); } - + do_not_optimize(subscriptions); -}).gc('inner'); +}).gc("inner"); -bench('Memory: Queue buffer reuse (10000 cycles)', () => { +bench("Memory: Queue buffer reuse (10000 cycles)", () => { const queue = createQueue(1000); - + // Fill queue for (let i = 0; i < 1000; i++) { enqueue(queue, i); } - + // Cycle through: should reuse buffer slots for (let i = 0; i < 10000; i++) { dequeue(queue); enqueue(queue, i); } - + do_not_optimize(queue); -}).gc('inner'); +}).gc("inner"); -bench('Memory: Large data streaming (1MB)', async () => { +bench("Memory: Large data streaming (1MB)", async () => { const oneMB = 1024 * 1024; const obs = Observable.from(largeDataGenerator(oneMB)); - + let totalBytes = 0; for await (const chunk of obs) { totalBytes += chunk.length; } - + do_not_optimize(totalBytes); -}).gc('inner'); +}).gc("inner"); -bench('Memory: Large data streaming (10MB)', async () => { +bench("Memory: Large data streaming (10MB)", async () => { const tenMB = 10 * 1024 * 1024; const obs = Observable.from(largeDataGenerator(tenMB)); - + let totalBytes = 0; for await (const chunk of obs) { totalBytes += chunk.length; } - + do_not_optimize(totalBytes); -}).gc('inner'); +}).gc("inner"); -bench('Memory: Large data streaming (100MB)', async () => { +bench("Memory: Large data streaming (100MB)", async () => { const hundredMB = 100 * 1024 * 1024; const obs = Observable.from(largeDataGenerator(hundredMB)); - + let totalBytes = 0; for await (const chunk of obs) { totalBytes += chunk.length; } - + do_not_optimize(totalBytes); -}).gc('inner'); +}).gc("inner"); -bench('Memory: Operator chain with large data (10MB)', async () => { +bench("Memory: Operator chain with large data (10MB)", async () => { const tenMB = 10 * 1024 * 1024; - + const result = pipe( Observable.from(largeDataGenerator(tenMB)), map((chunk: Uint8Array) => chunk.length), scan((acc: number, len: number) => acc + len, 0), - take(1000) + take(1000), ); - + const values: number[] = []; for await (const val of result) { if (!isObservableError(val)) values.push(val); } - + do_not_optimize(values); -}).gc('inner'); +}).gc("inner"); -bench('Memory: Rapid subscribe/unsubscribe cycles (10000x)', () => { +bench("Memory: Rapid subscribe/unsubscribe cycles (10000x)", () => { const obs = Observable.of(1, 2, 3, 4, 5); - + for (let i = 0; i < 10000; i++) { const sub = obs.subscribe(() => {}); sub.unsubscribe(); } - + do_not_optimize(obs); -}).gc('inner'); +}).gc("inner"); -bench('Memory: Concurrent subscriptions with cleanup (1000x)', () => { +bench("Memory: Concurrent subscriptions with cleanup (1000x)", () => { const obs = new Observable((observer) => { const id = setInterval(() => { observer.next(Math.random()); }, 100); - + return () => clearInterval(id); }); - + const subs = []; for (let i = 0; i < 1000; i++) { subs.push(obs.subscribe(() => {})); } - + // Cleanup all for (const sub of subs) { sub.unsubscribe(); } - + do_not_optimize(subs); -}).gc('inner'); +}).gc("inner"); -bench('Memory: Queue allocation patterns (100K items)', () => { +bench("Memory: Queue allocation patterns (100K items)", () => { const queue = createQueue(100000); - + for (let i = 0; i < 100000; i++) { enqueue(queue, i); } - + do_not_optimize(queue); -}).gc('inner'); +}).gc("inner"); -bench('Memory: Observable value buffering (10000 items)', async () => { +bench("Memory: Observable value buffering (10000 items)", async () => { const values: number[] = []; - + const obs = new Observable((observer) => { for (let i = 0; i < 10000; i++) { observer.next(i); } observer.complete(); }); - + for await (const val of obs) { values.push(val); } - + do_not_optimize(values); -}).gc('inner'); +}).gc("inner"); // Stress test: Push system to limits -bench('STRESS: 1GB data streaming', async () => { +bench("STRESS: 1GB data streaming", async () => { const oneGB = 1024 * 1024 * 1024; const obs = Observable.from(largeDataGenerator(oneGB)); - + let totalBytes = 0; let chunkCount = 0; - + for await (const chunk of obs) { totalBytes += chunk.length; chunkCount++; } - + do_not_optimize({ totalBytes, chunkCount }); -}).gc('inner'); +}).gc("inner"); -bench('STRESS: 1M subscriptions lifecycle', () => { +bench("STRESS: 1M subscriptions lifecycle", () => { const obs = Observable.of(42); let completedCount = 0; - + // Note: This is intentionally stressful // Real code shouldn't do this pattern for (let i = 0; i < 1000000; i++) { const sub = obs.subscribe({ next: () => {}, - complete: () => { completedCount++; } + complete: () => { + completedCount++; + }, }); sub.unsubscribe(); } - + do_not_optimize(completedCount); -}).gc('inner'); +}).gc("inner"); await run(); diff --git a/bench/observable_bench.ts b/bench/observable_bench.ts index 1bef6ac..cc82366 100644 --- a/bench/observable_bench.ts +++ b/bench/observable_bench.ts @@ -1,14 +1,15 @@ +// deno-lint-ignore-file no-import-prefix /** * Observable creation and subscription benchmarks. - * + * * Measures overhead of Observable creation, subscription, and teardown * across various scenarios from simple to complex patterns. */ -import { bench, run, do_not_optimize } from 'npm:mitata'; -import { Observable } from '../observable.ts'; -import { pipe } from '../helpers/pipe.ts'; -import { map, filter, take } from '../helpers/operations/core.ts'; +import { bench, do_not_optimize, run } from "npm:mitata@^1.0.34"; +import { Observable } from "../observable.ts"; +import { pipe } from "../helpers/pipe.ts"; +import { filter, map, take } from "../helpers/operations/core.ts"; // Test data generation function* numberGenerator(count: number) { @@ -25,41 +26,41 @@ const simpleObservable = new Observable((observer) => { const rangeObservable = Observable.from(numberGenerator(1000)); -const asyncObservable = new Observable((observer) => { +const _asyncObservable = new Observable((observer) => { const id = setInterval(() => { observer.next(Math.random()); }, 10); return () => clearInterval(id); }); -bench('Observable.of(single value)', () => { +bench("Observable.of(single value)", () => { // Act & Assert do_not_optimize(Observable.of(42)); }); -bench('Observable.of(10 values)', () => { +bench("Observable.of(10 values)", () => { do_not_optimize(Observable.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)); }); -bench('Observable.from(array 100 items)', () => { +bench("Observable.from(array 100 items)", () => { const arr = Array.from({ length: 100 }, (_, i) => i); do_not_optimize(Observable.from(arr)); }); -bench('Observable.from(generator 1000 items)', () => { +bench("Observable.from(generator 1000 items)", () => { do_not_optimize(Observable.from(numberGenerator(1000))); -}).gc('inner'); +}).gc("inner"); -bench('new Observable (simple sync)', () => { +bench("new Observable (simple sync)", () => { do_not_optimize( new Observable((observer) => { observer.next(42); observer.complete(); - }) + }), ); }); -bench('new Observable (with cleanup)', () => { +bench("new Observable (with cleanup)", () => { do_not_optimize( new Observable((observer) => { observer.next(42); @@ -67,17 +68,17 @@ bench('new Observable (with cleanup)', () => { return () => { // Cleanup }; - }) + }), ); }); -bench('subscribe + immediate complete', () => { +bench("subscribe + immediate complete", () => { const sub = simpleObservable.subscribe(() => {}); do_not_optimize(sub); sub.unsubscribe(); }); -bench('subscribe + collect 1000 values', async () => { +bench("subscribe + collect 1000 values", async () => { const values: number[] = []; await new Promise((resolve) => { rangeObservable.subscribe({ @@ -86,35 +87,35 @@ bench('subscribe + collect 1000 values', async () => { }); }); do_not_optimize(values); -}).gc('inner'); +}).gc("inner"); -bench('async iteration over 1000 values', async () => { +bench("async iteration over 1000 values", async () => { const values: number[] = []; for await (const val of rangeObservable) { values.push(val); } do_not_optimize(values); -}).gc('inner'); +}).gc("inner"); -bench('multiple subscribers (cold semantics)', () => { +bench("multiple subscribers (cold semantics)", () => { const obs = Observable.of(1, 2, 3); const sub1 = obs.subscribe(() => {}); const sub2 = obs.subscribe(() => {}); const sub3 = obs.subscribe(() => {}); - + do_not_optimize([sub1, sub2, sub3]); - + sub1.unsubscribe(); sub2.unsubscribe(); sub3.unsubscribe(); }); -bench('pipe with 3 operators', () => { +bench("pipe with 3 operators", () => { const result = pipe( Observable.from(numberGenerator(100)), map((x: number) => x * 2), filter((x: number) => x % 4 === 0), - take(10) + take(10), ); do_not_optimize(result); }); diff --git a/bench/operators_bench.ts b/bench/operators_bench.ts index 88e8a07..78335b9 100644 --- a/bench/operators_bench.ts +++ b/bench/operators_bench.ts @@ -1,17 +1,17 @@ +// deno-lint-ignore-file no-import-prefix /** * Operator pipeline benchmarks. - * + * * Measures performance of various operator combinations that represent * real-world Observable usage patterns. */ -import { bench, run, do_not_optimize } from 'npm:mitata'; -import { Observable } from '../observable.ts'; -import { isObservableError } from '../error.ts'; -import { pipe } from '../helpers/pipe.ts'; -import { map, filter, scan, take, tap } from '../helpers/operations/core.ts'; -import { debounce, delay, throttle } from '../helpers/operations/timing.ts'; -import { batch, toArray } from '../helpers/operations/batch.ts'; +import { bench, do_not_optimize, run } from "npm:mitata@^1.0.34"; +import { Observable } from "../observable.ts"; +import { isObservableError } from "../error.ts"; +import { pipe } from "../helpers/pipe.ts"; +import { filter, map, scan, take, tap } from "../helpers/operations/core.ts"; +import { batch, toArray } from "../helpers/operations/batch.ts"; function* numberStream(count: number) { for (let i = 0; i < count; i++) { @@ -19,145 +19,147 @@ function* numberStream(count: number) { } } -bench('Operators: map only (1000 items)', async () => { +bench("Operators: map only (1000 items)", async () => { const result = pipe( Observable.from(numberStream(1000)), - map((x: number) => x * 2) + map((x: number) => x * 2), ); - + const values: number[] = []; for await (const val of result) { if (!isObservableError(val)) values.push(val); } - + do_not_optimize(values); -}).gc('inner'); +}).gc("inner"); -bench('Operators: filter only (1000 items)', async () => { +bench("Operators: filter only (1000 items)", async () => { const result = pipe( Observable.from(numberStream(1000)), - filter((x: number) => x % 2 === 0) + filter((x: number) => x % 2 === 0), ); - + const values: number[] = []; for await (const val of result) { if (!isObservableError(val)) values.push(val); } - + do_not_optimize(values); -}).gc('inner'); +}).gc("inner"); -bench('Operators: map + filter chain', async () => { +bench("Operators: map + filter chain", async () => { const result = pipe( Observable.from(numberStream(1000)), map((x: number) => x * 2), filter((x: number) => x % 4 === 0), - map((x: number) => x / 2) + map((x: number) => x / 2), ); - + const values: number[] = []; for await (const val of result) { if (!isObservableError(val)) values.push(val); } - + do_not_optimize(values); -}).gc('inner'); +}).gc("inner"); -bench('Operators: map + filter + take', async () => { +bench("Operators: map + filter + take", async () => { const result = pipe( Observable.from(numberStream(10000)), map((x: number) => x * 2), filter((x: number) => x % 4 === 0), - take(100) + take(100), ); - + const values: number[] = []; for await (const val of result) { if (!isObservableError(val)) values.push(val); } - + do_not_optimize(values); }); -bench('Operators: scan (running sum)', async () => { +bench("Operators: scan (running sum)", async () => { const result = pipe( Observable.from(numberStream(1000)), - scan((acc: number, val: number) => acc + val, 0) + scan((acc: number, val: number) => acc + val, 0), ); - + const values: number[] = []; for await (const val of result) { if (!isObservableError(val)) values.push(val); } - + do_not_optimize(values); -}).gc('inner'); +}).gc("inner"); -bench('Operators: complex chain (5 operators)', async () => { +bench("Operators: complex chain (5 operators)", async () => { const result = pipe( Observable.from(numberStream(1000)), map((x: number) => x * 2), filter((x: number) => x % 4 === 0), scan((acc: number, val: number) => acc + val, 0), map((x: number) => x / 100), - take(500) + take(500), ); - + const values: number[] = []; for await (const val of result) { if (!isObservableError(val)) values.push(val); } - + do_not_optimize(values); -}).gc('inner'); +}).gc("inner"); -bench('Operators: tap (side effects)', async () => { +bench("Operators: tap (side effects)", async () => { let sideEffectCount = 0; - + const result = pipe( Observable.from(numberStream(1000)), - tap(() => { sideEffectCount++; }), - map((x: number) => x * 2) + tap(() => { + sideEffectCount++; + }), + map((x: number) => x * 2), ); - + const values: number[] = []; for await (const val of result) { if (!isObservableError(val)) values.push(val); } - + do_not_optimize({ values, sideEffectCount }); -}).gc('inner'); +}).gc("inner"); -bench('Operators: batch (groups of 10)', async () => { +bench("Operators: batch (groups of 10)", async () => { const result = pipe( Observable.from(numberStream(1000)), - batch(10) + batch(10), ); - + const batches: number[][] = []; for await (const batch of result) { if (!isObservableError(batch)) batches.push(batch); } - + do_not_optimize(batches); -}).gc('inner'); +}).gc("inner"); -bench('Operators: toArray collector', async () => { +bench("Operators: toArray collector", async () => { const result = pipe( Observable.from(numberStream(1000)), map((x: number) => x * 2), - toArray() + toArray(), ); - + const arrays: number[][] = []; for await (const arr of result) { if (!isObservableError(arr)) arrays.push(arr); } - + do_not_optimize(arrays); -}).gc('inner'); +}).gc("inner"); -bench('Operators: deep chain (10 operators)', async () => { +bench("Operators: deep chain (10 operators)", async () => { const result = pipe( Observable.from(numberStream(1000)), map((x: number) => x + 1), @@ -169,15 +171,15 @@ bench('Operators: deep chain (10 operators)', async () => { filter((x: number) => x > 0), map((x: number) => Math.floor(x)), take(100), - tap(() => {}) + tap(() => {}), ); - + const values: number[] = []; for await (const val of result) { if (!isObservableError(val)) values.push(val); } - + do_not_optimize(values); -}).gc('inner'); +}).gc("inner"); await run(); diff --git a/bench/queue_bench.ts b/bench/queue_bench.ts index dcec8fd..15d10ed 100644 --- a/bench/queue_bench.ts +++ b/bench/queue_bench.ts @@ -1,48 +1,48 @@ +// deno-lint-ignore-file no-import-prefix /** * Queue operations benchmarks focusing on O(1) performance claims. - * + * * Tests circular buffer operations at various scales to verify constant-time * performance and compare against naive Array.shift() implementations. */ -import { bench, run, do_not_optimize } from 'npm:mitata'; +import { bench, do_not_optimize, run } from "npm:mitata@^1.0.34"; import { + clear, createQueue, - enqueue, dequeue, - peek, + enqueue, isEmpty, - isFull, - clear, + peek, toArray, -} from '../queue.ts'; +} from "../queue.ts"; // Baseline: Array.shift() for comparison class ArrayQueue { private items: T[] = []; - + enqueue(item: T): void { this.items.push(item); } - + dequeue(): T | undefined { return this.items.shift(); } - + peek(): T | undefined { return this.items[0]; } - + isEmpty(): boolean { return this.items.length === 0; } } -bench('Queue: create empty queue', () => { +bench("Queue: create empty queue", () => { do_not_optimize(createQueue(1000)); }); -bench('Queue: enqueue 100 items', () => { +bench("Queue: enqueue 100 items", () => { const queue = createQueue(100); for (let i = 0; i < 100; i++) { enqueue(queue, i); @@ -50,28 +50,28 @@ bench('Queue: enqueue 100 items', () => { do_not_optimize(queue); }); -bench('Queue: enqueue 1000 items', () => { +bench("Queue: enqueue 1000 items", () => { const queue = createQueue(1000); for (let i = 0; i < 1000; i++) { enqueue(queue, i); } do_not_optimize(queue); -}).gc('inner'); +}).gc("inner"); -bench('Queue: enqueue 10000 items', () => { +bench("Queue: enqueue 10000 items", () => { const queue = createQueue(10000); for (let i = 0; i < 10000; i++) { enqueue(queue, i); } do_not_optimize(queue); -}).gc('inner'); +}).gc("inner"); -bench('Queue: dequeue 100 items', () => { +bench("Queue: dequeue 100 items", () => { const queue = createQueue(100); for (let i = 0; i < 100; i++) { enqueue(queue, i); } - + const results: number[] = []; for (let i = 0; i < 100; i++) { const val = dequeue(queue); @@ -80,23 +80,23 @@ bench('Queue: dequeue 100 items', () => { do_not_optimize(results); }); -bench('Queue: dequeue 1000 items', () => { +bench("Queue: dequeue 1000 items", () => { const queue = createQueue(1000); for (let i = 0; i < 1000; i++) { enqueue(queue, i); } - + const results: number[] = []; for (let i = 0; i < 1000; i++) { const val = dequeue(queue); if (val !== undefined) results.push(val); } do_not_optimize(results); -}).gc('inner'); +}).gc("inner"); -bench('Queue: enqueue+dequeue mixed 1000 ops', () => { +bench("Queue: enqueue+dequeue mixed 1000 ops", () => { const queue = createQueue(500); - + for (let i = 0; i < 1000; i++) { if (i % 2 === 0) { enqueue(queue, i); @@ -105,31 +105,31 @@ bench('Queue: enqueue+dequeue mixed 1000 ops', () => { } } do_not_optimize(queue); -}).gc('inner'); +}).gc("inner"); -bench('Queue: circular wrap 1000 cycles', () => { +bench("Queue: circular wrap 1000 cycles", () => { const queue = createQueue(100); - + // Fill queue for (let i = 0; i < 100; i++) { enqueue(queue, i); } - + // Cycle: dequeue one, enqueue one (causes wrapping) for (let i = 0; i < 1000; i++) { dequeue(queue); enqueue(queue, i + 100); } - + do_not_optimize(queue); -}).gc('inner'); +}).gc("inner"); -bench('Queue: peek 1000 times', () => { +bench("Queue: peek 1000 times", () => { const queue = createQueue(100); for (let i = 0; i < 100; i++) { enqueue(queue, i); } - + let sum = 0; for (let i = 0; i < 1000; i++) { const val = peek(queue); @@ -138,51 +138,51 @@ bench('Queue: peek 1000 times', () => { do_not_optimize(sum); }); -bench('Queue: toArray with 1000 items', () => { +bench("Queue: toArray with 1000 items", () => { const queue = createQueue(1000); for (let i = 0; i < 1000; i++) { enqueue(queue, i); } - + do_not_optimize(toArray(queue)); -}).gc('inner'); +}).gc("inner"); -bench('Queue: clear 1000 items', () => { +bench("Queue: clear 1000 items", () => { const queue = createQueue(1000); for (let i = 0; i < 1000; i++) { enqueue(queue, i); } - + clear(queue); do_not_optimize(queue); }); // Comparison benchmarks: circular buffer vs Array.shift() -bench('[Baseline] Array.shift: enqueue 1000 items', () => { +bench("[Baseline] Array.shift: enqueue 1000 items", () => { const queue = new ArrayQueue(); for (let i = 0; i < 1000; i++) { queue.enqueue(i); } do_not_optimize(queue); -}).gc('inner'); +}).gc("inner"); -bench('[Baseline] Array.shift: dequeue 1000 items', () => { +bench("[Baseline] Array.shift: dequeue 1000 items", () => { const queue = new ArrayQueue(); for (let i = 0; i < 1000; i++) { queue.enqueue(i); } - + const results: number[] = []; for (let i = 0; i < 1000; i++) { const val = queue.dequeue(); if (val !== undefined) results.push(val); } do_not_optimize(results); -}).gc('inner'); +}).gc("inner"); -bench('[Baseline] Array.shift: mixed 1000 ops', () => { +bench("[Baseline] Array.shift: mixed 1000 ops", () => { const queue = new ArrayQueue(); - + for (let i = 0; i < 1000; i++) { if (i % 2 === 0) { queue.enqueue(i); @@ -191,6 +191,6 @@ bench('[Baseline] Array.shift: mixed 1000 ops', () => { } } do_not_optimize(queue); -}).gc('inner'); +}).gc("inner"); await run(); diff --git a/deno.jsonc b/deno.jsonc index fa51e24..121e576 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -34,6 +34,11 @@ }, "license": "MIT", "publish": { + "include": [ + "**/*.ts", + "LICENSE", + "README.md" + ], "exclude": [ ".devcontainer/", ".github/", @@ -55,6 +60,7 @@ }, "exclude": [ "coverage/", - "bench/results/" + "bench/results/", + "npm/" ] } diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..b4a5dad --- /dev/null +++ b/deno.lock @@ -0,0 +1,109 @@ +{ + "version": "5", + "specifiers": { + "jsr:@david/code-block-writer@^13.0.3": "13.0.3", + "jsr:@deno/dnt@*": "0.42.3", + "jsr:@std/assert@^1.0.17": "1.0.19", + "jsr:@std/assert@^1.0.19": "1.0.19", + "jsr:@std/expect@1": "1.0.18", + "jsr:@std/fmt@1": "1.0.9", + "jsr:@std/fs@1": "1.0.23", + "jsr:@std/internal@^1.0.12": "1.0.12", + "jsr:@std/json@^1.0.2": "1.0.3", + "jsr:@std/jsonc@*": "1.0.2", + "jsr:@std/path@1": "1.1.4", + "jsr:@std/path@^1.1.4": "1.1.4", + "jsr:@std/testing@1": "1.0.17", + "jsr:@ts-morph/bootstrap@0.27": "0.27.0", + "jsr:@ts-morph/common@0.27": "0.27.0", + "npm:mitata@^1.0.34": "1.0.34" + }, + "jsr": { + "@david/code-block-writer@13.0.3": { + "integrity": "f98c77d320f5957899a61bfb7a9bead7c6d83ad1515daee92dbacc861e13bb7f" + }, + "@deno/dnt@0.42.3": { + "integrity": "62a917a0492f3c8af002dce90605bb0d41f7d29debc06aca40dba72ab65d8ae3", + "dependencies": [ + "jsr:@david/code-block-writer", + "jsr:@std/fmt", + "jsr:@std/fs", + "jsr:@std/path@1", + "jsr:@ts-morph/bootstrap" + ] + }, + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/expect@1.0.18": { + "integrity": "8566eab35200466f8609eb7e7aed062ed0db314e9a258d5d201b1b8997ce801a", + "dependencies": [ + "jsr:@std/assert@^1.0.19", + "jsr:@std/internal", + "jsr:@std/path@^1.1.4" + ] + }, + "@std/fmt@1.0.9": { + "integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0" + }, + "@std/fs@1.0.23": { + "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37", + "dependencies": [ + "jsr:@std/internal", + "jsr:@std/path@^1.1.4" + ] + }, + "@std/internal@1.0.12": { + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" + }, + "@std/json@1.0.3": { + "integrity": "97d5710996293a027b7aa5f0d1f4fa29f246f269e6b5597e08807613f37d426c" + }, + "@std/jsonc@1.0.2": { + "integrity": "909605dae3af22bd75b1cbda8d64a32cf1fd2cf6efa3f9e224aba6d22c0f44c7", + "dependencies": [ + "jsr:@std/json" + ] + }, + "@std/path@1.1.4": { + "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/testing@1.0.17": { + "integrity": "87bdc2700fa98249d48a17cd72413352d3d3680dcfbdb64947fd0982d6bbf681", + "dependencies": [ + "jsr:@std/assert@^1.0.17", + "jsr:@std/internal" + ] + }, + "@ts-morph/bootstrap@0.27.0": { + "integrity": "b8d7bc8f7942ce853dde4161b28f9aa96769cef3d8eebafb379a81800b9e2448", + "dependencies": [ + "jsr:@ts-morph/common" + ] + }, + "@ts-morph/common@0.27.0": { + "integrity": "c7b73592d78ce8479b356fd4f3d6ec3c460d77753a8680ff196effea7a939052" + } + }, + "npm": { + "mitata@1.0.34": { + "integrity": "sha512-Mc3zrtNBKIMeHSCQ0XqRLo1vbdIx1wvFV9c8NJAiyho6AjNfMY8bVhbS12bwciUdd1t4rj8099CH3N3NFahaUA==" + } + }, + "workspace": { + "dependencies": [ + "jsr:@libs/testing@5", + "jsr:@std/assert@^1.0.14", + "jsr:@std/async@^1.0.14", + "jsr:@std/expect@^1.0.17", + "jsr:@std/testing@^1.0.15", + "npm:mitata@^1.0.34" + ] + } +} diff --git a/events.ts b/events.ts index e6f194e..62cd6f6 100644 --- a/events.ts +++ b/events.ts @@ -3,12 +3,19 @@ */ import type { Observer, Subscription } from "./_types.ts"; -import type { SubscriptionObserver } from './observable.ts'; +import type { SubscriptionObserver } from "./observable.ts"; -import { Observable } from './observable.ts'; +import { Observable } from "./observable.ts"; import { Symbol } from "./symbol.ts"; -import { createQueue, enqueue, dequeue, isFull, clear, forEach } from './queue.ts'; // Assume path to your queue utils +import { + clear, + createQueue, + dequeue, + enqueue, + forEach, + isFull, +} from "./queue.ts"; // Assume path to your queue utils /** * A multicast event bus that extends {@link Observable}, allowing @@ -17,7 +24,6 @@ import { createQueue, enqueue, dequeue, isFull, clear, forEach } from './queue.t * * @typeParam T - The type of values emitted by this bus. * - * * - Calling {@link emit} delivers the value to all active subscribers. * - Calling {@link close} completes all subscribers and prevents further emissions. * - Implements both {@link Symbol.dispose} and {@link Symbol.asyncDispose} @@ -53,12 +59,11 @@ export class EventBus extends Observable { /** * Construct a new EventBus instance. * - * * The base {@link Observable} constructor is invoked with the subscriber * registration logic, adding and removing subscribers to the internal set. */ constructor() { - super(subscriber => { + super((subscriber) => { if (this.#closed) { subscriber.complete?.(); return; @@ -109,7 +114,6 @@ export class EventBus extends Observable { /** * Synchronous disposal method (for `using` syntax). * - * * Alias for {@link close}. */ [Symbol.dispose](): void { @@ -119,7 +123,6 @@ export class EventBus extends Observable { /** * Asynchronous disposal method. * - * * Alias for {@link close}. */ async [Symbol.asyncDispose](): Promise { @@ -138,7 +141,7 @@ export class EventBus extends Observable { * } * ``` */ -export type EventMap = {}; +export type EventMap = object; /** * The return type of {@link createEventDispatcher}. @@ -163,7 +166,7 @@ export interface EventDispatcher { */ on( name: Name, - handler: (payload: E[Name]) => void + handler: (payload: E[Name]) => void, ): Subscription; /** @@ -222,7 +225,9 @@ export interface EventDispatcher { * bus.close(); * ``` */ -export function createEventDispatcher(): EventDispatcher { +export function createEventDispatcher(): EventDispatcher< + E +> { // Internal bus carries a union of all event types and payloads const bus = new EventBus<{ type: keyof E; payload: E[keyof E] }>(); @@ -245,14 +250,14 @@ export function createEventDispatcher(): EventDispatcher */ on( name: Name, - handler: (payload: E[Name]) => void + handler: (payload: E[Name]) => void, ) { return bus.events.subscribe({ next(event) { if (event.type === name) { handler(event.payload as E[Name]); } - } + }, }); }, @@ -282,7 +287,7 @@ export function createEventDispatcher(): EventDispatcher */ close(): void { bus.close(); - } + }, }; } @@ -334,13 +339,15 @@ export interface WaitForEventOptions { */ export function waitForEvent< E extends EventMap, - K extends keyof E + K extends keyof E, >( bus: { events: Observable<{ type: keyof E; payload: E[keyof E] }> }, type: K, - { signal, throwOnClose = false }: WaitForEventOptions = {} + { signal, throwOnClose = false }: WaitForEventOptions = {}, ): Promise { - const { resolve, reject, promise } = Promise.withResolvers(); + const { resolve, reject, promise } = Promise.withResolvers< + E[K] | undefined + >(); // Immediate abort if (signal?.aborted) { @@ -348,13 +355,13 @@ export function waitForEvent< return promise; } - let subscription: Subscription | undefined; + const subscription_ref: { current?: Subscription } = {}; - subscription = bus.events.subscribe({ + subscription_ref.current = bus.events.subscribe({ next(event) { if (event.type === type) { cleanup(); - + // cast payload to the correct type resolve(event.payload as E[K]); } @@ -375,8 +382,8 @@ export function waitForEvent< }); function cleanup() { - subscription?.unsubscribe?.(); - signal?.removeEventListener?.('abort', onAbort); + subscription_ref.current?.unsubscribe?.(); + signal?.removeEventListener?.("abort", onAbort); } function onAbort() { @@ -384,12 +391,11 @@ export function waitForEvent< reject(signal!.reason); } - signal?.addEventListener?.('abort', onAbort, { once: true }); + signal?.addEventListener?.("abort", onAbort, { once: true }); return promise; } - /** * Controls when the replay buffer connects to the source Observable. * @@ -401,7 +407,7 @@ export function waitForEvent< * Choose 'eager' for system-critical events you never want to miss. * Choose 'lazy' for expensive operations that shouldn't run without consumers. */ -export type ReplayMode = 'eager' | 'lazy'; +export type ReplayMode = "eager" | "lazy"; /** * Configuration options for replay behavior. @@ -410,14 +416,14 @@ export interface ReplayOptions { /** * Maximum number of values to buffer. * When the buffer is full, the oldest value is discarded (FIFO). - * + * * @default Infinity (unlimited buffer, use with caution) */ count?: number; /** * Determines when to connect to the source Observable. - * + * * @default 'lazy' (resource-efficient, connects on-demand) */ mode?: ReplayMode; @@ -426,28 +432,28 @@ export interface ReplayOptions { /** * Adds replay capability to an Observable, multicasting values to multiple subscribers * while maintaining a buffer of recent emissions. - * + * * Without replay, each new subscriber triggers a fresh execution of the source Observable: * ```ts * const apiCall = new Observable(subscriber => { * console.log('Making expensive API call...'); * fetch('/api/data').then(response => subscriber.next(response)); * }); - * + * * apiCall.subscribe(data1 => {}); // Triggers API call #1 * apiCall.subscribe(data2 => {}); // Triggers API call #2 (duplicate!) * ``` - * + * * With replay, the source executes once and shares results: * ```ts * const sharedApi = withReplay(apiCall, { count: 1, mode: 'lazy' }); - * + * * sharedApi.subscribe(data1 => {}); // Triggers API call * sharedApi.subscribe(data2 => {}); // Gets cached result, no new call! * ``` - * + * * ## Memory Considerations - * + * * - Buffer size directly impacts memory usage: `count * sizeof(T)` * - 'eager' mode holds references even with no subscribers (potential memory leak) * - 'lazy' mode clears buffer when all subscribers disconnect (automatic cleanup) @@ -456,49 +462,49 @@ export interface ReplayOptions { * > **Note**: Infinite buffers are by default capped at 1000 items to prevent memory issues. * > The primary reason for this cap is because some runtimes such as Deno and Node.js * > litereally crash when you try to allocate Ininity-sized arrays. - * + * * ## Performance Characteristics - * + * * - Enqueue/Dequeue: O(1) constant time * - New subscriber replay: O(n) where n = buffer size * - Memory overhead: One queue + subscriber set + source subscription - * + * * ## Edge Cases & Gotchas - * + * * 1. **Late subscribers in eager mode**: May receive very old values if the source * emitted long ago and no cleanup occurred. - * + * * 2. **Infinite buffers**: Without a count limit, buffers grow indefinitely. * Always set a reasonable count for production use. - * + * * 3. **Error handling**: Errors are multicast to all subscribers but don't clear * the buffer. New subscribers still get the replay before the error. - * + * * 4. **Completion**: The source completion is multicast, but the replay buffer * remains accessible to new subscribers (they get replay + completion). - * + * * @param source The source Observable to add replay behavior to * @param options Configuration for replay behavior * @returns A new Observable with replay capability - * + * * @example * ```ts * // Lazy mode - only buffers when subscribers are present - * const shared = withReplay(expensive, { - * count: 5, + * const shared = withReplay(expensive, { + * count: 5, * mode: 'lazy' // Only run expensive when needed * }); - * + * * // Eager mode - always buffering, like a flight recorder - * const eventLog = withReplay(systemEvents, { - * count: 100, + * const eventLog = withReplay(systemEvents, { + * count: 100, * mode: 'eager' // Capture events even if no one's listening * }); * ``` */ export function withReplay( source: Observable, - { count = Infinity, mode = "lazy" }: ReplayOptions = {} + { count = Infinity, mode = "lazy" }: ReplayOptions = {}, ): Observable { // Validate inputs if (count <= 0) { @@ -514,7 +520,7 @@ export function withReplay( next(value) { // Manage buffer capacity if (count !== Infinity && isFull(buffer)) { - dequeue(buffer); // Remove oldest + dequeue(buffer); // Remove oldest } enqueue(buffer, value); @@ -532,7 +538,7 @@ export function withReplay( for (const sub of subscribers) { sub.complete(); } - } + }, }; const isEager = mode === "eager"; @@ -541,9 +547,9 @@ export function withReplay( /** * Creates the replay Observable that new subscribers will receive. */ - return new Observable(subscriber => { + return new Observable((subscriber) => { // Step 1: Replay buffered values to the new subscriber - forEach(buffer, item => subscriber.next(item)); + forEach(buffer, (item) => subscriber.next(item)); // Step 2: Add to active subscribers for future emissions subscribers.add(subscriber); @@ -561,7 +567,7 @@ export function withReplay( if (!isEager && subscribers.size === 0 && shared) { shared.unsubscribe(); shared = null; - clear(buffer); // Clear shared buffer when fully disconnected + clear(buffer); // Clear shared buffer when fully disconnected } // In eager mode, we keep the connection alive regardless }; diff --git a/helpers/_types.ts b/helpers/_types.ts index 219060e..6eab603 100644 --- a/helpers/_types.ts +++ b/helpers/_types.ts @@ -6,49 +6,69 @@ import type { Observable } from "../observable.ts"; * Type representing a stream operator function * Transforms a ReadableStream of type In to a ReadableStream of type Out */ -export type Operator = (stream: ReadableStream) => ReadableStream; +export type Operator = ( + stream: ReadableStream, +) => ReadableStream; + +/** + * Removes `ObservableError` from a type so operators can describe + * error-filtered output channels. + */ export type ExcludeError = Exclude; -// Combines Operator and SafeOperator. -// Why: Supports mixed operators. Solves pipeline flexibility. +/** + * Represents an operator slot in a pipeline where the previous operator may + * or may not have filtered `ObservableError` values out of the stream. + */ export type OperatorItem = Operator | Operator>; -// Inference Types -// Figures out the source type (Observable or Operator). -// Why: Ensures correct input type. Solves type safety in pipelines. -export type InferSourceType = - TSource extends Observable ? InferObservableType : - TSource extends OperatorItem ? InferOperatorItemOutputType : - TSource; - -// Gets the output type of an OperatorItem. -// Why: Tracks operator output. Solves pipeline type resolution. -export type InferOperatorItemOutputType> = - ReturnType extends ReadableStream ? T : never; - -// Gets the data type an Observable emits. -// Why: Extracts Observable data type. Solves type-safe data access. -export type InferObservableType> = +/** + * Infers the item type contributed by either an Observable source or an + * operator in a pipe chain. + */ +export type InferSourceType = TSource extends + Observable ? InferObservableType + : TSource extends OperatorItem + ? InferOperatorItemOutputType + : TSource; + +/** + * Extracts the chunk type emitted by an operator. + */ +export type InferOperatorItemOutputType< + TSource extends OperatorItem, +> = ReturnType extends ReadableStream ? T : never; + +/** + * Extracts the value type emitted by an Observable. + */ +export type InferObservableType> = TSource extends Observable ? R : any; -// Utility Types -// Gets the first item of a tuple. -// Why: Accesses pipeline start. Solves type extraction. -export type FirstTupleItem = TTuple[0]; +/** + * Returns the first item in a non-empty tuple. + */ +export type FirstTupleItem = + TTuple[0]; -// Gets the last item of a tuple. -// Why: Finds final operator. Solves pipeline output typing. -export type GenericLastTupleItem = - T extends [...infer _, infer L] ? L : never; +/** + * Returns the last item in any tuple shape. + */ +export type GenericLastTupleItem = T extends + [...infer _, infer L] ? L : never; -// Ensures last tuple item is an OperatorItem. -// Why: Validates pipeline end. Solves output type safety. +/** + * Returns the last tuple item only when it is a valid operator item. + */ export type LastTupleItem = - GenericLastTupleItem extends OperatorItem ? GenericLastTupleItem : never; + GenericLastTupleItem extends OperatorItem + ? GenericLastTupleItem + : never; -// Pipeline final type -// Defines Observable output based on last operator. -// Why: Sets pipeline result type. Solves type-safe output. +/** + * Computes the Observable type returned by a `pipe()` call from the source and + * its final operator. + */ export type ObservableWithPipe< TPipe extends readonly [Observable, ...OperatorItem[]], > = Observable>>; @@ -57,7 +77,7 @@ export type ObservableWithPipe< * Type representing how to handle errors in operators * - "ignore" => errors wrapped in ObservableError will be used as values * and can then be transformed as the operator sees fit - * - "pass-through" => errors automatically pass through, meaning ObservableError + * - "pass-through" => errors automatically pass through, meaning ObservableError * will not appear as a value * - "throw" => errors will cause the stream to throw and terminate * - "manual" => errors are passed to the transform function to handle manually @@ -96,7 +116,7 @@ export interface TransformFunctionOptions extends BaseTransformOptions { * How to handle errors in the stream: * - "ignore" => errors wrapped in ObservableError will be used as values * and can then be transformed as the operator sees fit - * - "pass-through" => errors automatically pass through, meaning ObservableError + * - "pass-through" => errors automatically pass through, meaning ObservableError * will not appear as a value * - "throw" => errors will cause the stream to throw and terminate * - "manual" => errors are passed to the transform function to handle manually @@ -112,7 +132,7 @@ export interface TransformFunctionOptions extends BaseTransformOptions { */ transform: ( chunk: T, - controller: TransformStreamDefaultController + controller: TransformStreamDefaultController, ) => R | undefined | void | null | Promise; /** @@ -121,7 +141,7 @@ export interface TransformFunctionOptions extends BaseTransformOptions { * @param controller - The TransformStreamDefaultController */ flush?: ( - controller: TransformStreamDefaultController + controller: TransformStreamDefaultController, ) => void | Promise; /** @@ -129,7 +149,7 @@ export interface TransformFunctionOptions extends BaseTransformOptions { * @param controller - The TransformStreamDefaultController */ start?: ( - controller: TransformStreamDefaultController + controller: TransformStreamDefaultController, ) => void | Promise; /** @@ -152,12 +172,13 @@ export type CreateOperatorOptions = /** * Options for stateful transformation logic */ -export interface StatefulTransformFunctionOptions extends BaseTransformOptions { +export interface StatefulTransformFunctionOptions + extends BaseTransformOptions { /** * How to handle errors in the stream: * - "ignore" => errors wrapped in ObservableError will be used as values * and can then be transformed as the operator sees fit - * - "pass-through" => errors automatically pass through, meaning ObservableError + * - "pass-through" => errors automatically pass through, meaning ObservableError * will not appear as a value * - "throw" => errors will cause the stream to throw and terminate * - "manual" => errors are passed to the transform function to handle manually @@ -180,7 +201,7 @@ export interface StatefulTransformFunctionOptions extends BaseTransform transform: ( chunk: T, state: S, - controller: TransformStreamDefaultController + controller: TransformStreamDefaultController, ) => void | Promise; /** @@ -191,7 +212,7 @@ export interface StatefulTransformFunctionOptions extends BaseTransform */ flush?: ( state: S, - controller: TransformStreamDefaultController + controller: TransformStreamDefaultController, ) => void | Promise; /** @@ -201,7 +222,7 @@ export interface StatefulTransformFunctionOptions extends BaseTransform */ start?: ( state: S, - controller: TransformStreamDefaultController + controller: TransformStreamDefaultController, ) => void | Promise; /** @@ -210,7 +231,7 @@ export interface StatefulTransformFunctionOptions extends BaseTransform */ cancel?: ( state: S, - reason?: unknown + reason?: unknown, ) => void | Promise; } @@ -222,7 +243,16 @@ export interface StatefulTransformFunctionOptions extends BaseTransform * @typeParam S - State type (if applicable) */ export interface TransformHandlerContext { + /** + * Human-readable operator name used in wrapped error messages. + */ operatorName?: string; + /** + * Indicates that the lifecycle handler should pass shared state through. + */ isStateful?: boolean; + /** + * Shared operator state for stateful transforms. + */ state?: S; -} \ No newline at end of file +} diff --git a/helpers/operations/combination.ts b/helpers/operations/combination.ts index bfe2fe1..5b69f0d 100644 --- a/helpers/operations/combination.ts +++ b/helpers/operations/combination.ts @@ -6,7 +6,7 @@ import type { SpecObservable } from "../../_spec.ts"; import type { ExcludeError, Operator } from "../_types.ts"; import { createStatefulOperator } from "../operators.ts"; -import { ObservableError, isObservableError } from "../../error.ts"; +import { isObservableError, ObservableError } from "../../error.ts"; import { pull } from "../../observable.ts"; /** @@ -56,7 +56,7 @@ import { pull } from "../../observable.ts"; */ export function mergeMap( project: (value: ExcludeError, index: number) => SpecObservable, - concurrent: number = Infinity + concurrent: number = Infinity, ): Operator { return createStatefulOperator( buffer: [], sourceCompleted: false, index: 0, - activeCount: 0 + activeCount: 0, }), // Process each incoming chunk @@ -96,7 +96,13 @@ export function mergeMap( innerObservable = project(value as ExcludeError, innerIndex); } catch (err) { // Forward any errors from the projection function - controller.enqueue(ObservableError.from(err, "operator:stateful:mergeMap:project", value) as R); + controller.enqueue( + ObservableError.from( + err, + "operator:stateful:mergeMap:project", + value, + ) as R, + ); return; } @@ -104,11 +110,19 @@ export function mergeMap( // Use pull to iterate asynchronously try { - for await (const innerValue of pull(innerObservable, { throwError: false })) { + for await ( + const innerValue of pull(innerObservable, { throwError: false }) + ) { controller.enqueue(innerValue as R | ObservableError); } } catch (err) { - controller.enqueue(ObservableError.from(err, "operator:stateful:mergeMap:innerObservable", value) as R); + controller.enqueue( + ObservableError.from( + err, + "operator:stateful:mergeMap:innerObservable", + value, + ) as R, + ); } finally { // Clean up after inner Observable completes state.activeSubscriptions.delete(innerIndex); @@ -155,7 +169,7 @@ export function mergeMap( state.buffer.length = 0; state.activeSubscriptions.clear(); state.activeCount = 0; - } + }, }); } @@ -209,7 +223,7 @@ export function mergeMap( * @returns An operator function that maps and concatenates values */ export function concatMap( - project: (value: ExcludeError, index: number) => SpecObservable + project: (value: ExcludeError, index: number) => SpecObservable, ): Operator { // concatMap is just mergeMap with concurrency = 1 return mergeMap(project, 1); @@ -260,7 +274,7 @@ export function concatMap( * @returns An operator function that maps and switches between values */ export function switchMap( - project: (value: ExcludeError, index: number) => SpecObservable + project: (value: ExcludeError, index: number) => SpecObservable, ): Operator { return createStatefulOperator( currentTask: null, currentTaskToken: null, sourceCompleted: false, - index: 0 + index: 0, }), // Process each incoming chunk @@ -302,7 +316,13 @@ export function switchMap( innerObservable = project(chunk as ExcludeError, state.index++); } catch (err) { // Forward any errors from the projection function - controller.enqueue(ObservableError.from(err, "operator:stateful:switchMap:project", chunk) as R); + controller.enqueue( + ObservableError.from( + err, + "operator:stateful:switchMap:project", + chunk, + ) as R, + ); return; } @@ -312,8 +332,7 @@ export function switchMap( // Subscribe to the new inner Observable const currentTaskToken = {}; - let currentTask: Promise; - currentTask = (async () => { + const currentTask: Promise = (async () => { const enqueueIfActive = (value: R | ObservableError): void => { if ( abortController.signal.aborted || @@ -330,7 +349,8 @@ export function switchMap( }; try { - const iterator = pull(innerObservable, { throwError: false })[Symbol.asyncIterator](); + const iterator = pull(innerObservable, { throwError: false }) + [Symbol.asyncIterator](); while (!abortController.signal.aborted) { const { value, done } = await iterator.next(); @@ -344,7 +364,11 @@ export function switchMap( } catch (err) { if (!abortController.signal.aborted) { enqueueIfActive( - ObservableError.from(err, "operator:stateful:switchMap:innerObservable", chunk), + ObservableError.from( + err, + "operator:stateful:switchMap:innerObservable", + chunk, + ), ); } } finally { @@ -386,6 +410,6 @@ export function switchMap( } state.currentTask = null; state.currentTaskToken = null; - } + }, }); } diff --git a/helpers/operators.ts b/helpers/operators.ts index a8c51c3..2aab8b4 100644 --- a/helpers/operators.ts +++ b/helpers/operators.ts @@ -1,19 +1,19 @@ /** * Operators are the building blocks of Observable pipelines. - * + * * If you've ever used `Array.map` or `Array.filter`, you already know the core idea: * an **operator** takes a sequence of values and transforms, filters, or combines them * into a new sequence. Operators let you build data pipelines, think of them as the * Lego bricks for working with streams of data. - * + * * Think of an operator as a function that takes a stream of values and returns a new stream, * transforming, filtering, or combining the data as it flows through. - * + * * For example, to double every number in an array: * ```ts * [1, 2, 3].map(x => x * 2); // [2, 4, 6] * ``` - * + * * With Observables, you want to do the same thing, but for values that arrive over time: * ```ts * // Double every number in a stream @@ -22,14 +22,14 @@ * controller.enqueue(chunk * 2); * } * }); - * + * * // Only allow even numbers through * const evens = createOperator({ * transform(chunk, controller) { * if (chunk % 2 === 0) controller.enqueue(chunk); * } * }); - * + * * // Use them together in a pipeline * pipe( * Observable.from([1, 2, 3, 4]), @@ -37,19 +37,19 @@ * evens * ).subscribe(console.log); // Output: 4, 8 * ``` - * + * * This module lets you build your own operators using the Web Streams API under the hood. * Why streams? Because they're fast, memory-efficient, and let you process data as it arrives, * not just after everything is loaded. This is especially useful for things like file processing, * network requests, or any situation where you want to handle data piece-by-piece. - * + * * ## Why Streams? Why Not Just Arrays? * * Arrays are great for data you already have. But what about data that arrives slowly, * or is too big to fit in memory? Think files, network responses, or user events. * That's where **streams** shine: they let you process data piece-by-piece, as it arrives, * without waiting for everything or loading it all at once. - * + * * The Web Streams API (and Node.js streams) are the standard way to do this in modern JavaScript. * But using them directly is verbose and error-prone: * ```ts @@ -69,16 +69,16 @@ * } * }); * ``` - * + * * By building operators on top of streams, you get: * - **Backpressure**: Slow consumers don't overwhelm fast producers. * - **Low memory usage**: Process data chunk-by-chunk, not all at once. * - **Composable pipelines**: Easily chain transformations. - * + * * ## Connecting Operators: Pipelines * * Operators are most powerful when you chain them together. This is called a pipeline. - * + * * It's just like chaining `map` and `filter` on arrays, but for streams: * ```ts * pipe( @@ -95,7 +95,7 @@ * }) * ).subscribe(console.log); // Output: 6 * ``` - * + * * Compare to arrays: * ```ts * [1, 2, 3, 4] @@ -103,11 +103,11 @@ * .filter(x => x % 3 === 0) * .forEach(console.log); // [2, 4, 8] * ``` - * - * Of course, no one wants to write operators from scratch every time. + * + * Of course, no one wants to write operators from scratch every time. * So we provide some core operations via basic familiar operators, * plus error handling utilities to make your pipelines robust. - * + * * Aka, `map`, `filter`, `reduce`, `batch`, `catchErrors`, `ignoreErrors`, and more. * So really the example above becomes: * ```ts @@ -117,20 +117,20 @@ * filter(x => x % 3 === 0) * ).subscribe(console.log); // Output: 2, 4, 8 * ``` - * - * The example is not ideal given arrays have functions for this already, + * + * The example is not ideal given arrays have functions for this already, * but you get the idea. It's meant more for streams of data that arrive over time. - * + * * ## Error Handling: Real-World Data is Messy - * + * * Real-world data is messy. Sometimes things go wrong aka, maybe a chunk is malformed, or a network * request fails. Our operators let you choose how to handle errors, with four modes: - * + * * - `"pass-through"` (default): Errors become special values in the stream, so you can handle them downstream. Imagine almost like bubble wrap over error since they are dangerous allowing us to make sure we don't break the flow. - * - `"ignore"`: Errors are silently skipped. The stream keeps going as if nothing happened. Imagine that we're basically just remove any errors from the stream while it's flowing (pretty stressful ngl). + * - `"ignore"`: Errors are silently skipped. The stream keeps going as if nothing happened. Imagine that we're basically just remove any errors from the stream while it's flowing (pretty stressful ngl). * - `"throw"`: The stream stops immediately on the first error. Basically start screaming bloody murder, an error has occured so everything must stop. * - `"manual"`: You handle all errors yourself. If you don't catch them, the stream will error. This is primarily for operators who have special error handling requirements. - * + * * Example: parsing JSON safely * ```ts * // Pass-through: errors become ObservableError values ( @@ -158,7 +158,7 @@ * } * }); * ``` - * + * * Compare to native TransformStream error handling: * ```ts * // Native: you must handle errors yourself @@ -172,12 +172,12 @@ * } * }); * ``` - * + * * ## Stateful Operators: Remembering Across Chunks - * + * * Sometimes you need to keep track of things as data flows through, like running totals, * buffers, or windows. Your `createStatefulOperator` lets you do this easily: - * + * * ```ts * // Running sum * const runningSum = createStatefulOperator({ @@ -187,16 +187,16 @@ * controller.enqueue(state.sum); * } * }); - * + * * pipe( * Observable.from([1, 2, 3]), * runningSum * ).subscribe(console.log); // Output: 1, 3, 6 * ``` - * + * * Native TransformStream can't do this as cleanly, you'd have to manage state outside the stream, * which gets messy, error-prone and annoying real quick. - * + * * ## Performance and Memory * * - **Hot path optimization**: The error handling logic is generated for each operator, @@ -204,41 +204,50 @@ * - **Memory safety**: Only the functions and state you need are kept alive; everything else * can be garbage collected. * - **Streams scale**: You can process gigabytes of data with minimal RAM, and your operators - * work just as well for infinite streams as for arrays (though arrays have better performance through + * work just as well for infinite streams as for arrays (though arrays have better performance through * their built-in `filter`, `map`, `forEach`, etc..., methods). - * + * * ## Summary - * + * * - Operators are like `Array.map`/`filter`, but for async streams of data. * - You can build pipelines that transform, filter, buffer, or combine data. * - Error handling is flexible and explicit. * - Streams make your code scalable and memory-efficient. * - State is easy to manage for advanced use cases. * - The helpers make working with streams as easy as working with arrays. - * + * * @module */ -import type { Operator, CreateOperatorOptions, StatefulTransformFunctionOptions, TransformFunctionOptions, TransformStreamOptions, ExcludeError, OperatorErrorMode, TransformHandlerContext } from "./_types.ts"; +import type { + CreateOperatorOptions, + ExcludeError, + Operator, + OperatorErrorMode, + StatefulTransformFunctionOptions, + TransformFunctionOptions, + TransformHandlerContext, + TransformStreamOptions, +} from "./_types.ts"; import { injectError, isTransformStreamOptions } from "./utils.ts"; -import { ObservableError, isObservableError } from "../error.ts"; +import { isObservableError, ObservableError } from "../error.ts"; /** * Creates optimized stream operators with consistent error handling - * + * * The Web Streams API's TransformStream is powerful but requires boilerplate for * error handling, lifecycle management, and memory optimization. This function * eliminates that complexity while providing four error handling strategies: - * + * * - **pass-through**: Errors become observable values in the stream (default) * - **ignore**: Silently skip errors and continue processing * - **throw**: Stop stream immediately on first error * - **manual**: No automatic error handling - you're in full control - * + * * Performance: Pre-compiles error handling logic to avoid runtime checks on every chunk. * Memory: Extracts only needed functions from options to enable garbage collection. - * + * * @example * ```ts * // Stream continues even if mapping fails for some items @@ -249,16 +258,16 @@ import { ObservableError, isObservableError } from "../error.ts"; * controller.enqueue(fn(chunk)); // If fn() throws, error gets enqueued * } * }); - * + * * // Stream stops immediately on any error * const strictMap = (fn: (x: T) => R) => createOperator({ - * name: 'strictMap', + * name: 'strictMap', * errorMode: 'throw', // Stream terminates on first error * transform(chunk, controller) { * controller.enqueue(fn(chunk)); * } * }); - * + * * // You handle all errors manually * const customMap = (fn: (x: T) => R) => createOperator({ * name: 'customMap', @@ -275,56 +284,107 @@ import { ObservableError, isObservableError } from "../error.ts"; * ``` */ // For "pass-through" error mode - output includes ObservableErrors -export function createOperator( - options: TransformFunctionOptions & { errorMode?: "pass-through" } +export function createOperator< + T, + R, + O extends R | ObservableError = R | ObservableError, +>( + options: TransformFunctionOptions & { errorMode?: "pass-through" }, ): Operator; -export function createOperator( - options: TransformStreamOptions & { errorMode?: "pass-through" } +/** + * Creates a pass-through operator from a pre-built TransformStream factory. + */ +export function createOperator< + T, + R, + O extends R | ObservableError = R | ObservableError, +>( + options: TransformStreamOptions & { errorMode?: "pass-through" }, ): Operator; -// For "ignore" error mode - no ObservableErrors in output -export function createOperator = ExcludeError>( - options: TransformFunctionOptions & { errorMode: "ignore" } +/** + * Creates an ignore-mode operator from a transform callback. + */ +export function createOperator< + T, + R, + O extends ExcludeError = ExcludeError, +>( + options: TransformFunctionOptions & { errorMode: "ignore" }, ): Operator; -export function createOperator = ExcludeError>( - options: TransformStreamOptions & { errorMode: "ignore" } +/** + * Creates an ignore-mode operator from a TransformStream factory. + */ +export function createOperator< + T, + R, + O extends ExcludeError = ExcludeError, +>( + options: TransformStreamOptions & { errorMode: "ignore" }, ): Operator; -// For "throw" error mode - no ObservableErrors in output -export function createOperator = ExcludeError>( - options: TransformFunctionOptions & { errorMode: "throw" } +/** + * Creates a throw-mode operator from a transform callback. + */ +export function createOperator< + T, + R, + O extends ExcludeError = ExcludeError, +>( + options: TransformFunctionOptions & { errorMode: "throw" }, ): Operator; -export function createOperator = ExcludeError>( - options: TransformStreamOptions & { errorMode: "throw" } +/** + * Creates a throw-mode operator from a TransformStream factory. + */ +export function createOperator< + T, + R, + O extends ExcludeError = ExcludeError, +>( + options: TransformStreamOptions & { errorMode: "throw" }, ): Operator; -// For "manual" error mode - output is entirely up to the implementation +/** + * Creates a manual-mode operator from a transform callback. + */ export function createOperator( - options: TransformFunctionOptions & { errorMode: "manual" } + options: TransformFunctionOptions & { errorMode: "manual" }, ): Operator; +/** + * Creates a manual-mode operator from a TransformStream factory. + */ export function createOperator( - options: TransformStreamOptions & { errorMode: "manual" } + options: TransformStreamOptions & { errorMode: "manual" }, ): Operator; // Default case -export function createOperator | ObservableError = R | ExcludeError | ObservableError>( - options: CreateOperatorOptions +export function createOperator< + T, + R, + O extends R | ExcludeError | ObservableError = + | R + | ExcludeError + | ObservableError, +>( + options: CreateOperatorOptions, ): Operator { // Extract operator name from options or the function name for better error reporting - const operatorName = `operator:${options.name || 'unknown'}`; - const errorMode = (options as TransformFunctionOptions)?.errorMode ?? "pass-through"; + const operatorName = `operator:${options.name || "unknown"}`; + const errorMode = (options as TransformFunctionOptions)?.errorMode ?? + "pass-through"; // Extract only what we need to avoid retaining the full options object const transform = (options as TransformFunctionOptions)?.transform; const start = (options as TransformFunctionOptions)?.start; const flush = (options as TransformFunctionOptions)?.flush; - + return (source) => { try { // Create a transform stream with the provided options - const transformStream = isTransformStreamOptions(options) ? - options.stream(options) : - new TransformStream({ + const transformStream = isTransformStreamOptions(options) + ? options.stream(options) + : new TransformStream( + { // Transform function to process each chunk transform: handleTransform(errorMode, transform, { operatorName }), @@ -335,30 +395,32 @@ export function createOperator | ObservableE flush: handleFlush(errorMode, flush, { operatorName }), }, { highWaterMark: 1 }, - { highWaterMark: 0 } + { highWaterMark: 0 }, ); - + // Pipe the source through the transform return source.pipeThrough(transformStream); } catch (err) { // If setup fails, return a stream that errors immediately - return source.pipeThrough(injectError(err, `${operatorName}:setup`, options)); + return source.pipeThrough( + injectError(err, `${operatorName}:setup`, options), + ); } }; } /** * Hot-path optimized error handling for transform functions - * + * * Problem: Transform functions are called for EVERY chunk in a stream. Doing * error mode checks and type checks on every call kills performance. - * + * * Solution: Pre-compile the error handling logic into optimized functions. * Each error mode gets its own specialized function with zero runtime overhead. - * + * * Memory optimization: Only references the specific transform function and state, * not the entire options object, enabling garbage collection of unused properties. - * + * * @example * ```ts * // Instead of this slow approach: @@ -370,7 +432,7 @@ export function createOperator | ObservableE * } * // ... more runtime checks * } - * + * * // handleTransform pre-compiles to this: * function fastIgnoreTransform(chunk, controller) { * if (isObservableError(chunk)) return; // Only one check needed @@ -379,7 +441,7 @@ export function createOperator | ObservableE * } catch (_) { return; } // Pre-compiled error handling * } * ``` - * + * * @typeParam T - Input chunk type * @typeParam O - Output chunk type * @typeParam S - State type (for stateful operators) @@ -390,97 +452,143 @@ export function createOperator | ObservableE */ export function handleTransform( errorMode: OperatorErrorMode, - transform: - TransformFunctionOptions['transform'] | - StatefulTransformFunctionOptions['transform'], - context: TransformHandlerContext = { } -): Transformer['transform'] { + transform: + | TransformFunctionOptions["transform"] + | StatefulTransformFunctionOptions["transform"], + context: TransformHandlerContext = {}, +): Transformer["transform"] { const operatorName = context.operatorName || `operator:unknown`; const isStateful = context.isStateful || false; const state = context.state; switch (errorMode) { case "pass-through": - return async function (chunk: T, controller: TransformStreamDefaultController) { + return async function ( + chunk: T, + controller: TransformStreamDefaultController, + ) { if (isObservableError(chunk)) { controller.enqueue(chunk as O); return; } - + try { if (isStateful) { // If stateful, pass the state along - return await (transform as StatefulTransformFunctionOptions['transform'])(chunk, state as S, controller); + return await (transform as StatefulTransformFunctionOptions< + T, + O, + S + >["transform"])(chunk, state as S, controller); } - await (transform as TransformFunctionOptions['transform'])(chunk, controller); + await (transform as TransformFunctionOptions["transform"])( + chunk, + controller, + ); } catch (err) { - controller.enqueue(ObservableError.from(err, operatorName, chunk) as O); + controller.enqueue( + ObservableError.from(err, operatorName, chunk) as O, + ); } }; - + case "ignore": - return async function (chunk: T, controller: TransformStreamDefaultController) { + return async function ( + chunk: T, + controller: TransformStreamDefaultController, + ) { if (isObservableError(chunk)) return; - + try { if (isStateful) { // If stateful, pass the state along - return await (transform as StatefulTransformFunctionOptions['transform'])(chunk, state as S, controller); + return await (transform as StatefulTransformFunctionOptions< + T, + O, + S + >["transform"])(chunk, state as S, controller); } - await (transform as TransformFunctionOptions['transform'])(chunk, controller); + await (transform as TransformFunctionOptions["transform"])( + chunk, + controller, + ); } catch (_) { // Silently ignore errors return; } }; - + case "throw": - return async function (chunk: T, controller: TransformStreamDefaultController) { + return async function ( + chunk: T, + controller: TransformStreamDefaultController, + ) { if (isObservableError(chunk)) { - return controller.error(ObservableError.from(chunk, operatorName, chunk)); + return controller.error( + ObservableError.from(chunk, operatorName, chunk), + ); } - + try { if (isStateful) { // If stateful, pass the state along - return await (transform as StatefulTransformFunctionOptions['transform'])(chunk, state as S, controller); + return await (transform as StatefulTransformFunctionOptions< + T, + O, + S + >["transform"])(chunk, state as S, controller); } - await (transform as TransformFunctionOptions['transform'])(chunk, controller); + await (transform as TransformFunctionOptions["transform"])( + chunk, + controller, + ); } catch (err) { - return controller.error(ObservableError.from(err, operatorName, chunk)); + return controller.error( + ObservableError.from(err, operatorName, chunk), + ); } }; - + case "manual": default: - return async function (chunk: T, controller: TransformStreamDefaultController) { + return async function ( + chunk: T, + controller: TransformStreamDefaultController, + ) { // In manual mode, user is expected to handle ALL errors // If they don't catch something, let it bubble up and error the stream if (isStateful) { // If stateful, pass the state along - return await (transform as StatefulTransformFunctionOptions['transform'])(chunk, state as S, controller); + return await (transform as StatefulTransformFunctionOptions< + T, + O, + S + >["transform"])(chunk, state as S, controller); } - await (transform as TransformFunctionOptions['transform'])(chunk, controller); + await (transform as TransformFunctionOptions["transform"])( + chunk, + controller, + ); }; } } /** * Lifecycle error handling for the start of the stream - * + * * The start() lifecycle method runs once when a TransformStream is created. * Unlike transform(), performance isn't critical here, but error handling * consistency is. This wrapper ensures start() failures are handled the * same way across all error modes. - * + * * Key difference: Start errors often indicate setup failures that should * terminate the stream immediately (unlike transform errors which might * be recoverable). - * + * * @example * ```ts * // Database connection setup that might fail @@ -488,7 +596,7 @@ export function handleTransform( * errorMode: 'pass-through', * start(controller) { * // If this throws, it becomes an ObservableError in the stream - * this.db = connectToDatabase(); + * this.db = connectToDatabase(); * }, * transform(chunk, controller) { * const result = this.db.process(chunk); @@ -496,7 +604,7 @@ export function handleTransform( * } * }); * ``` - * + * * @typeParam T - Input chunk type * @typeParam O - Output chunk type * @typeParam S - State type (for stateful operators) @@ -507,11 +615,11 @@ export function handleTransform( */ export function handleStart( errorMode: OperatorErrorMode, - start?: - TransformFunctionOptions['start'] | - StatefulTransformFunctionOptions['start'], - context: TransformHandlerContext = { } -): Transformer['start'] { + start?: + | TransformFunctionOptions["start"] + | StatefulTransformFunctionOptions["start"], + context: TransformHandlerContext = {}, +): Transformer["start"] { if (!start) return; const operatorName = context.operatorName || `operator:unknown`; @@ -522,19 +630,29 @@ export function handleStart( try { if (isStateful) { // If stateful, pass the state along - return await (start as StatefulTransformFunctionOptions['start'])!(state as S, controller); + return await (start as StatefulTransformFunctionOptions< + unknown, + O, + S + >["start"])!(state as S, controller); } - return await (start as TransformFunctionOptions['start'])!(controller); + return await (start as TransformFunctionOptions["start"])!( + controller, + ); } catch (err) { switch (errorMode) { case "ignore": controller.terminate(); break; case "throw": - return controller.error(ObservableError.from(err, `${operatorName}:start`)); + return controller.error( + ObservableError.from(err, `${operatorName}:start`), + ); case "pass-through": - controller.enqueue(ObservableError.from(err, `${operatorName}:start`) as O); + controller.enqueue( + ObservableError.from(err, `${operatorName}:start`) as O, + ); controller.terminate(); break; case "manual": @@ -547,14 +665,14 @@ export function handleStart( /** * Lifecycle error handling for stream cleanup - * + * * The flush() method runs once when the input stream ends. This is your * last chance to emit final values or clean up resources. Flush errors * should typically terminate the stream since there's no more input to process. - * + * * Common use cases: Emitting buffered data, closing file handles, * sending final aggregated results. - * + * * @example * ```ts * // Buffer that flushes remaining items on stream end @@ -577,7 +695,7 @@ export function handleStart( * } * }); * ``` - * + * * @typeParam T - Input chunk type * @typeParam O - Output chunk type * @typeParam S - State type (for stateful operators) @@ -588,33 +706,44 @@ export function handleStart( */ export function handleFlush( errorMode: OperatorErrorMode, - flush?: TransformFunctionOptions['flush'] | - StatefulTransformFunctionOptions['flush'], - context: TransformHandlerContext = { } -): Transformer['flush'] { + flush?: + | TransformFunctionOptions["flush"] + | StatefulTransformFunctionOptions["flush"], + context: TransformHandlerContext = {}, +): Transformer["flush"] { if (!flush) return; const operatorName = context.operatorName || `operator:unknown`; const isStateful = context.isStateful || false; const state = context.state; - + return async function (controller: TransformStreamDefaultController) { try { if (isStateful) { // If stateful, pass the state along - return await (flush as StatefulTransformFunctionOptions['flush'])!(state as S, controller); + return await (flush as StatefulTransformFunctionOptions< + unknown, + O, + S + >["flush"])!(state as S, controller); } - return await (flush as TransformFunctionOptions['flush'])!(controller); + return await (flush as TransformFunctionOptions["flush"])!( + controller, + ); } catch (err) { switch (errorMode) { case "ignore": controller.terminate(); break; case "throw": - return controller.error(ObservableError.from(err, `${operatorName}:flush`)); + return controller.error( + ObservableError.from(err, `${operatorName}:flush`), + ); case "pass-through": - controller.enqueue(ObservableError.from(err, `${operatorName}:flush`) as O); + controller.enqueue( + ObservableError.from(err, `${operatorName}:flush`) as O, + ); controller.terminate(); break; case "manual": @@ -627,41 +756,41 @@ export function handleFlush( /** * Creates operators that maintain state across stream chunks - * + * * Problem: Many stream operations need memory (scanning, buffering, counting, etc.) * but TransformStream doesn't provide built-in state management. - * + * * Solution: This function handles state creation, lifecycle integration, and * memory cleanup automatically. State is created once per stream and passed * to all lifecycle methods. - * + * * Memory safety: State is held in closure only for the stream's lifetime. * When the stream ends, state becomes eligible for garbage collection. - * + * * Performance: State access is direct (no lookups), and error handling * is pre-compiled just like createOperator(). - * + * * @example * ```ts * // Running average that needs to remember previous values * const runningAverage = () => createStatefulOperator({ * name: 'runningAverage', * createState: () => ({ sum: 0, count: 0 }), - * + * * transform(chunk, state, controller) { * state.sum += chunk; * state.count++; * controller.enqueue(state.sum / state.count); * }, - * + * * // State automatically cleaned up when stream ends * }); - * + * * // Time-based window that needs periodic cleanup * const timeWindow = (ms) => createStatefulOperator({ * name: 'timeWindow', * createState: () => ({ items: [], timer: null }), - * + * * start(state, controller) { * // Set up timer using state * state.timer = setInterval(() => { @@ -671,11 +800,11 @@ export function handleFlush( * } * }, ms); * }, - * + * * transform(chunk, state, controller) { * state.items.push(chunk); * }, - * + * * flush(state, controller) { * clearInterval(state.timer); // Cleanup * if (state.items.length > 0) { @@ -687,35 +816,69 @@ export function handleFlush( */ // For "pass-through" error mode - output includes ObservableErrors -export function createStatefulOperator( - options: StatefulTransformFunctionOptions & { errorMode?: "pass-through" } +export function createStatefulOperator< + T, + R, + S, + O extends R | ObservableError = R | ObservableError, +>( + options: StatefulTransformFunctionOptions & { + errorMode?: "pass-through"; + }, ): Operator; -// For "ignore" error mode - no ObservableErrors in output -export function createStatefulOperator = ExcludeError>( - options: StatefulTransformFunctionOptions & { errorMode: "ignore" } +/** + * Creates an ignore-mode stateful operator. + */ +export function createStatefulOperator< + T, + R, + S, + O extends ExcludeError = ExcludeError, +>( + options: StatefulTransformFunctionOptions & { errorMode: "ignore" }, ): Operator; -// For "throw" error mode - no ObservableErrors in output -export function createStatefulOperator = ExcludeError>( - options: StatefulTransformFunctionOptions & { errorMode: "throw" } +/** + * Creates a throw-mode stateful operator. + */ +export function createStatefulOperator< + T, + R, + S, + O extends ExcludeError = ExcludeError, +>( + options: StatefulTransformFunctionOptions & { errorMode: "throw" }, ): Operator; -// For "manual" error mode - output is entirely up to the implementation +/** + * Creates a manual-mode stateful operator. + */ export function createStatefulOperator( - options: StatefulTransformFunctionOptions & { errorMode: "manual" } + options: StatefulTransformFunctionOptions & { errorMode: "manual" }, ): Operator; // Default case -export function createStatefulOperator | ObservableError = R | ExcludeError | ObservableError>( - options: StatefulTransformFunctionOptions +export function createStatefulOperator< + T, + R, + S, + O extends R | ExcludeError | ObservableError = + | R + | ExcludeError + | ObservableError, +>( + options: StatefulTransformFunctionOptions, ): Operator { // Extract operator name from options or the function name for better error reporting - const operatorName = `operator:stateful:${options.name || 'unknown'}`; - const errorMode = (options as StatefulTransformFunctionOptions)?.errorMode ?? "pass-through"; + const operatorName = `operator:stateful:${options.name || "unknown"}`; + const errorMode = + (options as StatefulTransformFunctionOptions)?.errorMode ?? + "pass-through"; // Extract only what we need to avoid retaining the full options object - const transform = (options as StatefulTransformFunctionOptions)?.transform; + const transform = (options as StatefulTransformFunctionOptions) + ?.transform; const start = (options as StatefulTransformFunctionOptions)?.start; const flush = (options as StatefulTransformFunctionOptions)?.flush; @@ -738,7 +901,7 @@ export function createStatefulOperator | // If state creation fails, return a stream that errors immediately return source.pipeThrough( - injectError(err, `${operatorName}:create:state`, options) + injectError(err, `${operatorName}:create:state`, options), ); } @@ -746,23 +909,37 @@ export function createStatefulOperator | const transformStream = new TransformStream( { // Transform function to process each chunk - start: handleStart(errorMode, start, { operatorName, isStateful: true, state }), + start: handleStart(errorMode, start, { + operatorName, + isStateful: true, + state, + }), // Start function called when the stream is initialized - transform: handleTransform(errorMode, transform, { operatorName, isStateful: true, state }), + transform: handleTransform(errorMode, transform, { + operatorName, + isStateful: true, + state, + }), // Flush function called when the input is done - flush: handleFlush(errorMode, flush, { operatorName, isStateful: true, state }), + flush: handleFlush(errorMode, flush, { + operatorName, + isStateful: true, + state, + }), }, { highWaterMark: 1 }, - { highWaterMark: 0 } + { highWaterMark: 0 }, ); // Pipe the source through the transform return source.pipeThrough(transformStream); } catch (err) { // If setup fails, return a stream that errors immediately - return source.pipeThrough(injectError(err, `${operatorName}:setup`, options)); + return source.pipeThrough( + injectError(err, `${operatorName}:setup`, options), + ); } }; } diff --git a/helpers/pipe.ts b/helpers/pipe.ts index a26b6ab..d5b4961 100644 --- a/helpers/pipe.ts +++ b/helpers/pipe.ts @@ -13,16 +13,15 @@ import { Symbol } from "../symbol.ts"; * Pipe function with 19 overloads to handle up to 19 operators with proper typing. * Takes an Observable as input and returns an Observable as output, but uses * streams internally for efficiency. - * - * + * * This function takes an Observable as input and applies a series of operators * to transform it. It supports up to 19 operators with full type safety. - * + * * Internally, this function converts the Observable to a ReadableStream, * applies the stream operators, then converts back to an Observable. - * + * * @returns A new Observable with all transforms applied - * + * * @example * ```ts * // Basic pipeline with 3 operators @@ -32,7 +31,7 @@ import { Symbol } from "../symbol.ts"; * filterValue(x => x > 10), * takeValue(5) * ); - * + * * // Complex pipeline with many operators * const result = pipe( * sourceObservable, @@ -52,47 +51,59 @@ export function pipe( source: SpecObservable, ): Observable; -// Overload 1: Single operator +/** + * Pipes a source through an operator chain with one applied operator. + */ export function pipe( source: SpecObservable, - op1: Operator + op1: Operator, ): Observable; -// Overload 2: Two operators +/** + * Pipes a source through an operator chain with two applied operators. + */ export function pipe( source: SpecObservable, op1: Operator, - op2: Operator + op2: Operator, ): Observable; -// Overload 3: Three operators +/** + * Pipes a source through an operator chain with three applied operators. + */ export function pipe( source: SpecObservable, op1: Operator, op2: Operator, - op3: Operator + op3: Operator, ): Observable; -// Overload 4: Four operators +/** + * Pipes a source through an operator chain with four applied operators. + */ export function pipe( source: SpecObservable, op1: Operator, op2: Operator, op3: Operator, - op4: Operator + op4: Operator, ): Observable; -// Overload 5: Five operators +/** + * Pipes a source through an operator chain with five applied operators. + */ export function pipe( source: SpecObservable, op1: Operator, op2: Operator, op3: Operator, op4: Operator, - op5: Operator + op5: Operator, ): Observable; -// Overload 6: Six operators +/** + * Pipes a source through an operator chain with six applied operators. + */ export function pipe( source: SpecObservable, op1: Operator, @@ -100,10 +111,12 @@ export function pipe( op3: Operator, op4: Operator, op5: Operator, - op6: Operator + op6: Operator, ): Observable; -// Overload 7: Seven operators +/** + * Pipes a source through an operator chain with seven applied operators. + */ export function pipe( source: SpecObservable, op1: Operator, @@ -112,10 +125,12 @@ export function pipe( op4: Operator, op5: Operator, op6: Operator, - op7: Operator + op7: Operator, ): Observable; -// Overload 8: Eight operators +/** + * Pipes a source through an operator chain with eight applied operators. + */ export function pipe( source: SpecObservable, op1: Operator, @@ -125,10 +140,12 @@ export function pipe( op5: Operator, op6: Operator, op7: Operator, - op8: Operator + op8: Operator, ): Observable; -// Overload 9: Nine operators +/** + * Pipes a source through an operator chain with nine applied operators. + */ export function pipe( source: SpecObservable, op1: Operator, @@ -139,10 +156,12 @@ export function pipe( op6: Operator, op7: Operator, op8: Operator, - op9: Operator + op9: Operator, ): Observable; -// Overload 10: Ten operators +/** + * Pipes a source through an operator chain with ten applied operators. + */ export function pipe( source: SpecObservable, op1: Operator, @@ -154,10 +173,12 @@ export function pipe( op7: Operator, op8: Operator, op9: Operator, - op10: Operator + op10: Operator, ): Observable; -// Overload 11: Eleven operators +/** + * Pipes a source through an operator chain with eleven applied operators. + */ export function pipe( source: SpecObservable, op1: Operator, @@ -170,10 +191,12 @@ export function pipe( op8: Operator, op9: Operator, op10: Operator, - op11: Operator + op11: Operator, ): Observable; -// Overload 12: Twelve operators +/** + * Pipes a source through an operator chain with twelve applied operators. + */ export function pipe( source: SpecObservable, op1: Operator, @@ -187,10 +210,12 @@ export function pipe( op9: Operator, op10: Operator, op11: Operator, - op12: Operator + op12: Operator, ): Observable; -// Overload 13: Thirteen operators +/** + * Pipes a source through an operator chain with thirteen applied operators. + */ export function pipe( source: SpecObservable, op1: Operator, @@ -205,10 +230,12 @@ export function pipe( op10: Operator, op11: Operator, op12: Operator, - op13: Operator + op13: Operator, ): Observable; -// Overload 14: Fourteen operators +/** + * Pipes a source through an operator chain with fourteen applied operators. + */ export function pipe( source: SpecObservable, op1: Operator, @@ -224,10 +251,12 @@ export function pipe( op11: Operator, op12: Operator, op13: Operator, - op14: Operator + op14: Operator, ): Observable; -// Overload 15: Fifteen operators +/** + * Pipes a source through an operator chain with fifteen applied operators. + */ export function pipe( source: SpecObservable, op1: Operator, @@ -244,10 +273,12 @@ export function pipe( op12: Operator, op13: Operator, op14: Operator, - op15: Operator + op15: Operator, ): Observable; -// Overload 16: Sixteen operators +/** + * Pipes a source through an operator chain with sixteen applied operators. + */ export function pipe( source: SpecObservable, op1: Operator, @@ -265,10 +296,12 @@ export function pipe( op13: Operator, op14: Operator, op15: Operator, - op16: Operator + op16: Operator, ): Observable

; -// Overload 17: Seventeen operators +/** + * Pipes a source through an operator chain with seventeen applied operators. + */ export function pipe( source: SpecObservable, op1: Operator, @@ -287,10 +320,12 @@ export function pipe( op14: Operator, op15: Operator, op16: Operator, - op17: Operator + op17: Operator, ): Observable; -// Overload 18: Eighteen operators +/** + * Pipes a source through an operator chain with eighteen applied operators. + */ export function pipe( source: SpecObservable, op1: Operator, @@ -310,11 +345,34 @@ export function pipe( op15: Operator, op16: Operator, op17: Operator, - op18: Operator + op18: Operator, ): Observable; -// Overload 19: Nineteen operators -export function pipe( +/** + * Pipes a source through an operator chain with nineteen applied operators. + */ +export function pipe< + T, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, +>( source: SpecObservable, op1: Operator, op2: Operator, @@ -334,11 +392,32 @@ export function pipe op16: Operator, op17: Operator, op18: Operator, - op19: Operator + op19: Operator, ): Observable; // Implementation -export function pipe( +export function pipe< + T, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, +>( source: SpecObservable, op1?: Operator, op2?: Operator, @@ -358,63 +437,103 @@ export function pipe op16?: Operator, op17?: Operator, op18?: Operator, - op19?: Operator + op19?: Operator, ): Observable< - typeof op19 extends Operator ? S : - typeof op18 extends Operator ? R : - typeof op17 extends Operator ? Q : - typeof op16 extends Operator ? P : - typeof op15 extends Operator ? O : - typeof op14 extends Operator ? N : - typeof op13 extends Operator ? M : - typeof op12 extends Operator ? L : - typeof op11 extends Operator ? K : - typeof op10 extends Operator ? J : - typeof op9 extends Operator ? I : - typeof op8 extends Operator ? H : - typeof op7 extends Operator ? G : - typeof op6 extends Operator ? F : - typeof op5 extends Operator ? E : - typeof op4 extends Operator ? D : - typeof op3 extends Operator ? C : - typeof op2 extends Operator ? B : - typeof op1 extends Operator ? A : - T + typeof op19 extends Operator ? S + : typeof op18 extends Operator ? R + : typeof op17 extends Operator ? Q + : typeof op16 extends Operator ? P + : typeof op15 extends Operator ? O + : typeof op14 extends Operator ? N + : typeof op13 extends Operator ? M + : typeof op12 extends Operator ? L + : typeof op11 extends Operator ? K + : typeof op10 extends Operator ? J + : typeof op9 extends Operator ? I + : typeof op8 extends Operator ? H + : typeof op7 extends Operator ? G + : typeof op6 extends Operator ? F + : typeof op5 extends Operator ? E + : typeof op4 extends Operator ? D + : typeof op3 extends Operator ? C + : typeof op2 extends Operator ? B + : typeof op1 extends Operator ? A + : T > { // Ignore the source argument const len = arguments.length - 1; if (len === 0) return source as Observable; if (len > 19) { - throw new Error('pipe: Too many operators (maximum 19).'); + throw new Error("pipe: Too many operators (maximum 19)."); } if (typeof source[Symbol.observable] !== "function") { - throw new TypeError('pipe: source must be an Observable'); + throw new TypeError("pipe: source must be an Observable"); } return new Observable((observer) => { - let result: ReadableStream = toStream(pull(source, { throwError: false })); - - const errorPrefix = 'pipe:operator'; - if (op1) result = applyOperator(result, op1, { message: errorPrefix + `[1]` }); - if (op2) result = applyOperator(result, op2, { message: errorPrefix + `[2]` }); - if (op3) result = applyOperator(result, op3, { message: errorPrefix + `[3]` }); - if (op4) result = applyOperator(result, op4, { message: errorPrefix + `[4]` }); - if (op5) result = applyOperator(result, op5, { message: errorPrefix + `[5]` }); - if (op6) result = applyOperator(result, op6, { message: errorPrefix + `[6]` }); - if (op7) result = applyOperator(result, op7, { message: errorPrefix + `[7]` }); - if (op8) result = applyOperator(result, op8, { message: errorPrefix + `[8]` }); - if (op9) result = applyOperator(result, op9, { message: errorPrefix + `[9]` }); - if (op10) result = applyOperator(result, op10, { message: errorPrefix + `[10]` }); - if (op11) result = applyOperator(result, op11, { message: errorPrefix + `[11]` }); - if (op12) result = applyOperator(result, op12, { message: errorPrefix + `[12]` }); - if (op13) result = applyOperator(result, op13, { message: errorPrefix + `[13]` }); - if (op14) result = applyOperator(result, op14, { message: errorPrefix + `[14]` }); - if (op15) result = applyOperator(result, op15, { message: errorPrefix + `[15]` }); - if (op16) result = applyOperator(result, op16, { message: errorPrefix + `[16]` }); - if (op17) result = applyOperator(result, op17, { message: errorPrefix + `[17]` }); - if (op18) result = applyOperator(result, op18, { message: errorPrefix + `[18]` }); - if (op19) result = applyOperator(result, op19, { message: errorPrefix + `[19]` }); + let result: ReadableStream = toStream( + pull(source, { throwError: false }), + ); + + const errorPrefix = "pipe:operator"; + if (op1) { + result = applyOperator(result, op1, { message: errorPrefix + `[1]` }); + } + if (op2) { + result = applyOperator(result, op2, { message: errorPrefix + `[2]` }); + } + if (op3) { + result = applyOperator(result, op3, { message: errorPrefix + `[3]` }); + } + if (op4) { + result = applyOperator(result, op4, { message: errorPrefix + `[4]` }); + } + if (op5) { + result = applyOperator(result, op5, { message: errorPrefix + `[5]` }); + } + if (op6) { + result = applyOperator(result, op6, { message: errorPrefix + `[6]` }); + } + if (op7) { + result = applyOperator(result, op7, { message: errorPrefix + `[7]` }); + } + if (op8) { + result = applyOperator(result, op8, { message: errorPrefix + `[8]` }); + } + if (op9) { + result = applyOperator(result, op9, { message: errorPrefix + `[9]` }); + } + if (op10) { + result = applyOperator(result, op10, { message: errorPrefix + `[10]` }); + } + if (op11) { + result = applyOperator(result, op11, { message: errorPrefix + `[11]` }); + } + if (op12) { + result = applyOperator(result, op12, { message: errorPrefix + `[12]` }); + } + if (op13) { + result = applyOperator(result, op13, { message: errorPrefix + `[13]` }); + } + if (op14) { + result = applyOperator(result, op14, { message: errorPrefix + `[14]` }); + } + if (op15) { + result = applyOperator(result, op15, { message: errorPrefix + `[15]` }); + } + if (op16) { + result = applyOperator(result, op16, { message: errorPrefix + `[16]` }); + } + if (op17) { + result = applyOperator(result, op17, { message: errorPrefix + `[17]` }); + } + if (op18) { + result = applyOperator(result, op18, { message: errorPrefix + `[18]` }); + } + if (op19) { + result = applyOperator(result, op19, { message: errorPrefix + `[19]` }); + } const reader = result.getReader(); let cancelled = false; @@ -465,25 +584,25 @@ export function pipe void reader.cancel(); }; }) as Observable< - typeof op19 extends Operator ? S : - typeof op18 extends Operator ? R : - typeof op17 extends Operator ? Q : - typeof op16 extends Operator ? P : - typeof op15 extends Operator ? O : - typeof op14 extends Operator ? N : - typeof op13 extends Operator ? M : - typeof op12 extends Operator ? L : - typeof op11 extends Operator ? K : - typeof op10 extends Operator ? J : - typeof op9 extends Operator ? I : - typeof op8 extends Operator ? H : - typeof op7 extends Operator ? G : - typeof op6 extends Operator ? F : - typeof op5 extends Operator ? E : - typeof op4 extends Operator ? D : - typeof op3 extends Operator ? C : - typeof op2 extends Operator ? B : - typeof op1 extends Operator ? A : - T + typeof op19 extends Operator ? S + : typeof op18 extends Operator ? R + : typeof op17 extends Operator ? Q + : typeof op16 extends Operator ? P + : typeof op15 extends Operator ? O + : typeof op14 extends Operator ? N + : typeof op13 extends Operator ? M + : typeof op12 extends Operator ? L + : typeof op11 extends Operator ? K + : typeof op10 extends Operator ? J + : typeof op9 extends Operator ? I + : typeof op8 extends Operator ? H + : typeof op7 extends Operator ? G + : typeof op6 extends Operator ? F + : typeof op5 extends Operator ? E + : typeof op4 extends Operator ? D + : typeof op3 extends Operator ? C + : typeof op2 extends Operator ? B + : typeof op1 extends Operator ? A + : T >; } diff --git a/observable.ts b/observable.ts index 2c99401..c1d60be 100644 --- a/observable.ts +++ b/observable.ts @@ -74,7 +74,7 @@ * return () => ws.close(); * }); * ``` - * + * * @example Basic subscription: * ```ts * import { Observable } from './observable.ts'; @@ -165,7 +165,7 @@ * * // Observable → async iterator (back‑pressure aware) * for await (const chunk of obs) { - * … + * processChunk(chunk); * } * * // Observable → Promise (first value only) @@ -229,7 +229,6 @@ * obs.subscribe(v => console.log("[OBS]", v)); * ``` * - * * ## FAQ * - **Why does my network request fire twice?** Cold observables run once per * subscribe. Reuse a single subscription or share the source. @@ -240,17 +239,20 @@ * * @module */ -import type { SpecObservable, ObservableProtocol, SpecSubscription } from "./_spec.ts"; +import type { + ObservableProtocol, + SpecObservable, + SpecSubscription, +} from "./_spec.ts"; import type { Observer, Subscription } from "./_types.ts"; -import { ObservableError, assertObservableError } from "./error.ts"; +import { assertObservableError, ObservableError } from "./error.ts"; import { Symbol } from "./symbol.ts"; /** * Teardown function returned by the *subscriber* when it needs to release * resources (DOM handlers, sockets…). * - * * A *teardown* function or object returned from the subscriber to release * resources when a subscription terminates. * @@ -259,10 +261,10 @@ import { Symbol } from "./symbol.ts"; * - `{ [Symbol.dispose](): void }` – synchronous disposable. * - `{ [Symbol.asyncDispose](): Promise }` – async disposable. * - `undefined | null` – nothing to clean up. - * - * **Timing Note**: Cleanup is captured and called **even if** `observer.error()` or + * + * **Timing Note**: Cleanup is captured and called **even if** `observer.error()` or * `observer.complete()` is called synchronously before your subscriber returns. - * + * * @example * ```ts * new Observable(observer => { @@ -271,14 +273,14 @@ import { Symbol } from "./symbol.ts"; * return () => clearInterval(timer); * }); * ``` - * + * * @example Multi-resource cleanup * ```ts * new Observable(observer => { * const timer = setInterval(tick, 1000); * const ws = new WebSocket(url); * const sub = other.subscribe(observer); - * + * * return () => { * clearInterval(timer); * ws.close(); @@ -286,7 +288,14 @@ import { Symbol } from "./symbol.ts"; * }; * }); */ -export type Teardown = (() => void) | SpecSubscription | AsyncDisposable | Disposable | null | undefined | void; +export type Teardown = + | (() => void) + | SpecSubscription + | AsyncDisposable + | Disposable + | null + | undefined + | void; /** * Internal state associated with each Subscription. @@ -311,39 +320,44 @@ export interface StateMap { /** * Central registry of subscription state. - * + * * Using a WeakMap allows us to: * 1. Associate state with subscription objects without extending them * 2. Let the garbage collector automatically clean up entries when subscriptions are no longer referenced * 3. Hide implementation details from users */ -export const SubscriptionStateMap: WeakMap> = new WeakMap(); +export const SubscriptionStateMap: WeakMap> = + new WeakMap(); /** * Creates a new Subscription object with properly initialized state. - * - * + * * We validate observer methods early, ensuring type errors are caught * at subscription time rather than during event emission. - * + * * The returned Subscription includes support for: * - Manual cancellation via `unsubscribe()` * - Automatic cleanup via `using` blocks (Symbol.dispose) * - Async cleanup contexts (Symbol.asyncDispose) - * + * * @throws TypeError if observer methods are present but not functions * @internal */ -export function createSubscription(observer: Observer, opts?: { signal?: AbortSignal } | null): Subscription { +export function createSubscription( + observer: Observer, + opts?: { signal?: AbortSignal } | null, +): Subscription { // Observer's methods should be functions if they exist - if (observer.next !== undefined && typeof observer.next !== 'function') { - throw new TypeError('Observer.next must be a function'); + if (observer.next !== undefined && typeof observer.next !== "function") { + throw new TypeError("Observer.next must be a function"); } - if (observer.error !== undefined && typeof observer.error !== 'function') { - throw new TypeError('Observer.error must be a function'); + if (observer.error !== undefined && typeof observer.error !== "function") { + throw new TypeError("Observer.error must be a function"); } - if (observer.complete !== undefined && typeof observer.complete !== 'function') { - throw new TypeError('Observer.complete must be a function'); + if ( + observer.complete !== undefined && typeof observer.complete !== "function" + ) { + throw new TypeError("Observer.complete must be a function"); } // Create a local statemap to speed up access during hot-paths @@ -352,41 +366,45 @@ export function createSubscription(observer: Observer, opts?: { signal?: A observer, cleanup: null, removeAbortHandler: null, - } + }; /* ------------------------------------------------------------------- * Create the Subscription facade (spec: CreateSubscription()). * ------------------------------------------------------------------- */ const subscription: Subscription = { - get [Symbol.toStringTag](): "Subscription" { return "Subscription" as const; }, + get [Symbol.toStringTag](): "Subscription" { + return "Subscription" as const; + }, /** * Returns whether this subscription is closed. - * - * + * * A subscription becomes closed after: * - Explicit call to unsubscribe() * - Error notification * - Complete notification - * + * * Once closed, no further events will be delivered to the observer, * and resources associated with the subscription are released. */ - get closed() { return stateMap.closed }, + get closed() { + return stateMap.closed; + }, /** * Cancels the subscription and releases resources. - * - * + * * - Safe to call multiple times (idempotent) * - Synchronously performs cleanup * - Marks subscription as closed * - Prevents further observer notifications - * + * * This is the primary method for consumers to explicitly * terminate a subscription when they no longer need it. */ - unsubscribe(): void { closeSubscription(this, stateMap); }, + unsubscribe(): void { + closeSubscription(this, stateMap); + }, // Support `using` disposal for automatic resource management [Symbol.dispose]() { @@ -396,12 +414,13 @@ export function createSubscription(observer: Observer, opts?: { signal?: A // Support async disposal patterns [Symbol.asyncDispose]() { return Promise.resolve(this.unsubscribe()); - } + }, }; // Adds support for unsubscribing via AbortSignals const abortHandler = () => subscription?.unsubscribe(); - const removeAbortHandler = () => opts?.signal?.removeEventListener("abort", abortHandler); + const removeAbortHandler = () => + opts?.signal?.removeEventListener("abort", abortHandler); opts?.signal?.addEventListener?.("abort", abortHandler, { once: true }); stateMap.removeAbortHandler = removeAbortHandler; @@ -414,19 +433,37 @@ export function createSubscription(observer: Observer, opts?: { signal?: A * Mark subscription as closed and return observer reference. * Does NOT perform cleanup - that happens later. */ -export function markSubscriptionClosed(state: StateMap | undefined | null, returnObserver: true): Observer | null; -export function markSubscriptionClosed(state: StateMap | undefined | null, returnObserver: false): undefined | null; -export function markSubscriptionClosed(state: StateMap | undefined | null, returnObserver?: boolean): Observer | undefined | null; -export function markSubscriptionClosed(state: StateMap | undefined | null, returnObserver = false): Observer | null | undefined { +export function markSubscriptionClosed( + state: StateMap | undefined | null, + returnObserver: true, +): Observer | null; +/** + * Marks a subscription as closed when the caller does not need the observer. + */ +export function markSubscriptionClosed( + state: StateMap | undefined | null, + returnObserver: false, +): undefined | null; +/** + * Marks a subscription as closed and optionally returns the detached observer. + */ +export function markSubscriptionClosed( + state: StateMap | undefined | null, + returnObserver?: boolean, +): Observer | undefined | null; +export function markSubscriptionClosed( + state: StateMap | undefined | null, + returnObserver = false, +): Observer | null | undefined { if (!state || state.closed) return null; - + // Capture observer BEFORE marking closed (for spec compliance) const observer = state.observer; - + // Mark as closed (this is what SubscriptionClosed checks) state.closed = true; state.observer = null; - + // Return observer if requested (enables spec-compliant error/complete) if (returnObserver) return observer; } @@ -434,23 +471,26 @@ export function markSubscriptionClosed(state: StateMap | undefined | null, /** * Perform cleanup if available. Safe to call multiple times. */ -export function performSubscriptionCleanup(subscription: Subscription, state?: StateMap | null): void { +export function performSubscriptionCleanup( + subscription: Subscription, + state?: StateMap | null, +): void { if (!state) return; - + // Cache cleanup, abort signal and the abort handler before clearing let cleanup = state.cleanup; let removeAbortHandler = state.removeAbortHandler; - + // Only clean if we have something to clean if (!cleanup && !removeAbortHandler) return; - + // Clear references first state.cleanup = null; state.removeAbortHandler = null; - + // Remove the abort handler removeAbortHandler?.(); - + // Run teardown (existing logic preserved) try { cleanupSubscription(cleanup); @@ -463,18 +503,18 @@ export function performSubscriptionCleanup(subscription: Subscription, state?: S /** * Marks a subscription as closed and schedules necessary cleanup. - * + * * This is the centralized implementation for all subscription termination paths: * - Manual unsubscribe() * - Observer.error() * - Observer.complete() - * + * * The function ensures: * 1. Idempotency (safe to call multiple times) * 2. Cleanup happens exactly once * 3. State is properly cleared to prevent memory leaks * 4. WeakMap entry is removed to aid garbage collection - * + * * @param subscription - The subscription to close * @internal */ @@ -492,16 +532,15 @@ export function closeSubscription( /** * Handles the actual cleanup process for a subscription. - * - * + * * The spec allows three different types of cleanup values: * 1. Function: Called directly * 2. Object with unsubscribe method: unsubscribe() is called * 3. (deviate from spec) Object with Symbol.dispose/asyncDispose: dispose() is called - * + * * Any errors during cleanup are reported asynchronously to prevent * them from disrupting the unsubscribe flow. - * + * * @param cleanup - Function or object to perform cleanup * @internal */ @@ -511,18 +550,23 @@ function cleanupSubscription(cleanup: Teardown) { if (!temp) return; try { - if (typeof temp === 'function') temp(); + if (typeof temp === "function") temp(); else if (typeof temp === "object") { - if (typeof (temp as SpecSubscription).unsubscribe === 'function') + if (typeof (temp as SpecSubscription).unsubscribe === "function") { (temp as SpecSubscription).unsubscribe(); - else if (typeof (temp as AsyncDisposable)[Symbol.asyncDispose] === "function") + } else if ( + typeof (temp as AsyncDisposable)[Symbol.asyncDispose] === "function" + ) { (temp as AsyncDisposable)[Symbol.asyncDispose](); - else if (typeof (temp as Disposable)[Symbol.dispose] === "function") + } else if (typeof (temp as Disposable)[Symbol.dispose] === "function") { (temp as Disposable)[Symbol.dispose](); + } } } catch (err) { // Report cleanup errors asynchronously to avoid disrupting the unsubscribe flow - queueMicrotask(() => { throw err }); + queueMicrotask(() => { + throw err; + }); } temp = null; @@ -530,19 +574,18 @@ function cleanupSubscription(cleanup: Teardown) { /** * Wraps an observer with key guarantees required by the Observable specification. - * - * + * * SubscriptionObserver is a critical component that ensures: - * + * * 1. The observer contract is honored correctly * 2. Notifications stop after a subscription is closed * 3. Error/complete notifications properly terminate the subscription * 4. Observer methods are called with the correct `this` context * 5. Errors are properly propagated according to spec - * + * * This wrapper acts as the intermediary between the Observable producer * and the consumer-provided Observer. - * + * * @typeParam T - The type of values delivered by the parent Observable. */ export class SubscriptionObserver { @@ -553,25 +596,24 @@ export class SubscriptionObserver { #subscription?: Subscription | null = null; /** - * Returns whether this observer's subscription is closed. - * - * - * Uses the single source of truth for closed state from SubscriptionStateMap. - * This property is used by subscriber functions to check if they should - * continue delivering events. - * - * @example - * ```ts - * const timer = new Observable(observer => { - * const id = setInterval(() => { - * if (!observer.closed) { - * observer.next(Date.now()); - * } - * }, 1000); - * return () => clearInterval(id); - * }); - * ``` - */ + * Returns whether this observer's subscription is closed. + * + * Uses the single source of truth for closed state from SubscriptionStateMap. + * This property is used by subscriber functions to check if they should + * continue delivering events. + * + * @example + * ```ts + * const timer = new Observable(observer => { + * const id = setInterval(() => { + * if (!observer.closed) { + * observer.next(Date.now()); + * } + * }, 1000); + * return () => clearInterval(id); + * }); + * ``` + */ get closed(): boolean { const state = this.#state; if (!state) return true; @@ -580,7 +622,7 @@ export class SubscriptionObserver { /** * Creates a new SubscriptionObserver attached to the given subscription. - * + * * @param subscription - The subscription that created this observer */ constructor(subscription?: Subscription | null) { @@ -588,42 +630,41 @@ export class SubscriptionObserver { if (subscription) { this.#state = SubscriptionStateMap.get(subscription); - if (!this.#state) throw new Error('Subscription state not found'); + if (!this.#state) throw new Error("Subscription state not found"); } } /** * Delivers the next value to the observer if the subscription is open. - * - * + * * This is typically the "hot path" in an Observable implementation, * as it's called for every emitted value. Key behaviors: - * + * * 1. Silently returns if subscription is closed (no errors) * 2. Properly preserves observer's `this` context * 3. Catches and handles errors thrown from observer.next * 4. Forwards errors to observer.error when available - * + * * Performance Considerations: * - Minimizes property access chains * - Early returns for closed subscriptions * - Type checking to avoid calling non-functions - * + * * @param value - The value to deliver to the observer - * + * * @example * ```ts * // Inside a subscriber function: * observer.next(42); // Delivers value to consumer * ``` - * - * > Note: Error-propagation policy + * + * > Note: Error-propagation policy * > ───────────────────────────── * > * If the *observer supplies its own `error()` handler*, * > that handler is considered the “catch-block” for the stream. * > ↳ Any exception that happens *inside* the user’s `next()` / * > `complete()` callbacks is forwarded to `error(err)` **once**. - * > ↳ If `error()` itself throws, we still delegate to `HostReportErrors` (≈ “unhandled-promise rejection”) + * > ↳ If `error()` itself throws, we still delegate to `HostReportErrors` (≈ “unhandled-promise rejection”) * > (i.e. `queueMicrotask`), exactly as the proposal specifies. * > * > * If the observer does **not** implement `error()`, we fall back to the @@ -647,45 +688,49 @@ export class SubscriptionObserver { if (!observer) return; const nextFn = observer.next; - if (typeof nextFn !== 'function') return; + if (typeof nextFn !== "function") return; - try { nextFn.call(observer, value); } - catch (err) { + try { + nextFn.call(observer, value); + } catch (err) { const errorFn = observer.error; if (typeof errorFn === "function") { - try { errorFn.call(observer, err); } - catch (err) { queueMicrotask(() => { throw err; }); } - } - - // Either a user callback or HostReportErrors emulation (queueMicrotask). - else queueMicrotask(() => { throw err; }); + try { + errorFn.call(observer, err); + } catch (err) { + queueMicrotask(() => { + throw err; + }); + } + } // Either a user callback or HostReportErrors emulation (queueMicrotask). + else {queueMicrotask(() => { + throw err; + });} } } /** * Delivers an error notification to the observer, then closes the subscription. - * - * + * * Error is a terminal operation - after calling it: * 1. The subscription is immediately marked as closed * 2. Resources are released via unsubscribe() * 3. No further notifications will be delivered - * + * * Error Handling: * - If observer.error exists, the error is delivered there * - If observer.error throws, the error is reported asynchronously * - If no error handler exists, the error is reported asynchronously - * + * * > Note: Even for "silent" errors (no error handler), we still close * the subscription and report the error to the host. - * - * ## - * - * + * + * ## + * * @example Important Timing Consideration * When this method is called during the subscriber function execution (before it returns), - * there's a potential race condition with cleanup functions. - * + * there's a potential race condition with cleanup functions. + * * Consider: * ```ts * new Observable(observer => { @@ -693,17 +738,17 @@ export class SubscriptionObserver { * return () => cleanupResources(); // But this hasn't been returned yet! * }); * ``` - * + * * Our implementation handles this by: * 1. Marking the subscription as closed immediately * 2. Scheduling actual cleanup in a microtask to ensure the teardown function * has time to be captured and stored - * + * * This ensures resources are properly cleaned up even when error/complete * is called synchronously during subscription setup. - * + * * @param err - The error to deliver - * + * * @example * ```ts * // Inside a subscriber function: @@ -713,7 +758,7 @@ export class SubscriptionObserver { * observer.error(err); // Terminates the subscription with error * } * ``` - * + * * > Note: {@link SubscriptionObserver.next | Review the error propagation policy in `next()` on how errors propagate, the behaviour is not obvious on first glance.} */ error(err: unknown) { @@ -725,12 +770,17 @@ export class SubscriptionObserver { const errorFn = observer?.error; if (typeof errorFn === "function") { - try { errorFn.call(observer, err); } - catch (innerErr) { queueMicrotask(() => { throw innerErr; }); } - } - - // No error handler, delegate to host - else queueMicrotask(() => { throw err; }); + try { + errorFn.call(observer, err); + } catch (innerErr) { + queueMicrotask(() => { + throw innerErr; + }); + } + } // No error handler, delegate to host + else {queueMicrotask(() => { + throw err; + });} // Perform cleanup after marking closed performSubscriptionCleanup(this.#subscription!, state); @@ -741,17 +791,16 @@ export class SubscriptionObserver { /** * Signals successful completion of the observable sequence. - * - * + * * Complete is a terminal operation - after calling it: * 1. The subscription is immediately marked as closed * 2. Resources are released via unsubscribe() * 3. No further notifications will be delivered - * + * * If observer.complete throws an error: * - The error is forwarded to observer.error if available * - Otherwise, it's reported asynchronously to the host - * + * * @example * ```ts * // Inside a subscriber function: @@ -759,7 +808,7 @@ export class SubscriptionObserver { * observer.next(2); * observer.complete(); // Terminates the subscription normally * ``` - * + * * > Note: {@link SubscriptionObserver.next | Review the error propagation policy in `next()` on how errors propagate, the behaviour is not obvious on first glance.} */ complete() { @@ -772,16 +821,22 @@ export class SubscriptionObserver { const completeFn = observer?.complete; if (typeof completeFn === "function") { - try { completeFn.call(observer); } - catch (err) { + try { + completeFn.call(observer); + } catch (err) { const errorFn = observer?.error; if (typeof errorFn === "function") { - try { errorFn.call(observer, err); } - catch (innerErr) { queueMicrotask(() => { throw innerErr; }); } - } - - // Either a user callback or HostReportErrors emulation (queueMicrotask). - else queueMicrotask(() => { throw err; }); + try { + errorFn.call(observer, err); + } catch (innerErr) { + queueMicrotask(() => { + throw innerErr; + }); + } + } // Either a user callback or HostReportErrors emulation (queueMicrotask). + else {queueMicrotask(() => { + throw err; + });} } } @@ -796,20 +851,21 @@ export class SubscriptionObserver { * Returns a standard string tag for the object. * Used by Object.prototype.toString. */ - get [Symbol.toStringTag](): "Subscription Observer" { return "Subscription Observer" as const; } + get [Symbol.toStringTag](): "Subscription Observer" { + return "Subscription Observer" as const; + } } /** * Observale - A push-based stream for handling async data over time. - * - * **What it is**: Like a "smart Promise" that can emit multiple values and provides + * + * **What it is**: Like a "smart Promise" that can emit multiple values and provides * unified patterns for resource management, error handling, and subscription lifecycle. - * - * + * * Observable is the central type in this library, representing a push-based * source of values that can be subscribed to. It delivers values to observers * and provides lifecycle guarantees around subscription and cleanup. - * + * * Key guarantees: * 1. Lazy execution - nothing happens until `subscribe()` is called * 2. Multiple independent subscriptions to the same Observable @@ -819,46 +875,46 @@ export class SubscriptionObserver { * Extensions beyond the TC39 proposal: * - Pull API via AsyncIterable interface * - Using/await using support via Symbol.dispose/asyncDispose - * + * * Gotchas: * - Two subscribers → two side‑effects on a cold stream. * - Remember to cancel infinite observables. * - Calling `next()` after `complete()` is a no‑op. * - Errors in observer callbacks go to error handler if provided, else global reporting. * - Synchronous completion during subscribe still captures cleanup functions. - * + * * @typeParam T - Type of values emitted by this Observable */ -export class Observable implements AsyncIterable, SpecObservable, ObservableProtocol { +export class Observable + implements AsyncIterable, SpecObservable, ObservableProtocol { /** The subscriber function provided when the Observable was created */ #subscribeFn: (obs: SubscriptionObserver) => Teardown; /** * Creates a new Observable with the given subscriber function. - * + * * **Important**: This just stores your function - nothing executes until `subscribe()` is called. * Think of it like writing a recipe vs actually cooking. - * - * + * * The subscriber function is the heart of an Observable. It: * 1. Is called once per subscription (not at Observable creation time) * 2. Receives a SubscriptionObserver to send values through * 3. Can optionally return a cleanup function or subscription - * + * * Nothing happens when an Observable is created - execution only * begins when subscribe() is called. - * + * * @param subscribeFn - Function that implements the Observable's behavior - * + * * Your subscriber function receives a `SubscriptionObserver` to: - * - `observer.next(value)` - Emit a value + * - `observer.next(value)` - Emit a value * - `observer.error(err)` - Emit error (terminates) * - `observer.complete()` - Signal completion (terminates) * - `observer.closed` - Check if subscription is still active - * + * * @throws TypeError if subscribeFn is not a function * @throws TypeError if Observable is called without "new" - * + * * @example Timer with cleanup * ```ts * // Timer that emits the current timestamp every second @@ -867,7 +923,7 @@ export class Observable implements AsyncIterable, SpecObservable, Obser * const id = setInterval(() => { * observer.next(Date.now()); * }, 1000); - * + * * // Return cleanup function * return () => { * console.log('Cleaning up timer'); @@ -875,12 +931,12 @@ export class Observable implements AsyncIterable, SpecObservable, Obser * }; * }); * ``` - * + * * @example Async operation with error handling * ```ts * const fetch = new Observable(observer => { * const controller = new AbortController(); - * + * * fetch('/api/data', { signal: controller.signal }) * .then(res => res.json()) * .then(data => { @@ -888,19 +944,19 @@ export class Observable implements AsyncIterable, SpecObservable, Obser * observer.complete(); * }) * .catch(err => observer.error(err)); - * + * * return () => controller.abort(); // Cleanup * }); * ``` */ constructor(subscribeFn: (obs: SubscriptionObserver) => Teardown) { - if (typeof subscribeFn !== 'function') { - throw new TypeError('Observable initializer must be a function'); + if (typeof subscribeFn !== "function") { + throw new TypeError("Observable initializer must be a function"); } // Add check for constructor invocation if (!(this instanceof Observable)) { - throw new TypeError('Observable must be called with new'); + throw new TypeError("Observable must be called with new"); } this.#subscribeFn = subscribeFn; @@ -908,50 +964,50 @@ export class Observable implements AsyncIterable, SpecObservable, Obser /** * Returns this Observable (required for interoperability). - * - * + * * This method implements the TC39 Symbol.observable protocol, * which allows foreign Observable implementations to recognize * and interoperate with this implementation. - * + * * @returns This Observable instance */ - [Symbol.observable](): Observable { return this; } + [Symbol.observable](): Observable { + return this; + } /** * Subscribes to this Observable with an observer object. - * - * + * * This method creates a subscription that: * 1. Executes the subscriber function to begin producing values * 2. Delivers those values to the observer's callbacks * 3. Returns a subscription object for cancellation - * - * **What happens**: Creates subscription → calls observer.start() → executes subscriber function → + * + * **What happens**: Creates subscription → calls observer.start() → executes subscriber function → * stores cleanup → returns subscription for cancellation. - * + * * Subscription Lifecycle: * - Starts immediately and synchronously * - Continues until explicitly cancelled or completed/errored * - Guarantees proper resource cleanup on termination - * + * * **Error handling**: If you provide an error callback, it catches all stream errors. * If not, errors become unhandled Promise rejections. - * + * * **Memory warning**: Infinite Observables need manual `unsubscribe()` or `using` blocks. * If the Observable never calls `complete()` or `error()`, * resources will not be automatically released unless you call - * `unsubscribe()` manually. - * + * `unsubscribe()` manually. + * * For long-lived subscriptions, consider: * 1. Using a `using` block with this subscription * 2. Setting up a timeout or take-until condition * 3. Explicitly calling `unsubscribe()` when no longer needed - * + * * @param observer - Object with next/error/complete callbacks * @param opts.signal - Optional AbortSignal to close subscription * @returns Subscription object that can be used to cancel the subscription - * + * * @example Observer object * ```ts * const subscription = observable.subscribe({ @@ -959,11 +1015,11 @@ export class Observable implements AsyncIterable, SpecObservable, Obser * error(err) { console.error('Error:', err) }, * complete() { console.log('Done!') } * }); - * + * * // Later, to cancel: * subscription.unsubscribe(); * ``` - * + * * @example Two ways to subscribe * ```ts * // Observer object (recommended) @@ -973,57 +1029,59 @@ export class Observable implements AsyncIterable, SpecObservable, Obser * error(err) { console.error('Error:', err); }, * complete() { console.log('Done'); } * }); - * + * * // Separate functions * obs.subscribe( * val => console.log(val), - * err => console.error(err), + * err => console.error(err), * () => console.log('done') * ); * ``` */ - subscribe(observer: Observer, opts?: { signal?: AbortSignal }): Subscription; + subscribe( + observer: Observer, + opts?: { signal?: AbortSignal }, + ): Subscription; /** * Subscribes to this Observable with callback functions. - * - * + * * Convenience overload that wraps the callbacks in an Observer object. * See the documentation for the observer-based overload for details * on subscription behavior. - * + * * This method creates a subscription that: * 1. Executes the subscriber function to begin producing values * 2. Delivers those values to the observer's callbacks * 3. Returns a subscription object for cancellation - * - * **What happens**: Creates subscription → calls observer.start() → executes subscriber function → + * + * **What happens**: Creates subscription → calls observer.start() → executes subscriber function → * stores cleanup → returns subscription for cancellation. - * + * * Subscription Lifecycle: * - Starts immediately and synchronously * - Continues until explicitly cancelled or completed/errored * - Guarantees proper resource cleanup on termination - * + * * **Error handling**: If you provide an error callback, it catches all stream errors. * If not, errors become unhandled Promise rejections. - * + * * **Memory warning**: Infinite Observables need manual `unsubscribe()` or `using` blocks. * If the Observable never calls `complete()` or `error()`, * resources will not be automatically released unless you call - * `unsubscribe()` manually. - * + * `unsubscribe()` manually. + * * For long-lived subscriptions, consider: * 1. Using a `using` block with this subscription * 2. Setting up a timeout or take-until condition * 3. Explicitly calling `unsubscribe()` when no longer needed - * + * * @param next - Function to handle each emitted value * @param error - Optional function to handle errors * @param complete - Optional function to handle completion * @param opts.signal - Optional AbortSignal to close subscription * @returns Subscription object that can be used to cancel the subscription - * + * * @example * ```ts * const subscription = observable.subscribe( @@ -1032,7 +1090,7 @@ export class Observable implements AsyncIterable, SpecObservable, Obser * () => console.log('Done!') * ); * ``` - * + * * @example Two ways to subscribe * ```ts * // Observer object (recommended) @@ -1042,11 +1100,11 @@ export class Observable implements AsyncIterable, SpecObservable, Obser * error(err) { console.error('Error:', err); }, * complete() { console.log('Done'); } * }); - * + * * // Separate functions * obs.subscribe( * val => console.log(val), - * err => console.error(err), + * err => console.error(err), * () => console.log('done') * ); * ``` @@ -1065,24 +1123,33 @@ export class Observable implements AsyncIterable, SpecObservable, Obser observerOrNext: Observer | ((value: T) => void), errorOrOpts?: ((e: unknown) => void) | { signal?: AbortSignal }, complete?: () => void, - _opts?: { signal?: AbortSignal } + _opts?: { signal?: AbortSignal }, ): Subscription { // Check for invalid this context if (this === null || this === undefined) { - throw new TypeError('Cannot read property "subscribe" of null or undefined'); + throw new TypeError( + 'Cannot read property "subscribe" of null or undefined', + ); } /* ------------------------------------------------------------------- * 1. Normalise the observer – mirrors spec step 4. * ------------------------------------------------------------------- */ const observer: Observer | null = ( - typeof observerOrNext === 'function' - ? { next: observerOrNext, error: errorOrOpts as (e: unknown) => void, complete } + typeof observerOrNext === "function" + ? { + next: observerOrNext, + error: errorOrOpts as (e: unknown) => void, + complete, + } : observerOrNext - ) ?? {}; // ← spec-compliant fallback for null / primitives + ) ?? {}; // ← spec-compliant fallback for null / primitives // Additional options to pass along AbortSignal (part of the WCIG Observables Spec., thought to implement it for convinence reasons) - const opts = (typeof observerOrNext === 'function' ? _opts : errorOrOpts as typeof _opts) ?? {}; + const opts = + (typeof observerOrNext === "function" + ? _opts + : errorOrOpts as typeof _opts) ?? {}; /* ------------------------------------------------------------------- * 2. Create the Subscription facade (spec: CreateSubscription()). @@ -1094,13 +1161,12 @@ export class Observable implements AsyncIterable, SpecObservable, Obser * ------------------------------------------------------------------- */ const subObserver = new SubscriptionObserver(subscription); - /* ------------------------------------------------------------------- * 4. Call observer.start(subscription) – (spec step 10). * ------------------------------------------------------------------- */ try { observer.start?.(subscription); - if (subscription?.closed) return subscription; // spec step 10.d + if (subscription?.closed) return subscription; // spec step 10.d } catch (err) { // WarnIfAbrupt: report, but return closed subscription // Queue in a micro-task so it surfaces *after* current job, @@ -1125,35 +1191,40 @@ export class Observable implements AsyncIterable, SpecObservable, Obser // Validate the cleanup value if provided if (cleanup !== undefined && cleanup !== null) { - if (!( - typeof cleanup === 'function' || - typeof (cleanup as SpecSubscription)?.unsubscribe === 'function' || - typeof (cleanup as Disposable)?.[Symbol.dispose] === 'function' || - typeof (cleanup as AsyncDisposable)?.[Symbol.asyncDispose] === 'function' - )) { - throw new TypeError('Expected subscriber to return a function, an unsubscribe object, a disposable with a [Symbol.dispose] method, an async-disposable with a [Symbol.asyncDispose] method, or undefined/null'); + if ( + !( + typeof cleanup === "function" || + typeof (cleanup as SpecSubscription)?.unsubscribe === "function" || + typeof (cleanup as Disposable)?.[Symbol.dispose] === "function" || + typeof (cleanup as AsyncDisposable)?.[Symbol.asyncDispose] === + "function" + ) + ) { + throw new TypeError( + "Expected subscriber to return a function, an unsubscribe object, a disposable with a [Symbol.dispose] method, an async-disposable with a [Symbol.asyncDispose] method, or undefined/null", + ); } } // Store the cleanup function in the subscription state const state = SubscriptionStateMap.get(subscription); - if (state && cleanup) (state.cleanup = cleanup); + if (state && cleanup) state.cleanup = cleanup; /** * Handle the case where complete/error was called synchronously during the subscribe function. * This is a critical edge case that requires special handling - when the observer * calls `error()` or `complete()` before the subscribe function returns, we need to ensure * that any teardown function returned by the subscriber is still executed properly. - * + * * The returned teardown wouldn't have been available when `unsubscribe()` was initially * triggered by error/complete, so we need to handle it manually here. - * + * * @example * ```ts * const errorObservable = new Observable(observer => { * observer.error(new Error("test error")); // Will auto-unsubscribe (but teardown hasn't been defined yet) * log.push("after error"); // This should still run - * + * * // Teardown now defined but now the subscription has been closedn * // but resources being used haven't actually been disposed yet * return () => { @@ -1161,7 +1232,7 @@ export class Observable implements AsyncIterable, SpecObservable, Obser * }; * }); * ``` - * + * * `observer.error` fires before the teardown function is defined, so we would need to manually cleanup ourselves * by manually running the teardown function */ @@ -1181,129 +1252,137 @@ export class Observable implements AsyncIterable, SpecObservable, Obser /** * Enables `for await ... of observable` syntax for direct async iteration. - * - * + * * This method allows Observables to be used in any context that accepts an AsyncIterable, - * implementing the "pull" mode of consuming an Observable. - * + * implementing the "pull" mode of consuming an Observable. + * * Uses default buffer size of 64 items. - * + * * The implementation delegates to the `pull()` function which: * 1. Converts push-based events to pull-based async iteration * 2. Applies backpressure with ReadableStream * 3. Handles proper cleanup on early termination - * + * * @returns An AsyncIterator that yields values from this Observable - * + * * @example * ```ts * const observable = Observable.of(1, 2, 3); - * + * * // Using for-await-of directly on an Observable * for await (const value of observable) { * console.log(value); // Logs 1, 2, 3 * } * ``` */ - async *[Symbol.asyncIterator](): AsyncIterator { yield* pull(this); } + async *[Symbol.asyncIterator](): AsyncIterator { + yield* pull(this); + } /** * Converts this Observable into an AsyncGenerator with backpressure control. - * + * * **Why use this**: Control buffer size to prevent memory issues when producer is faster than consumer. * Uses ReadableStream internally for efficient buffering. - * - * + * * This method provides more control over async iteration than the default * Symbol.asyncIterator implementation, allowing consumers to: - * + * * 1. Specify a queuing strategy with a custom highWaterMark * 2. Control buffering behavior when the producer is faster than the consumer * 3. Apply backpressure to prevent memory issues with fast producers - * + * * The implementation uses ReadableStream internally to manage buffering * and backpressure, pausing the producer when the buffer fills up. - * + * * **Buffer sizing**: * - Small (1-10): Memory-constrained environments, large data items - * - Medium (10-100): Most applications, good balance + * - Medium (10-100): Most applications, good balance * - Large (100+): High-throughput scenarios, small items - * - * **Error handling**: Errors are sent through value channel (not stream errors) to ensure + * + * **Error handling**: Errors are sent through value channel (not stream errors) to ensure * all buffered values are processed before error is thrown. - * + * * @param options - Configuration options for the pull operation * @param options.strategy.highWaterMark - Max items to buffer before applying backpressure (default: 64) * @returns Async generator that yields values and slows the producer when the * buffer is full. - * + * * @example Memory-efficient processing * ```ts * // Buffer up to 5 items before applying backpressure - * for await (const value of observable.pull({ - * strategy: { highWaterMark: 5 } + * for await (const value of observable.pull({ + * strategy: { highWaterMark: 5 } * })) { * console.log(value); * // Slow consumer - producer will pause when buffer fills * await new Promise(r => setTimeout(r, 1000)); * } - * + * * // Large items, tiny buffer * for await (const item of largeDataStream.pull({ strategy: { highWaterMark: 1 } })) { * await processLargeItem(item); // Producer pauses when buffer full * } - * + * * // High throughput, large buffer * for await (const event of fastStream.pull({ strategy: { highWaterMark: 1000 } })) { * await processFast(event); * } * ``` */ - pull(opts?: Parameters[1] & { ignoreError?: true }): AsyncGenerator; - pull(opts?: Parameters[1] & { ignoreError?: false }): AsyncGenerator; - pull(opts?: Parameters[1] & { ignoreError?: boolean }): AsyncGenerator { - return pull(this, opts) + pull( + opts?: Parameters[1] & { ignoreError?: true }, + ): AsyncGenerator; + /** + * Returns pulled values while preserving `ObservableError` objects in the + * yielded value channel. + */ + pull( + opts?: Parameters[1] & { ignoreError?: false }, + ): AsyncGenerator; + pull( + opts?: Parameters[1] & { ignoreError?: boolean }, + ): AsyncGenerator { + return pull(this, opts); } /** * Converts Promise, an iterable, async iterable, or Observable-like object to an Observable. - * - * + * * This static method is a key part of the Observable interoperability mechanism, * handling multiple input types in a consistent way. - * + * * **Handles**: - * - Arrays, Sets, Maps → sync emission + * - Arrays, Sets, Maps → sync emission * - Async generators → values over time * - Symbol.observable objects → delegates to their implementation - * - * + * * Behavior depends on the input type: * 1. Objects with Symbol.observable - Delegates to their implementation * 2. Synchronous iterables - Emits all values then completes * 3. Asynchronous iterables - Emits values as they arrive then completes * 4. Promise - Emits a single value (the resovled value) then completes - * + * * Unlike Promise.resolve, Observable.from will not return the input unchanged * if it's already an Observable, unless it's an instance of the exact same * constructor. This ensures consistent behavior across different Observable * implementations. - * + * * @param input - The object to convert to an Observable * @returns A new Observable that emits values from the input - * + * * @example * ```ts * // From an array * Observable.from([1, 2, 3]).subscribe({ * next: val => console.log(val) // 1, 2, 3 * }); - * + * * // From a Promise * Observable.from(Promise.resolve("result")).subscribe({ * next: val => console.log(val) // "result" * }); - * + * * // From another Observable-like object * const foreign = { * [Symbol.observable]() { @@ -1322,19 +1401,18 @@ export class Observable implements AsyncIterable, SpecObservable, Obser /** * Creates an Observable that synchronously emits the given values then completes. - * - * + * * This is a convenience method for creating simple Observables that: * 1. Emit a fixed set of values synchronously * 2. Complete immediately after emitting all values * 3. Never error - * + * * It's the Observable equivalent of `Promise.resolve()` for single values * or `[].values()` for multiple values. - * + * * @param items - Values to emit * @returns A new Observable that emits the given values then completes - * + * * @example * ```ts * // Create and subscribe @@ -1342,7 +1420,7 @@ export class Observable implements AsyncIterable, SpecObservable, Obser * next: val => console.log(val), // Logs 1, 2, 3 * complete: () => console.log('Done!') * }); - * + * * // Output: * // 1 * // 2 @@ -1354,26 +1432,25 @@ export class Observable implements AsyncIterable, SpecObservable, Obser /** * Converts a Observable into an AsyncGenerator with backpressure control. - * - * + * * This method provides more control over async iteration than the default * Symbol.asyncIterator implementation, allowing consumers to: - * + * * 1. Specify a queuing strategy with a custom highWaterMark * 2. Control buffering behavior when the producer is faster than the consumer * 3. Apply backpressure to prevent memory issues with fast producers - * + * * The implementation uses ReadableStream internally to manage buffering * and backpressure, pausing the producer when the buffer fills up. - * + * * @param options - Configuration options for the pull operation * @returns An AsyncGenerator that yields values from this Observable - * + * * @example * ```ts * // Buffer up to 5 items before applying backpressure - * for await (const value of observable.pull({ - * strategy: { highWaterMark: 5 } + * for await (const value of observable.pull({ + * strategy: { highWaterMark: 5 } * })) { * console.log(value); * // Slow consumer - producer will pause when buffer fills @@ -1387,33 +1464,36 @@ export class Observable implements AsyncIterable, SpecObservable, Obser * Standard string tag for the object. * Used by Object.prototype.toString. */ - get [Symbol.toStringTag](): "Observable" { return "Observable"; } + get [Symbol.toStringTag](): "Observable" { + return "Observable"; + } } -/** - * Cached empty observable handler +/** + * Cached empty observable handler * @internal */ -function EMPTY(obs: SubscriptionObserver) { obs.complete(); } +function EMPTY(obs: SubscriptionObserver) { + obs.complete(); +} /** * Creates an Observable that synchronously emits the given values then completes. - * - * + * * This standalone function implements the Observable.of static method while * properly supporting subclassing. It's the Observable equivalent of: * - `Array.of()` for collections * - `Promise.resolve()` for single values - * + * * Key behaviors: * 1. Emits values synchronously when subscribed * 2. Completes immediately after all values are emitted * 3. Never errors * 4. Respects the constructor it was called on for subclassing - * + * * @param items - Values to emit * @returns A new Observable that emits the given values then completes - * + * * @example * ```ts * // Basic usage @@ -1422,18 +1502,20 @@ function EMPTY(obs: SubscriptionObserver) { obs.complete(); } * complete: () => console.log('Done!') * }); * // Output: 1, 2, 3, Done! - * + * * // Subclassing support * class MyObservable extends Observable { * // Custom methods... * } - * + * * // Creates a MyObservable instance * const mine = MyObservable.of(1, 2, 3); * ``` */ export function of(this: unknown, ...items: T[]): Observable { - const Constructor = (typeof this === "function" ? this as typeof Observable : Observable); + const Constructor = typeof this === "function" + ? this as typeof Observable + : Observable; const len = items.length; // Pre-defined handlers for common cases to avoid creating new closures @@ -1441,18 +1523,18 @@ export function of(this: unknown, ...items: T[]): Observable { case 0: return new Constructor(EMPTY); case 1: - return new Constructor(obs => { + return new Constructor((obs) => { obs.next(items[0]); obs.complete(); }); case 2: - return new Constructor(obs => { + return new Constructor((obs) => { obs.next(items[0]); obs.next(items[1]); obs.complete(); }); case 3: - return new Constructor(obs => { + return new Constructor((obs) => { obs.next(items[0]); obs.next(items[1]); obs.next(items[2]); @@ -1460,7 +1542,7 @@ export function of(this: unknown, ...items: T[]): Observable { }); default: // For arrays > 3 items, balance between code size and performance - return new Constructor(obs => { + return new Constructor((obs) => { // Based on benchmarking: // - Arrays < 100: simple loop is fine (method call dominates) // - Arrays >= 100: unrolling provides measurable benefit @@ -1497,38 +1579,37 @@ export function of(this: unknown, ...items: T[]): Observable { /** * Converts an Observable-like, sync iterable, or async iterable into an Observable. - * - * + * * This is the standalone implementation of Observable.from, supporting: * - Objects with Symbol.observable (Observable-like) * - Regular iterables (arrays, Maps, Sets, generators) * - Async iterables (async generators, ReadableStreams) - * + * * Conversion follows these rules: * 1. For Symbol.observable objects: delegates to their implementation * 2. For Promises: resolves and emits the promise's value * 3. For iterables: synchronously emits all values, then completes * 4. For async iterables: emits values as they arrive, then completes - * + * * This function properly supports subclassing, preserving the constructor * it was called on. - * + * * @throws TypeError if input is null, undefined, or not convertible - * + * * @example * ```ts * // From array * from([1, 2, 3]).subscribe(x => console.log(x)); * // Output: 1, 2, 3 - * + * * // From Promise * from(Promise.resolve('done')).subscribe(x => console.log(x)); * // Output: 'done' - * + * * // From Map * from(new Map([['a', 1], ['b', 2]])).subscribe(x => console.log(x)); * // Output: ['a', 1], ['b', 2] - * + * * // From another Observable implementation * const foreign = { * [Symbol.observable]() { @@ -1545,25 +1626,31 @@ export function of(this: unknown, ...items: T[]): Observable { */ export function from( this: unknown, - input: SpecObservable | - Iterable | AsyncIterable | PromiseLike | ArrayLike, - { throwError = true } = {} + input: + | SpecObservable + | Iterable + | AsyncIterable + | PromiseLike + | ArrayLike, + { throwError = true } = {}, ): Observable { if (input === null || input === undefined) { - throw new TypeError('Cannot convert undefined or null to Observable'); + throw new TypeError("Cannot convert undefined or null to Observable"); } - const Constructor = (typeof this === "function" ? this as typeof Observable : Observable); + const Constructor = typeof this === "function" + ? this as typeof Observable + : Observable; // Faster implementation of iteration for array-like values - const arr = (input as ArrayLike); + const arr = input as ArrayLike; if (Array.isArray(input) || typeof arr.length === "number") { const len = arr.length; // Optimize for small arrays if (len === 0) return new Constructor(EMPTY); if (len === 1) { - return new Constructor(obs => { + return new Constructor((obs) => { if (throwError) assertObservableError(arr[0], obs); obs.next(arr[0]); obs.complete(); @@ -1571,7 +1658,7 @@ export function from( } // Type check to ensure it's actually array-like - return new Constructor(obs => { + return new Constructor((obs) => { try { // Typed arrays: no bounds checking needed, direct iteration // Small arrays: simple loop with early exit checks @@ -1618,42 +1705,44 @@ export function from( if (obs.closed) return; } } - } catch (err) { + } catch (err) { obs.error(err); } - + obs.complete(); }); } // Case 1 – object with @@observable const observableFn = (input as SpecObservable)[Symbol.observable]; - if (typeof observableFn === 'function') { + if (typeof observableFn === "function") { const observable = observableFn.call(input); // Validate the result has a subscribe method - if (!observable || typeof observable.subscribe !== 'function') { - throw new TypeError('Object returned from [Symbol.observable]() does not implement subscribe method'); + if (!observable || typeof observable.subscribe !== "function") { + throw new TypeError( + "Object returned from [Symbol.observable]() does not implement subscribe method", + ); } // Return directly if it's already an instance of the target constructor if (observable instanceof Constructor) return observable as Observable; // Otherwise, wrap it to ensure consistent behavior - return new Constructor(observer => { + return new Constructor((observer) => { const sub = observable.subscribe(observer); return () => sub?.unsubscribe?.(); }); } - // Fast implementation for Set & Maps which are generally optimized + // Fast implementation for Set & Maps which are generally optimized // by the runtime when using `for..of` loops if (input instanceof Set || input instanceof Map) { - const collection = (input as Set | Map); + const collection = input as Set | Map; const size = collection.size; if (size === 0) return new Constructor(EMPTY); - return new Constructor(obs => { + return new Constructor((obs) => { // For...of is optimized for Sets in V8 for (const item of collection) { if (throwError) assertObservableError(item, obs); @@ -1667,9 +1756,9 @@ export function from( } // Case 2 – promise - const promise = (input as PromiseLike); - if (typeof promise.then === 'function') { - return new Constructor(obs => { + const promise = input as PromiseLike; + if (typeof promise.then === "function") { + return new Constructor((obs) => { promise.then( (value) => { if (throwError) assertObservableError(value, obs); @@ -1677,15 +1766,15 @@ export function from( obs.complete(); }, // Error during iteration - (err) => obs.error(err) + (err) => obs.error(err), ); }); } // Case 3 – synchronous iterable const iteratorFn = (input as Iterable)[Symbol.iterator]; - if (typeof iteratorFn === 'function') { - return new Constructor(obs => { + if (typeof iteratorFn === "function") { + return new Constructor((obs) => { const iterator = iteratorFn.call(input); try { @@ -1703,25 +1792,33 @@ export function from( } return () => { - if (typeof iterator?.return === 'function') { + if (typeof iterator?.return === "function") { try { iterator.return(); // IteratorClose - } catch (err) { queueMicrotask(() => { throw err }) } + } catch (err) { + queueMicrotask(() => { + throw err; + }); + } } - } + }; }); } // Case 4 – async iterable const asyncIteratorFn = (input as AsyncIterable)[Symbol.asyncIterator]; - if (typeof asyncIteratorFn === 'function') { - return new Constructor(obs => { + if (typeof asyncIteratorFn === "function") { + return new Constructor((obs) => { const asyncIterator = asyncIteratorFn.call(input); // Start consuming the async iterable (async () => { try { - for (let step = await asyncIterator.next(); !step.done; step = await asyncIterator.next()) { + for ( + let step = await asyncIterator.next(); + !step.done; + step = await asyncIterator.next() + ) { if (throwError) assertObservableError(step.value); obs.next(step.value); @@ -1735,71 +1832,76 @@ export function from( // Error during iteration obs.error(err); } - })() + })(); return () => { - if (typeof asyncIterator?.return === 'function') { + if (typeof asyncIterator?.return === "function") { try { asyncIterator.return(); // IteratorClose - } catch (err) { queueMicrotask(() => { throw err }) } + } catch (err) { + queueMicrotask(() => { + throw err; + }); + } } - } + }; }); } - throw new TypeError('Input is not Observable, Iterable, AsyncIterable, Promise, or ReadableStream'); + throw new TypeError( + "Input is not Observable, Iterable, AsyncIterable, Promise, or ReadableStream", + ); } /** * Converts an Observable into an AsyncGenerator with backpressure control. - * - * + * * This function bridges the gap between push-based Observables and * pull-based async iteration, allowing consumers to: * 1. Process values at their own pace * 2. Use standard async iteration patterns (for-await-of) * 3. Control buffering behavior to prevent memory issues - * + * * ## How It Works * Observable → ReadableStream (for buffering) → AsyncGenerator - * + * * **Key features**: * - Automatic backpressure when consumer slower than producer * - Configurable buffer size via highWaterMark - * - Proper cleanup on early termination + * - Proper cleanup on early termination * - Errors sent through value channel to preserve buffered items - * + * * Implementation details: * - Uses ReadableStream as the backpressure mechanism * - Connects the Observable to the stream as a source * - Returns an AsyncGenerator that yields values from the stream * - Handles proper cleanup on early termination - * + * * Instead of using ReadableStream's error mechanism, this implementation uses a special - * approach to error handling: errors are wrapped in `ObservableError` objects and + * approach to error handling: errors are wrapped in `ObservableError` objects and * sent through the normal value channel. This ensures all values emitted before an error * are properly processed in order before the error is thrown. - * + * * ## Key Benefits - * + * * 1. **Controlled Processing**: Process values at your own pace rather than being overwhelmed * 2. **Proper Backpressure**: When your consumer is slow, the producer automatically slows down * 3. **Complete Error Handling**: Errors don't cause queued values to be lost * 4. **Resource Safety**: Automatically cleans up subscriptions, even with early termination * 5. **Memory Efficiency**: Controls buffer size to prevent memory issues with fast producers * 6. **Iterator Integration**: Natural integration with other async iteration tools - * + * * @param observable - Source Observable to pull values from * @param options - Configuration options for the ReadableStream * @param options.strategy - Queuing strategy that controls how backpressure is applied * @param options.strategy.highWaterMark - Buffer size before backpressure (default: 64) - * + * * @returns An AsyncGenerator that yields values from the Observable at the consumer's pace - * + * * @example Basic usage with for-await-of loop: * ```ts * const numbers = Observable.of(1, 2, 3, 4, 5); - * + * * // Process each value at your own pace * for await (const num of pull(numbers)) { * console.log(`Processing ${num}`); @@ -1811,20 +1913,20 @@ export function from( * // Processing 3 * // Processing 4 * // Processing 5 - * + * * const fast = new Observable(obs => { * let count = 0; * const id = setInterval(() => obs.next(count++), 10); // 100/sec * return () => clearInterval(id); * }); - * + * * // Slow consumer with small buffer * for await (const n of pull(fast, { strategy: { highWaterMark: 5 } })) { * console.log(n); * await new Promise(r => setTimeout(r, 1000)); // 1/sec - producer slows down * } * ``` - * + * * @example Handling errors while ensuring all prior values are processed: * ```ts * // Observable that emits values then errors @@ -1834,7 +1936,7 @@ export function from( * observer.error(new Error("Something went wrong")); * // Even though an error occurred, both 1 and 2 will be processed * }); - * + * * try { * for await (const value of pull(source)) { * console.log(`Got value: ${value}`); @@ -1842,13 +1944,13 @@ export function from( * } catch (err) { * console.error(`Error caught: ${err.message}`); * } - * + * * // Output: * // Got value: 1 * // Got value: 2 * // Error caught: Something went wrong * ``` - * + * * @example Controlling buffer size for memory efficiency: * ```ts * // Create a producer that emits values rapidly @@ -1863,10 +1965,10 @@ export function from( * }, 1); * return () => clearInterval(interval); * }); - * + * * // Limit buffer to just 5 items to prevent memory issues - * for await (const num of pull(fastProducer, { - * strategy: { highWaterMark: 5 } + * for await (const num of pull(fastProducer, { + * strategy: { highWaterMark: 5 } * })) { * console.log(`Processing ${num}`); * // Slow consumer - producer will pause when buffer fills @@ -1875,43 +1977,62 @@ export function from( * ``` */ export function pull( - this: unknown, - observable: SpecObservable, - opts?: { strategy?: QueuingStrategy, throwError?: true }, - ): AsyncGenerator; + this: unknown, + observable: SpecObservable, + opts?: { strategy?: QueuingStrategy; throwError?: true }, +): AsyncGenerator; +/** + * Converts an Observable into an AsyncGenerator that yields both values and + * wrapped `ObservableError` objects when `throwError` is disabled. + */ export function pull( - this: unknown, - observable: SpecObservable, - opts?: { strategy?: QueuingStrategy, throwError?: false }, - ): AsyncGenerator; + this: unknown, + observable: SpecObservable, + opts?: { + strategy?: QueuingStrategy; + throwError?: false; + }, +): AsyncGenerator; export async function* pull( this: unknown, observable: SpecObservable, - { strategy = { highWaterMark: 64 }, throwError = true }: { strategy?: QueuingStrategy, throwError?: boolean } = {}, + { strategy = { highWaterMark: 64 }, throwError = true }: { + strategy?: QueuingStrategy; + throwError?: boolean; + } = {}, ): AsyncGenerator { const obs = observable?.[Symbol.observable]?.(); let sub: SpecSubscription | null = null; // Create a ReadableStream that will buffer values from the Observable const stream = new ReadableStream({ - start: ctrl => { + start: (ctrl) => { // Subscribe to the Observable and connect it to the stream sub = obs?.subscribe({ // Normal values flow directly into the stream - next: v => ctrl.enqueue(v), + next: (v) => ctrl.enqueue(v), // Errors are wrapped as special values rather than using stream.error() // This ensures values emitted before the error are still processed - error: e => { ctrl.enqueue(ObservableError.from(e, "observable:pull")); sub = null }, + error: (e) => { + ctrl.enqueue(ObservableError.from(e, "observable:pull")); + sub = null; + }, // Close the stream when the Observable completes - complete: () => { ctrl.close(); sub = null }, + complete: () => { + ctrl.close(); + sub = null; + }, }); }, // Clean up the subscription if the stream is cancelled // This happens when the AsyncGenerator is terminated early - cancel: () => { sub?.unsubscribe(); sub = null }, + cancel: () => { + sub?.unsubscribe(); + sub = null; + }, }, strategy); // Get a reader for the stream and yield values as they become available @@ -1941,47 +2062,47 @@ export async function* pull( /** * Checks if a value is an Observable instance from this library. - * - * When working with different Observable implementations or mixed data types, you often need - * to verify what kind of object you're dealing with. This function provides a reliable way to - * check if something is specifically an instance of our Observable class, which is helpful + * + * When working with different Observable implementations or mixed data types, you often need + * to verify what kind of object you're dealing with. This function provides a reliable way to + * check if something is specifically an instance of our Observable class, which is helpful * for type safety and ensuring you can use all the methods available on our implementation. - * + * * **Why This Function Exists**: - * - * In JavaScript ecosystems, you might encounter different Observable implementations - RxJS, - * this library, custom implementations, or objects that just happen to have a `subscribe` method. + * + * In JavaScript ecosystems, you might encounter different Observable implementations - RxJS, + * this library, custom implementations, or objects that just happen to have a `subscribe` method. * Without a proper way to distinguish between them, you'd have to either: * - Risk calling methods that don't exist (crashes your app) * - Write defensive code with lots of property checks (clutters your logic) * - Use duck typing that might give false positives (unreliable) - * - * This function eliminates those problems by giving you a definitive answer: "Is this an - * Observable from our library?" If yes, you know exactly what methods and properties are + * + * This function eliminates those problems by giving you a definitive answer: "Is this an + * Observable from our library?" If yes, you know exactly what methods and properties are * available. - * + * * **How It Relates to Other Checks**: - * - * Think of this as the strict cousin of `isSpecObservable()`. While `isSpecObservable()` - * asks "can I subscribe to this?" this function asks "is this specifically our Observable?" - * - * Use `isObservable()` when you need to ensure you're working with our exact implementation, + * + * Think of this as the strict cousin of `isSpecObservable()`. While `isSpecObservable()` + * asks "can I subscribe to this?" this function asks "is this specifically our Observable?" + * + * Use `isObservable()` when you need to ensure you're working with our exact implementation, * and `isSpecObservable()` when you just need something subscribable. - * + * * **Performance Story**: - * - * This function uses `instanceof`, which modern JavaScript engines optimize very well. It's - * essentially a pointer comparison under the hood, making it extremely fast and suitable for + * + * This function uses `instanceof`, which modern JavaScript engines optimize very well. It's + * essentially a pointer comparison under the hood, making it extremely fast and suitable for * use in performance-critical code paths. - * + * * The performance characteristics are: * - Single `instanceof` check (optimized by JavaScript engines) * - No method calls or property access required * - Safe to use in tight loops or frequently called functions * - Memory efficient (no allocations, just a boolean return) - * + * * **Common Ways to Use This Function**: - * + * * ```typescript * // Scenario 1: Type-safe method access * function processObservable(input: unknown) { @@ -1990,28 +2111,28 @@ export async function* pull( * const generator = input.pull({ strategy: { highWaterMark: 10 } }); * return generator; // Can safely use our specific methods * } - * + * * throw new Error('Expected an Observable from this library'); * } - * + * * // Scenario 2: Library interoperability * function convertToOurObservable(source: unknown): Observable { * if (isObservable(source)) { * return source; // Already our type, no conversion needed * } - * + * * if (isSpecObservable(source)) { * return Observable.from(source); // Convert from other implementation * } - * + * * throw new Error('Cannot convert to Observable'); * } - * + * * // Scenario 3: Filtering mixed arrays * const mixedSources = [rxjsObservable, ourObservable, promise, array]; * const ourObservables = mixedSources.filter(isObservable); * // ourObservables is now Observable[] with full type safety - * + * * // Scenario 4: Defensive programming * function subscribeToSource(source: unknown) { * if (isObservable(source)) { @@ -2025,46 +2146,46 @@ export async function* pull( * } * } * ``` - * + * * **What Makes This Function Reliable**: - * + * * Unlike duck typing (checking for the presence of methods), this function is precise: * - Returns true only for actual instances of our Observable class * - Handles inheritance correctly (subclasses return true) * - Never gives false positives from look-alike objects * - Works correctly across different module loading scenarios - * + * * **Edge Cases Handled**: * - `null` and `undefined` → false (not Observables) * - Objects with `subscribe` methods → false (unless they're actually our Observable) * - Subclasses of Observable → true (proper inheritance support) * - Cross-frame instances → true (same constructor reference) - * + * * **When to Use This vs Other Options**: - * + * * Choose `isObservable()` when: * - You need to access methods specific to our Observable implementation * - You're building type guards for strict type checking * - You need to distinguish between different Observable libraries * - You're doing performance-critical filtering of mixed object types - * + * * Choose `isSpecObservable()` instead when: * - You just need something that can be subscribed to * - You want maximum compatibility with other Observable implementations * - You're building generic utilities that work with any Observable-like object - * + * * @template T - The expected type for the Observable's emitted values * @param value - Any value that might or might not be our Observable * @returns true if the value is an instance of our Observable class, false otherwise - * + * * @example Simple type checking * ```typescript * const maybeObservable: unknown = getDataSource(); - * + * * if (isObservable(maybeObservable)) { * // TypeScript knows maybeObservable is Observable * const subscription = maybeObservable.subscribe(console.log); - * + * * // Can also use our specific methods * for await (const value of maybeObservable.pull()) { * console.log('Pulled:', value); @@ -2073,14 +2194,14 @@ export async function* pull( * console.log('Not our Observable implementation'); * } * ``` - * + * * @example Building a conversion utility * ```typescript * function ensureOurObservable(source: unknown): Observable { * if (isObservable(source)) { * return source; // Already the right type * } - * + * * if (isSpecObservable(source)) { * // Convert from another Observable implementation * return new Observable(observer => { @@ -2088,12 +2209,12 @@ export async function* pull( * return () => sub.unsubscribe(); * }); * } - * + * * // Try to convert from other types * return Observable.from(source as any); * } * ``` - * + * * @example Library integration * ```typescript * // Function that works with any Observable but optimizes for ours @@ -2110,7 +2231,9 @@ export async function* pull( * } * ``` */ -export function isObservable(value: unknown): value is Observable { +export function isObservable( + value: unknown, +): value is Observable { // This is a straightforward instanceof check // Works reliably across module boundaries and handles inheritance correctly return value instanceof Observable; @@ -2118,63 +2241,63 @@ export function isObservable(value: unknown): value is Observable(source: unknown): Promise { * if (isSpecObservable(source)) { * // Works with RxJS, our Observable, or any other spec-compliant implementation * const results: T[] = []; - * + * * return new Promise((resolve, reject) => { * source.subscribe({ * next: value => results.push(value), @@ -2183,10 +2306,10 @@ export function isObservable(value: unknown): value is Observable(source: unknown): Promise { * if (isSpecObservable(source)) { @@ -2199,13 +2322,13 @@ export function isObservable(value: unknown): value is Observable( * stream: unknown, @@ -2214,11 +2337,11 @@ export function isObservable(value: unknown): value is Observable(stream)) { * throw new TypeError('Expected an Observable-like object'); * } - * + * * const subscription = stream.subscribe({ next: handler }); * return () => subscription.unsubscribe(); * } - * + * * // Scenario 4: Filtering and type narrowing * const mixedSources: unknown[] = [ * rxjsObservable, @@ -2227,55 +2350,55 @@ export function isObservable(value: unknown): value is Observable * ``` - * + * * **What Makes This Function Robust**: - * + * * This function implements the official Observable protocol checking: * 1. Verifies the object has `Symbol.observable` method * 2. Calls that method to get the subscribable object * 3. Ensures the result has a working `subscribe` method * 4. Handles errors gracefully (returns false rather than throwing) - * + * * **Edge Cases Handled**: * - `null` and `undefined` → false (not objects) * - Objects without `Symbol.observable` → false (not Observable protocol) * - `Symbol.observable` that throws → false (graceful error handling) * - `Symbol.observable` returning non-objects → false (invalid protocol) * - Objects with `subscribe` but no `Symbol.observable` → false (incomplete protocol) - * + * * **When to Use This vs Other Options**: - * + * * Choose `isSpecObservable()` when: * - Building libraries that should work with any Observable implementation * - You need maximum compatibility across the Observable ecosystem * - You're creating utilities for consuming streams regardless of their origin * - You want to follow the official Observable specification strictly - * + * * Choose `isObservable()` instead when: * - You need methods specific to our Observable implementation * - Performance is critical and you know the expected types * - You're working within a single Observable implementation ecosystem * - You need compile-time guarantees about available methods - * + * * @template T - The expected type for values emitted by the Observable * @param value - Any value that might conform to the Observable protocol * @returns true if the value implements the Observable specification, false otherwise - * + * * @example Cross-library compatibility * ```typescript * import { Observable as RxObservable } from 'rxjs'; * import { Observable as OurObservable } from './observable.ts'; - * + * * const sources = [ * new RxObservable(sub => sub.next(1)), * new OurObservable(obs => obs.next(2)), * { subscribe() { return { unsubscribe() {} }; } } // Custom * ]; - * + * * // Process any Observable-like object * sources.forEach(source => { * if (isSpecObservable(source)) { @@ -2284,14 +2407,14 @@ export function isObservable(value: unknown): value is Observable(source: unknown): Promise { * if (!isSpecObservable(source)) { * return Promise.reject(new Error('Source must be Observable')); * } - * + * * return new Promise((resolve, reject) => { * const subscription = source.subscribe({ * next: value => { @@ -2303,31 +2426,31 @@ export function isObservable(value: unknown): value is Observable { * process(stream: unknown): AsyncGenerator; * } - * + * * class UniversalProcessor implements StreamProcessor { * async* process(stream: unknown): AsyncGenerator { * if (isSpecObservable(stream)) { * // Convert any Observable to async generator * const observable = stream[Symbol.observable](); - * + * * let resolve: (value: IteratorResult) => void; * let reject: (error: any) => void; * let promise = new Promise>((res, rej) => { * resolve = res; * reject = rej; * }); - * + * * const subscription = observable.subscribe({ * next: value => { * resolve({ value, done: false }); @@ -2339,7 +2462,7 @@ export function isObservable(value: unknown): value is Observable resolve({ value: undefined as any, done: true }) * }); - * + * * try { * while (true) { * const result = await promise; @@ -2356,16 +2479,19 @@ export function isObservable(value: unknown): value is Observable(value: unknown): value is SpecObservable { +export function isSpecObservable( + value: unknown, +): value is SpecObservable { // Early return for non-objects - if (value === null || value === undefined || typeof value !== 'object') { + if (value === null || value === undefined || typeof value !== "object") { return false; } try { // Check if the object has the Symbol.observable method - const observableMethod = (value as Record)[Symbol.observable]; - if (typeof observableMethod !== 'function') { + const observableMethod = + (value as Record)[Symbol.observable]; + if (typeof observableMethod !== "function") { return false; } @@ -2376,8 +2502,8 @@ export function isSpecObservable(value: unknown): value is SpecObse return ( subscribable !== null && subscribable !== undefined && - typeof subscribable === 'object' && - typeof (subscribable as Record).subscribe === 'function' + typeof subscribable === "object" && + typeof (subscribable as Record).subscribe === "function" ); } catch { // If any step throws, it's not a valid Observable diff --git a/scripts/build_npm.ts b/scripts/build_npm.ts index 35f885b..4eeb835 100644 --- a/scripts/build_npm.ts +++ b/scripts/build_npm.ts @@ -8,86 +8,102 @@ * Output is written to ./npm/ and is gitignored so publish artifacts never * leak back into source control. */ -import { build, emptyDir } from 'jsr:@deno/dnt'; -import { parse } from 'jsr:@std/jsonc'; +import { build, emptyDir } from "jsr:@deno/dnt"; +import { parse } from "jsr:@std/jsonc"; -const deno_config_path = new URL('../deno.jsonc', import.meta.url); +const deno_config_path = new URL("../deno.jsonc", import.meta.url); const deno_config_text = await Deno.readTextFile(deno_config_path); const deno_config = parse(deno_config_text) as Record; function readConfigString(key: string): string { - const value = deno_config[key]; + const value = deno_config[key]; - if (typeof value !== 'string') { - throw new Error(`Unable to find "${key}" in deno.jsonc.`); - } + if (typeof value !== "string") { + throw new Error(`Unable to find "${key}" in deno.jsonc.`); + } - return value; + return value; } -await emptyDir('./npm'); +await emptyDir("./npm"); await build({ - entryPoints: [ - './mod.ts', - { name: './error', path: './error.ts' }, - { name: './types', path: './_types.ts' }, - { name: './events', path: './events.ts' }, - { name: './queue', path: './queue.ts' }, - { name: './observable', path: './observable.ts' }, - { name: './operators', path: './helpers/mod.ts' }, - { name: './operations', path: './helpers/operations/mod.ts' }, - { name: './operations/batch', path: './helpers/operations/batch.ts' }, - { name: './operations/combination', path: './helpers/operations/combination.ts' }, - { name: './operations/conditional', path: './helpers/operations/conditional.ts' }, - { name: './operations/errors', path: './helpers/operations/errors.ts' }, - { name: './operations/timing', path: './helpers/operations/timing.ts' }, - { name: './operations/core', path: './helpers/operations/core.ts' }, - ], - outDir: './npm', - shims: { deno: false }, - typeCheck: 'both', + entryPoints: [ + "./mod.ts", + { name: "./error", path: "./error.ts" }, + { name: "./types", path: "./_types.ts" }, + { name: "./events", path: "./events.ts" }, + { name: "./queue", path: "./queue.ts" }, + { name: "./observable", path: "./observable.ts" }, + { name: "./operators", path: "./helpers/mod.ts" }, + { name: "./operations", path: "./helpers/operations/mod.ts" }, + { name: "./operations/batch", path: "./helpers/operations/batch.ts" }, + { + name: "./operations/combination", + path: "./helpers/operations/combination.ts", + }, + { + name: "./operations/conditional", + path: "./helpers/operations/conditional.ts", + }, + { name: "./operations/errors", path: "./helpers/operations/errors.ts" }, + { name: "./operations/timing", path: "./helpers/operations/timing.ts" }, + { name: "./operations/core", path: "./helpers/operations/core.ts" }, + ], + outDir: "./npm", + shims: { deno: false }, - // The Deno test suite imports jsr:@std/testing and other Deno-specific test - // utilities. Running those files through Node would pull Deno-only types into - // the npm build graph and fail type-checking for reasons unrelated to the - // published library surface. - test: false, + // Skip dnt's extra Node-oriented type-check pass. The Deno-native source has + // already been validated with `deno check **/*.ts`, so re-checking the + // transformed npm output here would mostly duplicate that verification while + // switching to Node-specific type resolution rules. + typeCheck: false, - package: { - name: readConfigString('name'), - version: readConfigString('version'), - description: readConfigString('description'), - license: readConfigString('license'), - keywords: [ - 'observable', - 'observables', - 'reactive', - 'streams', - 'tc39', - 'web-streams', - 'deno', - 'node', - 'bun', - ], - repository: { - type: 'git', - url: 'git+https://github.com/okikio/observables.git', - }, - bugs: { - url: 'https://github.com/okikio/observables/issues', - }, - homepage: 'https://jsr.io/@okikio/observables', + // The Deno test suite imports jsr:@std/testing and other Deno-specific test + // utilities. Running those files through Node would pull Deno-only types into + // the npm build graph for reasons unrelated to the published library surface. + test: false, - // dnt generates main/module/types/exports from the entry points above. - sideEffects: false, - engines: { - node: '>=20', - }, - }, + package: { + name: readConfigString("name"), + version: readConfigString("version"), + description: readConfigString("description"), + license: readConfigString("license"), + author: "okikio", + keywords: [ + "observable", + "observables", + "reactive", + "streams", + "tc39", + "web-streams", + "deno", + "node", + "bun", + ], + repository: { + type: "git", + url: "git+https://github.com/okikio/observables.git", + }, + bugs: { + url: "https://github.com/okikio/observables/issues", + }, + homepage: "https://github.com/okikio/observables#readme", - postBuild() { - Deno.copyFileSync('LICENSE', 'npm/LICENSE'); - Deno.copyFileSync('README.md', 'npm/README.md'); - }, + // dnt generates main, module, types, and exports from the declared entry + // points, so the remaining fields here only describe npm metadata. + sideEffects: false, + publishConfig: { + access: "public", + provenance: true, + }, + engines: { + node: ">=20", + }, + }, + + postBuild() { + Deno.copyFileSync("LICENSE", "npm/LICENSE"); + Deno.copyFileSync("README.md", "npm/README.md"); + }, }); diff --git a/tests/_utils/_assert.ts b/tests/_utils/_assert.ts index 49f240f..f72e2ae 100644 --- a/tests/_utils/_assert.ts +++ b/tests/_utils/_assert.ts @@ -1,4 +1,5 @@ -import { AssertionError } from "@std/assert/assertion-error"; +// deno-lint-ignore-file no-import-prefix +import { AssertionError } from "jsr:@std/assert@^1/assertion-error"; /** * Make an assertion that `actual` and `expected` are not equal, deeply. @@ -29,4 +30,4 @@ export function assertNotEquals(actual: T, expected: T, msg?: string) { throw new AssertionError( `Expected actual: ${actualString} not to be: ${expectedString}${msgSuffix}`, ); -} \ No newline at end of file +} diff --git a/tests/events/events_test.ts b/tests/events/events_test.ts index 2197191..def3a0c 100644 --- a/tests/events/events_test.ts +++ b/tests/events/events_test.ts @@ -1,23 +1,28 @@ /** * Comprehensive test suite for EventBus and Event Dispatcher - * + * * This test suite follows event emitter testing best practices: * - Uses expect-based assertions for cleaner syntax - * - Tests synchronous and asynchronous scenarios + * - Tests synchronous and asynchronous scenarios * - Verifies cleanup and resource management * - Tests error conditions and edge cases * - Follows AAA pattern (Arrange, Act, Assert) * - Tests timing and event ordering - * + * * @module */ +// deno-lint-ignore-file no-import-prefix import type { Subscription } from "../../_types.ts"; import type { EventMap } from "../../events.ts"; -import { createEventDispatcher, waitForEvent, withReplay } from "../../events.ts"; -import { expect, test, runtime } from "@libs/testing"; -import { spy } from "@std/testing/mock"; +import { + createEventDispatcher, + waitForEvent, + withReplay, +} from "../../events.ts"; +import { expect, runtime, test } from "@libs/testing"; +import { spy } from "jsr:@std/testing@^1/mock"; // Import the modules under test import { Observable } from "../../observable.ts"; @@ -27,876 +32,887 @@ import { EventBus } from "../../events.ts"; * Helper function to create a spy that captures event data */ function createEventSpy() { - const calls: T[] = []; - const handler = (data: T) => calls.push(data); - const spyHandler = spy(handler); - return { handler: spyHandler, spy: spyHandler, calls }; + const calls: T[] = []; + const handler = (data: T) => calls.push(data); + const spyHandler = spy(handler); + return { handler: spyHandler, spy: spyHandler, calls }; } test("EventBus - Basic event emission and subscription", () => { - // Arrange - const bus = new EventBus(); - const { handler, spy: handlerSpy, calls } = createEventSpy(); + // Arrange + const bus = new EventBus(); + const { handler, spy: handlerSpy, calls } = createEventSpy(); - // Act - Subscribe and emit - const subscription = bus.subscribe({ next: handler }); - bus.emit("hello"); - bus.emit("world"); + // Act - Subscribe and emit + const subscription = bus.subscribe({ next: handler }); + bus.emit("hello"); + bus.emit("world"); - // Assert - expect(handlerSpy.calls.length).toBe(2); - expect(handlerSpy.calls[0].args[0]).toBe("hello"); - expect(handlerSpy.calls[1].args[0]).toBe("world"); // Fixed: was calls[39] - expect(calls).toEqual(["hello", "world"]); + // Assert + expect(handlerSpy.calls.length).toBe(2); + expect(handlerSpy.calls[0].args[0]).toBe("hello"); + expect(handlerSpy.calls[1].args[0]).toBe("world"); // Fixed: was calls[39] + expect(calls).toEqual(["hello", "world"]); - // Cleanup - subscription.unsubscribe(); + // Cleanup + subscription.unsubscribe(); }); test("EventBus - Multiple subscribers receive same events", () => { - // Arrange - const bus = new EventBus(); - const subscriber1 = createEventSpy(); - const subscriber2 = createEventSpy(); + // Arrange + const bus = new EventBus(); + const subscriber1 = createEventSpy(); + const subscriber2 = createEventSpy(); - // Act - const sub1 = bus.subscribe({ next: subscriber1.handler }); - const sub2 = bus.subscribe({ next: subscriber2.handler }); + // Act + const sub1 = bus.subscribe({ next: subscriber1.handler }); + const sub2 = bus.subscribe({ next: subscriber2.handler }); - bus.emit(42); - bus.emit(99); + bus.emit(42); + bus.emit(99); - // Assert - expect(subscriber1.spy.calls.length).toBe(2); - expect(subscriber2.spy.calls.length).toBe(2); - expect(subscriber1.calls).toEqual([42, 99]); - expect(subscriber2.calls).toEqual([42, 99]); + // Assert + expect(subscriber1.spy.calls.length).toBe(2); + expect(subscriber2.spy.calls.length).toBe(2); + expect(subscriber1.calls).toEqual([42, 99]); + expect(subscriber2.calls).toEqual([42, 99]); - // Cleanup - sub1.unsubscribe(); - sub2.unsubscribe(); + // Cleanup + sub1.unsubscribe(); + sub2.unsubscribe(); }); test("EventBus - Events emitted after close are ignored", () => { - // Arrange - const bus = new EventBus(); - const { handler, spy: handlerSpy } = createEventSpy(); + // Arrange + const bus = new EventBus(); + const { handler, spy: handlerSpy } = createEventSpy(); - // Act - const subscription = bus.subscribe({ next: handler }); - bus.close(); - bus.emit("should-be-ignored"); + // Act + const subscription = bus.subscribe({ next: handler }); + bus.close(); + bus.emit("should-be-ignored"); - // Assert - expect(handlerSpy.calls.length).toBe(0); + // Assert + expect(handlerSpy.calls.length).toBe(0); - // Cleanup - subscription.unsubscribe(); + // Cleanup + subscription.unsubscribe(); }); test("EventBus - Subscribers get completion notification when bus closes", () => { - // Arrange - const bus = new EventBus(); - const completeSpy = spy(); + // Arrange + const bus = new EventBus(); + const completeSpy = spy(); - // Act - bus.subscribe({ complete: completeSpy }); - bus.close(); + // Act + bus.subscribe({ complete: completeSpy }); + bus.close(); - // Assert - expect(completeSpy.calls.length).toBe(1); + // Assert + expect(completeSpy.calls.length).toBe(1); }); test("EventBus - Late subscribers to closed bus get immediate completion", () => { - // Arrange - const bus = new EventBus(); - bus.close(); + // Arrange + const bus = new EventBus(); + bus.close(); - // Act - const completeSpy = spy(); - bus.subscribe({ complete: completeSpy }); + // Act + const completeSpy = spy(); + bus.subscribe({ complete: completeSpy }); - // Assert - Should complete immediately - expect(completeSpy.calls.length).toBe(1); + // Assert - Should complete immediately + expect(completeSpy.calls.length).toBe(1); }); test("EventBus - Resource cleanup with using blocks", () => { - // Arrange & Act - let bus: EventBus; - let subscription: Subscription; + // Arrange & Act + let bus: EventBus; + let subscription: Subscription; - { - using testBus = new EventBus(); - bus = testBus; + { + using testBus = new EventBus(); + bus = testBus; - const { handler } = createEventSpy(); - subscription = bus.subscribe({ next: handler }); + const { handler } = createEventSpy(); + subscription = bus.subscribe({ next: handler }); - bus.emit("test"); - } // Bus should be automatically disposed here + bus.emit("test"); + } // Bus should be automatically disposed here - // Assert - Bus should be closed after using block - expect(subscription.closed).toBe(true); + // Assert - Bus should be closed after using block + expect(subscription.closed).toBe(true); }); test("EventBus - Async disposal", async () => { - // Arrange - await using bus = new EventBus(); - const { handler } = createEventSpy(); + // Arrange + await using bus = new EventBus(); + const { handler } = createEventSpy(); - // Act - const subscription = bus.subscribe({ next: handler }); - bus.emit("test"); + // Act + const subscription = bus.subscribe({ next: handler }); + bus.emit("test"); - // Assert - Bus is still active - expect(subscription.closed).toBe(false); + // Assert - Bus is still active + expect(subscription.closed).toBe(false); - // After async disposal, subscription should be closed + // After async disposal, subscription should be closed }); test("EventBus - Error handling in observers", () => { - // Arrange - const bus = new EventBus(); - const errorHandler = spy(); - const nextHandler = spy(() => { - throw new Error("Handler error"); - }); + // Arrange + const bus = new EventBus(); + const errorHandler = spy(); + const nextHandler = spy(() => { + throw new Error("Handler error"); + }); - // Act - bus.subscribe({ - next: nextHandler, - error: errorHandler - }); + // Act + bus.subscribe({ + next: nextHandler, + error: errorHandler, + }); - bus.emit("trigger-error"); + bus.emit("trigger-error"); - // Assert - Error handler should be called - expect(nextHandler.calls.length).toBe(1); - expect(errorHandler.calls.length).toBe(1); + // Assert - Error handler should be called + expect(nextHandler.calls.length).toBe(1); + expect(errorHandler.calls.length).toBe(1); }); test("EventBus - Memory cleanup verification", () => { - // Arrange - const bus = new EventBus(); - const handlers = Array.from({ length: 100 }, () => createEventSpy()); + // Arrange + const bus = new EventBus(); + const handlers = Array.from({ length: 100 }, () => createEventSpy()); - // Act - Create many subscriptions - const subscriptions = handlers.map(h => bus.subscribe({ next: h.handler })); + // Act - Create many subscriptions + const subscriptions = handlers.map((h) => bus.subscribe({ next: h.handler })); - // Emit some events - bus.emit("test1"); - bus.emit("test2"); + // Emit some events + bus.emit("test1"); + bus.emit("test2"); - // Verify all received events - handlers.forEach(h => { - expect(h.spy.calls.length).toBe(2); - }); + // Verify all received events + handlers.forEach((h) => { + expect(h.spy.calls.length).toBe(2); + }); - // Cleanup all subscriptions - subscriptions.forEach(sub => sub.unsubscribe()); + // Cleanup all subscriptions + subscriptions.forEach((sub) => sub.unsubscribe()); - // Emit after cleanup - no handlers should be called - const callCountsBefore = handlers.map(h => h.spy.calls.length); - bus.emit("after-cleanup"); - const callCountsAfter = handlers.map(h => h.spy.calls.length); + // Emit after cleanup - no handlers should be called + const callCountsBefore = handlers.map((h) => h.spy.calls.length); + bus.emit("after-cleanup"); + const callCountsAfter = handlers.map((h) => h.spy.calls.length); - // Assert - No new calls after unsubscribe - expect(callCountsBefore).toEqual(callCountsAfter); + // Assert - No new calls after unsubscribe + expect(callCountsBefore).toEqual(callCountsAfter); }); test("Event Dispatcher - Type-safe event emission and handling", () => { - interface TestEvents extends EventMap { - userLogin: { userId: string; timestamp: number }; - userLogout: { userId: string }; - dataUpdate: { id: number; data: string }; - error: { code: number; message: string }; - simpleEvent: void; - } - - // Arrange - const dispatcher = createEventDispatcher(); - const loginSpy = spy(); - const logoutSpy = spy(); - - // Act - const loginSub = dispatcher.on("userLogin", loginSpy); - const logoutSub = dispatcher.on("userLogout", logoutSpy); - - dispatcher.emit("userLogin", { userId: "123", timestamp: Date.now() }); - dispatcher.emit("userLogout", { userId: "123" }); - - // Assert - expect(loginSpy.calls.length).toBe(1); - expect(logoutSpy.calls.length).toBe(1); - - // Verify correct data was passed - const loginCall = loginSpy.calls[0]; - expect(loginCall.args[0].userId).toBeDefined(); // Fixed: was loginCall.args.userId - expect(loginCall.args[0].timestamp).toBeDefined(); // Fixed: was loginCall.args.timestamp - expect(loginCall.args[0].userId).toBe("123"); // Fixed: was loginCall.args.userId - - // Cleanup - loginSub.unsubscribe(); - logoutSub.unsubscribe(); - dispatcher.close(); + interface TestEvents extends EventMap { + userLogin: { userId: string; timestamp: number }; + userLogout: { userId: string }; + dataUpdate: { id: number; data: string }; + error: { code: number; message: string }; + simpleEvent: void; + } + + // Arrange + const dispatcher = createEventDispatcher(); + const loginSpy = spy(); + const logoutSpy = spy(); + + // Act + const loginSub = dispatcher.on("userLogin", loginSpy); + const logoutSub = dispatcher.on("userLogout", logoutSpy); + + dispatcher.emit("userLogin", { userId: "123", timestamp: Date.now() }); + dispatcher.emit("userLogout", { userId: "123" }); + + // Assert + expect(loginSpy.calls.length).toBe(1); + expect(logoutSpy.calls.length).toBe(1); + + // Verify correct data was passed + const loginCall = loginSpy.calls[0]; + expect(loginCall.args[0].userId).toBeDefined(); // Fixed: was loginCall.args.userId + expect(loginCall.args[0].timestamp).toBeDefined(); // Fixed: was loginCall.args.timestamp + expect(loginCall.args[0].userId).toBe("123"); // Fixed: was loginCall.args.userId + + // Cleanup + loginSub.unsubscribe(); + logoutSub.unsubscribe(); + dispatcher.close(); }); test("Event Dispatcher - Event filtering - only matching events trigger handlers", () => { - interface TestEvents extends EventMap { - userLogin: { userId: string; timestamp: number }; - userLogout: { userId: string }; - dataUpdate: { id: number; data: string }; - error: { code: number; message: string }; - simpleEvent: void; - } + interface TestEvents extends EventMap { + userLogin: { userId: string; timestamp: number }; + userLogout: { userId: string }; + dataUpdate: { id: number; data: string }; + error: { code: number; message: string }; + simpleEvent: void; + } - // Arrange - const dispatcher = createEventDispatcher(); - const loginSpy = spy(); - const logoutSpy = spy(); + // Arrange + const dispatcher = createEventDispatcher(); + const loginSpy = spy(); + const logoutSpy = spy(); - // Act - dispatcher.on("userLogin", loginSpy); - dispatcher.on("userLogout", logoutSpy); + // Act + dispatcher.on("userLogin", loginSpy); + dispatcher.on("userLogout", logoutSpy); - // Emit different events - dispatcher.emit("userLogin", { userId: "123", timestamp: Date.now() }); - dispatcher.emit("dataUpdate", { id: 1, data: "test" }); - dispatcher.emit("userLogout", { userId: "123" }); + // Emit different events + dispatcher.emit("userLogin", { userId: "123", timestamp: Date.now() }); + dispatcher.emit("dataUpdate", { id: 1, data: "test" }); + dispatcher.emit("userLogout", { userId: "123" }); - // Assert - Each handler only called for its event type - expect(loginSpy.calls.length).toBe(1); - expect(logoutSpy.calls.length).toBe(1); + // Assert - Each handler only called for its event type + expect(loginSpy.calls.length).toBe(1); + expect(logoutSpy.calls.length).toBe(1); - dispatcher.close(); + dispatcher.close(); }); test("Event Dispatcher - Void event types", () => { - interface TestEvents extends EventMap { - userLogin: { userId: string; timestamp: number }; - userLogout: { userId: string }; - dataUpdate: { id: number; data: string }; - error: { code: number; message: string }; - simpleEvent: void; - } + interface TestEvents extends EventMap { + userLogin: { userId: string; timestamp: number }; + userLogout: { userId: string }; + dataUpdate: { id: number; data: string }; + error: { code: number; message: string }; + simpleEvent: void; + } - // Arrange - const dispatcher = createEventDispatcher(); - const eventSpy = spy(); + // Arrange + const dispatcher = createEventDispatcher(); + const eventSpy = spy(); - // Act - dispatcher.on("simpleEvent", eventSpy); - dispatcher.emit("simpleEvent", undefined); + // Act + dispatcher.on("simpleEvent", eventSpy); + dispatcher.emit("simpleEvent", undefined); - // Assert - expect(eventSpy.calls.length).toBe(1); - expect(eventSpy.calls[0].args[0]).toBeUndefined(); + // Assert + expect(eventSpy.calls.length).toBe(1); + expect(eventSpy.calls[0].args[0]).toBeUndefined(); - dispatcher.close(); + dispatcher.close(); }); test("Event Dispatcher - Multiple handlers for same event", () => { - interface TestEvents extends EventMap { - userLogin: { userId: string; timestamp: number }; - userLogout: { userId: string }; - dataUpdate: { id: number; data: string }; - error: { code: number; message: string }; - simpleEvent: void; - } + interface TestEvents extends EventMap { + userLogin: { userId: string; timestamp: number }; + userLogout: { userId: string }; + dataUpdate: { id: number; data: string }; + error: { code: number; message: string }; + simpleEvent: void; + } - // Arrange - const dispatcher = createEventDispatcher(); - const handler1 = spy(); - const handler2 = spy(); - const handler3 = spy(); + // Arrange + const dispatcher = createEventDispatcher(); + const handler1 = spy(); + const handler2 = spy(); + const handler3 = spy(); - // Act - dispatcher.on("dataUpdate", handler1); - dispatcher.on("dataUpdate", handler2); - dispatcher.on("dataUpdate", handler3); + // Act + dispatcher.on("dataUpdate", handler1); + dispatcher.on("dataUpdate", handler2); + dispatcher.on("dataUpdate", handler3); - dispatcher.emit("dataUpdate", { id: 42, data: "test-data" }); + dispatcher.emit("dataUpdate", { id: 42, data: "test-data" }); - // Assert - All handlers called - expect(handler1.calls.length).toBe(1); - expect(handler2.calls.length).toBe(1); - expect(handler3.calls.length).toBe(1); + // Assert - All handlers called + expect(handler1.calls.length).toBe(1); + expect(handler2.calls.length).toBe(1); + expect(handler3.calls.length).toBe(1); - // Verify same data passed to all - const expectedPayload = { id: 42, data: "test-data" }; - expect(handler1.calls[0].args[0]).toEqual(expectedPayload); - expect(handler2.calls[0].args[0]).toEqual(expectedPayload); // Fixed: was handler2.calls.args - expect(handler3.calls[0].args[0]).toEqual(expectedPayload); // Fixed: was handler3.calls.args + // Verify same data passed to all + const expectedPayload = { id: 42, data: "test-data" }; + expect(handler1.calls[0].args[0]).toEqual(expectedPayload); + expect(handler2.calls[0].args[0]).toEqual(expectedPayload); // Fixed: was handler2.calls.args + expect(handler3.calls[0].args[0]).toEqual(expectedPayload); // Fixed: was handler3.calls.args - dispatcher.close(); + dispatcher.close(); }); test("Event Dispatcher - Resource disposal", () => { - interface TestEvents extends EventMap { - userLogin: { userId: string; timestamp: number }; - userLogout: { userId: string }; - dataUpdate: { id: number; data: string }; - error: { code: number; message: string }; - simpleEvent: void; - } + interface TestEvents extends EventMap { + userLogin: { userId: string; timestamp: number }; + userLogout: { userId: string }; + dataUpdate: { id: number; data: string }; + error: { code: number; message: string }; + simpleEvent: void; + } - // Arrange & Act - let dispatcher: ReturnType>; + // Arrange & Act + let dispatcher: ReturnType>; - { - using testDispatcher = createEventDispatcher(); - dispatcher = testDispatcher; + { + using testDispatcher = createEventDispatcher(); + dispatcher = testDispatcher; - const handler = spy(); - dispatcher.on("simpleEvent", handler); - dispatcher.emit("simpleEvent", undefined); + const handler = spy(); + dispatcher.on("simpleEvent", handler); + dispatcher.emit("simpleEvent", undefined); - expect(handler.calls.length).toBe(1); - } // Should dispose here + expect(handler.calls.length).toBe(1); + } // Should dispose here - // Assert - Emitting after disposal should not work - const handler2 = spy(); - dispatcher.on("simpleEvent", handler2); - dispatcher.emit("simpleEvent", undefined); + // Assert - Emitting after disposal should not work + const handler2 = spy(); + dispatcher.on("simpleEvent", handler2); + dispatcher.emit("simpleEvent", undefined); - // Handler should not be called because dispatcher is closed - expect(handler2.calls.length).toBe(0); + // Handler should not be called because dispatcher is closed + expect(handler2.calls.length).toBe(0); }); test("waitForEvent utility - Resolves with payload when event fires", async () => { - interface AsyncTestEvents extends EventMap { - data: { value: number }; - error: { message: string }; - complete: void; - } + interface AsyncTestEvents extends EventMap { + data: { value: number }; + error: { message: string }; + complete: void; + } - // Arrange - const dispatcher = createEventDispatcher(); + // Arrange + const dispatcher = createEventDispatcher(); - // Act - Start waiting for event - const eventPromise = waitForEvent(dispatcher, "data"); + // Act - Start waiting for event + const eventPromise = waitForEvent(dispatcher, "data"); - // Emit the event after a delay - setTimeout(() => { - dispatcher.emit("data", { value: 42 }); - }, 10); + // Emit the event after a delay + setTimeout(() => { + dispatcher.emit("data", { value: 42 }); + }, 10); - const result = await eventPromise; + const result = await eventPromise; - // Assert - expect(result).toBeDefined(); - expect(result!.value).toBe(42); + // Assert + expect(result).toBeDefined(); + expect(result!.value).toBe(42); - dispatcher.close(); + dispatcher.close(); }); test("waitForEvent utility - Resolves with undefined when stream completes before event", async () => { - interface AsyncTestEvents extends EventMap { - data: { value: number }; - error: { message: string }; - complete: void; - } + interface AsyncTestEvents extends EventMap { + data: { value: number }; + error: { message: string }; + complete: void; + } - // Arrange - const dispatcher = createEventDispatcher(); + // Arrange + const dispatcher = createEventDispatcher(); - // Act - const eventPromise = waitForEvent(dispatcher, "data"); + // Act + const eventPromise = waitForEvent(dispatcher, "data"); - // Close dispatcher before event fires - setTimeout(() => { - dispatcher.close(); - }, 10); + // Close dispatcher before event fires + setTimeout(() => { + dispatcher.close(); + }, 10); - const result = await eventPromise; + const result = await eventPromise; - // Assert - expect(result).toBeUndefined(); + // Assert + expect(result).toBeUndefined(); }); test("waitForEvent utility - Rejects when throwOnClose is true and stream completes", async () => { - interface AsyncTestEvents extends EventMap { - data: { value: number }; - error: { message: string }; - complete: void; - } + interface AsyncTestEvents extends EventMap { + data: { value: number }; + error: { message: string }; + complete: void; + } - // Arrange - const dispatcher = createEventDispatcher(); + // Arrange + const dispatcher = createEventDispatcher(); - // Act - const eventPromise = waitForEvent(dispatcher, "data", { throwOnClose: true }); + // Act + const eventPromise = waitForEvent(dispatcher, "data", { throwOnClose: true }); - // Close dispatcher - setTimeout(() => { - dispatcher.close(); - }, 10); + // Close dispatcher + setTimeout(() => { + dispatcher.close(); + }, 10); - // Assert - await expect(eventPromise).rejects.toThrow('Stream closed before event "data" fired'); + // Assert + await expect(eventPromise).rejects.toThrow( + 'Stream closed before event "data" fired', + ); }); test("waitForEvent utility - Supports AbortSignal for cancellation", async () => { - interface AsyncTestEvents extends EventMap { - data: { value: number }; - error: { message: string }; - complete: void; - } + interface AsyncTestEvents extends EventMap { + data: { value: number }; + error: { message: string }; + complete: void; + } - // Arrange - const dispatcher = createEventDispatcher(); - const controller = new AbortController(); + // Arrange + const dispatcher = createEventDispatcher(); + const controller = new AbortController(); - // Act - const eventPromise = waitForEvent(dispatcher, "data", { - signal: controller.signal - }); + // Act + const eventPromise = waitForEvent(dispatcher, "data", { + signal: controller.signal, + }); - // Abort after delay - setTimeout(() => { - controller.abort(new Error("Test cancellation")); - }, 10); + // Abort after delay + setTimeout(() => { + controller.abort(new Error("Test cancellation")); + }, 10); - // Assert - await expect(eventPromise).rejects.toThrow("Test cancellation"); + // Assert + await expect(eventPromise).rejects.toThrow("Test cancellation"); - dispatcher.close(); + dispatcher.close(); }); test("waitForEvent utility - Immediate abort", async () => { - interface AsyncTestEvents extends EventMap { - data: { value: number }; - error: { message: string }; - complete: void; - } + interface AsyncTestEvents extends EventMap { + data: { value: number }; + error: { message: string }; + complete: void; + } - // Arrange - const dispatcher = createEventDispatcher(); - const controller = new AbortController(); - controller.abort(new Error("Already aborted")); + // Arrange + const dispatcher = createEventDispatcher(); + const controller = new AbortController(); + controller.abort(new Error("Already aborted")); - // Act & Assert - await expect( - waitForEvent(dispatcher, "data", { signal: controller.signal }) - ).rejects.toThrow("Already aborted"); + // Act & Assert + await expect( + waitForEvent(dispatcher, "data", { signal: controller.signal }), + ).rejects.toThrow("Already aborted"); - dispatcher.close(); + dispatcher.close(); }); test("withReplay utility - Replays last N emissions to new subscribers", () => { - // Arrange - const bus = new EventBus(); - const replaySource = withReplay(bus.events, { count: 3, mode: "eager" }); + // Arrange + const bus = new EventBus(); + const replaySource = withReplay(bus.events, { count: 3, mode: "eager" }); - // Act - Emit some events before subscribing - bus.emit(1); - bus.emit(2); - bus.emit(3); - bus.emit(4); // This should push out the first emission + // Act - Emit some events before subscribing + bus.emit(1); + bus.emit(2); + bus.emit(3); + bus.emit(4); // This should push out the first emission - // Subscribe after emissions - const { handler, spy: replaySpy } = createEventSpy(); - const subscription = replaySource.subscribe({ next: handler }); + // Subscribe after emissions + const { handler, spy: replaySpy } = createEventSpy(); + const subscription = replaySource.subscribe({ next: handler }); - // Emit more events after subscription - bus.emit(5); + // Emit more events after subscription + bus.emit(5); - // Assert - Should get replayed events plus new ones - expect(replaySpy.calls.length).toBe(4); // 3 replayed + 1 new - expect(replaySpy.calls.map(call => call.args[0])).toEqual([2, 3, 4, 5]); + // Assert - Should get replayed events plus new ones + expect(replaySpy.calls.length).toBe(4); // 3 replayed + 1 new + expect(replaySpy.calls.map((call) => call.args[0])).toEqual([2, 3, 4, 5]); - // Cleanup - subscription.unsubscribe(); - bus.close(); + // Cleanup + subscription.unsubscribe(); + bus.close(); }); test("withReplay utility - Infinite replay buffer", () => { - // Arrange - const bus = new EventBus(); - const replaySource = withReplay(bus.events, { mode: "eager" }); // Need eager mode to capture pre-subscription events + // Arrange + const bus = new EventBus(); + const replaySource = withReplay(bus.events, { mode: "eager" }); // Need eager mode to capture pre-subscription events - // Act - Emit many events - const testEvents = Array.from({ length: 1000 }, (_, i) => `event-${i}`); - testEvents.forEach(event => bus.emit(event)); + // Act - Emit many events + const testEvents = Array.from({ length: 1000 }, (_, i) => `event-${i}`); + testEvents.forEach((event) => bus.emit(event)); - // Subscribe after all emissions - const { handler, calls } = createEventSpy(); - const subscription = replaySource.subscribe({ next: handler }); + // Subscribe after all emissions + const { handler, calls } = createEventSpy(); + const subscription = replaySource.subscribe({ next: handler }); - // Assert - Should replay all events - expect(calls.length).toBe(1000); - expect(calls[0]).toBe("event-0"); - expect(calls[999]).toBe("event-999"); // Fixed: was expect(calls).toBe("event-999") + // Assert - Should replay all events + expect(calls.length).toBe(1000); + expect(calls[0]).toBe("event-0"); + expect(calls[999]).toBe("event-999"); // Fixed: was expect(calls).toBe("event-999") - // Cleanup - subscription.unsubscribe(); - bus.close(); + // Cleanup + subscription.unsubscribe(); + bus.close(); }); test("withReplay utility - Multiple subscribers each get replay", () => { - // Arrange - const bus = new EventBus(); - const replaySource = withReplay(bus.events, { count: 2, mode: "eager" }); // Need eager mode + // Arrange + const bus = new EventBus(); + const replaySource = withReplay(bus.events, { count: 2, mode: "eager" }); // Need eager mode - // Act - Emit events - bus.emit("first"); - bus.emit("second"); + // Act - Emit events + bus.emit("first"); + bus.emit("second"); - // Subscribe multiple times - const subscriber1 = createEventSpy(); - const subscriber2 = createEventSpy(); + // Subscribe multiple times + const subscriber1 = createEventSpy(); + const subscriber2 = createEventSpy(); - const sub1 = replaySource.subscribe({ next: subscriber1.handler }); - const sub2 = replaySource.subscribe({ next: subscriber2.handler }); + const sub1 = replaySource.subscribe({ next: subscriber1.handler }); + const sub2 = replaySource.subscribe({ next: subscriber2.handler }); - // Assert - Both should get replay - expect(subscriber1.calls).toEqual(["first", "second"]); - expect(subscriber2.calls).toEqual(["first", "second"]); + // Assert - Both should get replay + expect(subscriber1.calls).toEqual(["first", "second"]); + expect(subscriber2.calls).toEqual(["first", "second"]); - // Cleanup - sub1.unsubscribe(); - sub2.unsubscribe(); - bus.close(); + // Cleanup + sub1.unsubscribe(); + sub2.unsubscribe(); + bus.close(); }); test("withReplay utility - Buffer cleanup on unsubscribe", () => { - // Arrange - const bus = new EventBus(); - const replaySource = withReplay(bus.events, { count: 10 }); + // Arrange + const bus = new EventBus(); + const replaySource = withReplay(bus.events, { count: 10 }); - // Act - bus.emit(1); - bus.emit(2); + // Act + bus.emit(1); + bus.emit(2); - const { handler } = createEventSpy(); - const subscription = replaySource.subscribe({ next: handler }); + const { handler } = createEventSpy(); + const subscription = replaySource.subscribe({ next: handler }); - // Cleanup - subscription.unsubscribe(); - bus.close(); + // Cleanup + subscription.unsubscribe(); + bus.close(); - // Assert - No errors should occur, memory should be cleaned - // This is more of a smoke test for cleanup logic + // Assert - No errors should occur, memory should be cleaned + // This is more of a smoke test for cleanup logic }); test("Error handling - EventBus handles observer errors gracefully", () => { - // Arrange - const bus = new EventBus(); - const errorSpy = spy(); - const faultyHandler = spy(() => { - throw new Error("Observer error"); - }); + // Arrange + const bus = new EventBus(); + const errorSpy = spy(); + const faultyHandler = spy(() => { + throw new Error("Observer error"); + }); - // Act - bus.subscribe({ - next: faultyHandler, - error: errorSpy - }); + // Act + bus.subscribe({ + next: faultyHandler, + error: errorSpy, + }); - bus.emit("trigger-error"); + bus.emit("trigger-error"); - // Assert - expect(faultyHandler.calls.length).toBe(1); - expect(errorSpy.calls.length).toBe(1); + // Assert + expect(faultyHandler.calls.length).toBe(1); + expect(errorSpy.calls.length).toBe(1); - bus.close(); + bus.close(); }); test("Error handling - Dispatcher handles large payloads", () => { - interface LargePayloadEvents extends EventMap { - bigData: { data: number[] }; - } + interface LargePayloadEvents extends EventMap { + bigData: { data: number[] }; + } - // Arrange - const dispatcher = createEventDispatcher(); - const handler = spy(); + // Arrange + const dispatcher = createEventDispatcher(); + const handler = spy(); - // Act - dispatcher.on("bigData", handler); + // Act + dispatcher.on("bigData", handler); - const largeArray = Array.from({ length: 10000 }, (_, i) => i); - dispatcher.emit("bigData", { data: largeArray }); + const largeArray = Array.from({ length: 10000 }, (_, i) => i); + dispatcher.emit("bigData", { data: largeArray }); - // Assert - expect(handler.calls.length).toBe(1); - expect(handler.calls[0].args[0].data.length).toBe(10000); // Fixed: was handler.calls[0].args.data.length + // Assert + expect(handler.calls.length).toBe(1); + expect(handler.calls[0].args[0].data.length).toBe(10000); // Fixed: was handler.calls[0].args.data.length - dispatcher.close(); + dispatcher.close(); }); test("Error handling - Concurrent event emissions", async () => { - // Arrange - const bus = new EventBus(); - const { handler, calls } = createEventSpy(); + // Arrange + const bus = new EventBus(); + const { handler, calls } = createEventSpy(); - // Act - bus.subscribe({ next: handler }); + // Act + bus.subscribe({ next: handler }); - // Emit events concurrently - const promises = Array.from({ length: 100 }, (_, i) => - Promise.resolve().then(() => bus.emit(i)) - ); + // Emit events concurrently + const promises = Array.from( + { length: 100 }, + (_, i) => Promise.resolve().then(() => bus.emit(i)), + ); - await Promise.all(promises); + await Promise.all(promises); - // Assert - All events should be captured - expect(calls.length).toBe(100); + // Assert - All events should be captured + expect(calls.length).toBe(100); - // Events should contain all numbers 0-99 (order may vary due to concurrency) - const sortedCalls = [...calls].sort((a, b) => a - b); - expect(sortedCalls).toEqual(Array.from({ length: 100 }, (_, i) => i)); + // Events should contain all numbers 0-99 (order may vary due to concurrency) + const sortedCalls = [...calls].sort((a, b) => a - b); + expect(sortedCalls).toEqual(Array.from({ length: 100 }, (_, i) => i)); - bus.close(); + bus.close(); }); test("Error handling - Event ordering preservation", () => { - // Arrange - const bus = new EventBus(); - const { handler, calls } = createEventSpy(); + // Arrange + const bus = new EventBus(); + const { handler, calls } = createEventSpy(); - // Act - bus.subscribe({ next: handler }); + // Act + bus.subscribe({ next: handler }); - // Emit events synchronously in order - for (let i = 0; i < 1000; i++) { - bus.emit(i); - } + // Emit events synchronously in order + for (let i = 0; i < 1000; i++) { + bus.emit(i); + } - // Assert - Order should be preserved for synchronous emissions - expect(calls).toEqual(Array.from({ length: 1000 }, (_, i) => i)); + // Assert - Order should be preserved for synchronous emissions + expect(calls).toEqual(Array.from({ length: 1000 }, (_, i) => i)); - bus.close(); + bus.close(); }); test("Performance - High-frequency event emission", () => { - // Arrange - const bus = new EventBus(); - const { handler, calls } = createEventSpy(); + // Arrange + const bus = new EventBus(); + const { handler, calls } = createEventSpy(); - // Act - const startTime = performance.now(); - bus.subscribe({ next: handler }); + // Act + const startTime = performance.now(); + bus.subscribe({ next: handler }); - // Emit 10,000 events rapidly - for (let i = 0; i < 10000; i++) { - bus.emit(i); - } + // Emit 10,000 events rapidly + for (let i = 0; i < 10000; i++) { + bus.emit(i); + } - const endTime = performance.now(); + const endTime = performance.now(); - // Assert - expect(calls.length).toBe(10000); + // Assert + expect(calls.length).toBe(10000); - // Should complete reasonably quickly (less than 100ms) - const duration = endTime - startTime; - console.log(`High-frequency emission took ${duration}ms`); - // Note: We don't assert on timing as it's environment-dependent + // Should complete reasonably quickly (less than 100ms) + const duration = endTime - startTime; + console.log(`High-frequency emission took ${duration}ms`); + // Note: We don't assert on timing as it's environment-dependent - bus.close(); + bus.close(); }); test("Performance - Subscription/unsubscription performance", () => { - // Arrange - const bus = new EventBus(); + // Arrange + const bus = new EventBus(); - // Act - Create and destroy many subscriptions quickly - const startTime = performance.now(); + // Act - Create and destroy many subscriptions quickly + const startTime = performance.now(); - const subscriptions = Array.from({ length: 1000 }, () => { - const { handler } = createEventSpy(); - return bus.subscribe({ next: handler }); - }); + const subscriptions = Array.from({ length: 1000 }, () => { + const { handler } = createEventSpy(); + return bus.subscribe({ next: handler }); + }); - subscriptions.forEach(sub => sub.unsubscribe()); + subscriptions.forEach((sub) => sub.unsubscribe()); - const endTime = performance.now(); + const endTime = performance.now(); - // Assert - const duration = endTime - startTime; - console.log(`1000 subscribe/unsubscribe cycles took ${duration}ms`); + // Assert + const duration = endTime - startTime; + console.log(`1000 subscribe/unsubscribe cycles took ${duration}ms`); - bus.close(); + bus.close(); }); test("Performance - Memory usage with replay buffer", () => { - // Arrange - const bus = new EventBus(); - const replaySource = withReplay(bus.events, { count: 1000, mode: "eager" }); // Need eager mode + // Arrange + const bus = new EventBus(); + const replaySource = withReplay(bus.events, { count: 1000, mode: "eager" }); // Need eager mode - // Act - Fill the buffer - for (let i = 0; i < 2000; i++) { - bus.emit(`event-${i}`); - } + // Act - Fill the buffer + for (let i = 0; i < 2000; i++) { + bus.emit(`event-${i}`); + } - // Subscribe and check we only get last 1000 events - const { handler, calls } = createEventSpy(); - replaySource.subscribe({ next: handler }); + // Subscribe and check we only get last 1000 events + const { handler, calls } = createEventSpy(); + replaySource.subscribe({ next: handler }); - // Assert - Should only replay last 1000 events - expect(calls.length).toBe(1000); - expect(calls[0]).toBe("event-1000"); // First in buffer should be event-1000 - expect(calls[999]).toBe("event-1999"); // Fixed: was expect(calls).toBe("event-1999") + // Assert - Should only replay last 1000 events + expect(calls.length).toBe(1000); + expect(calls[0]).toBe("event-1000"); // First in buffer should be event-1000 + expect(calls[999]).toBe("event-1999"); // Fixed: was expect(calls).toBe("event-1999") - bus.close(); + bus.close(); }); /** * Integration tests with Observable features */ test("Observable integration - EventBus as Observable source", async () => { - // Arrange - const bus = new EventBus(); + // Arrange + const bus = new EventBus(); - // Act - Use EventBus with async iteration - const collectedValues: number[] = []; - const iterationPromise = (async () => { - for await (const value of bus.events) { - collectedValues.push(value); - if (value >= 3) break; // Stop after collecting a few values - } - })(); + // Act - Use EventBus with async iteration + const collectedValues: number[] = []; + const iterationPromise = (async () => { + for await (const value of bus.events) { + collectedValues.push(value); + if (value >= 3) break; // Stop after collecting a few values + } + })(); - // Emit some values - bus.emit(1); - bus.emit(2); - bus.emit(3); + // Emit some values + bus.emit(1); + bus.emit(2); + bus.emit(3); - await iterationPromise; + await iterationPromise; - // Assert - expect(collectedValues).toEqual([1, 2, 3]); + // Assert + expect(collectedValues).toEqual([1, 2, 3]); - bus.close(); + bus.close(); }); test("Observable integration - Converting other observables to EventBus", () => { - // Arrange - const sourceObservable = Observable.of(1, 2, 3, 4, 5); - const bus = new EventBus(); + // Arrange + const sourceObservable = Observable.of(1, 2, 3, 4, 5); + const bus = new EventBus(); - // Subscribe to bus - const { handler, calls } = createEventSpy(); - bus.subscribe({ next: handler }); + // Subscribe to bus + const { handler, calls } = createEventSpy(); + bus.subscribe({ next: handler }); - // Act - Pipe source observable to bus - sourceObservable.subscribe({ - next: value => bus.emit(value), - complete: () => bus.close() - }); + // Act - Pipe source observable to bus + sourceObservable.subscribe({ + next: (value) => bus.emit(value), + complete: () => bus.close(), + }); - // Assert - expect(calls).toEqual([1, 2, 3, 4, 5]); + // Assert + expect(calls).toEqual([1, 2, 3, 4, 5]); }); /** * Example usage tests (documentation tests) */ test("Documentation - Basic EventBus example from docs", () => { - // Example from the module documentation - const bus = new EventBus(); + // Example from the module documentation + const bus = new EventBus(); - const eventLog: string[] = []; - const completionLog: string[] = []; + const eventLog: string[] = []; + const completionLog: string[] = []; - bus.events.subscribe({ - next(msg) { eventLog.push(`Received: ${msg}`); }, - complete() { completionLog.push('Bus closed'); } - }); + bus.events.subscribe({ + next(msg) { + eventLog.push(`Received: ${msg}`); + }, + complete() { + completionLog.push("Bus closed"); + }, + }); - // Emit values - bus.emit('hello'); - bus.emit('world'); + // Emit values + bus.emit("hello"); + bus.emit("world"); - // Close the bus - bus.close(); + // Close the bus + bus.close(); - // Verify - expect(eventLog).toEqual(['Received: hello', 'Received: world']); - expect(completionLog).toEqual(['Bus closed']); + // Verify + expect(eventLog).toEqual(["Received: hello", "Received: world"]); + expect(completionLog).toEqual(["Bus closed"]); }); test("Documentation - Typed event dispatcher example from docs", () => { - // Example from the module documentation - interface MyEvents { - message: { text: string }; - error: { code: number; message: string }; - } + // Example from the module documentation + interface MyEvents { + message: { text: string }; + error: { code: number; message: string }; + } - const bus = createEventDispatcher(); + const bus = createEventDispatcher(); - const messageLog: string[] = []; + const messageLog: string[] = []; - // Subscribe to `message` events - bus.on('message', payload => { - messageLog.push(`New message: ${payload.text}`); - }); + // Subscribe to `message` events + bus.on("message", (payload) => { + messageLog.push(`New message: ${payload.text}`); + }); - // Emit an event - bus.emit('message', { text: 'Hello World' }); + // Emit an event + bus.emit("message", { text: "Hello World" }); - // Verify - expect(messageLog).toEqual(['New message: Hello World']); + // Verify + expect(messageLog).toEqual(["New message: Hello World"]); - // Close the bus when done - bus.close(); + // Close the bus when done + bus.close(); }); test("Documentation - waitForEvent example from docs", async () => { - interface MyEvents { - data: { value: number }; - done: void; - } + interface MyEvents { + data: { value: number }; + done: void; + } - const bus = createEventDispatcher(); + const bus = createEventDispatcher(); - // Start waiting for event - const eventPromise = waitForEvent(bus, 'data').then(payload => { - return payload ? `Data arrived: ${payload.value}` : 'No data'; - }); + // Start waiting for event + const eventPromise = waitForEvent(bus, "data").then((payload) => { + return payload ? `Data arrived: ${payload.value}` : "No data"; + }); - // Emit the event - setTimeout(() => { - bus.emit('data', { value: 42 }); - }, 1); + // Emit the event + setTimeout(() => { + bus.emit("data", { value: 42 }); + }, 1); - const result = await eventPromise; - expect(result).toBe('Data arrived: 42'); + const result = await eventPromise; + expect(result).toBe("Data arrived: 42"); - bus.close(); + bus.close(); }); // Runtime-specific tests if (runtime === "deno") { - test("Deno-specific - Network permissions test", async () => { - // This test only runs on Deno and requires network permissions - await using server = Deno.serve( - { port: 8080, onListen: () => null }, - () => new Response(null, { status: 200 }) - ); - - const response = await fetch(`http://${server.addr.hostname}:${server.addr.port}`); - expect(response.status).toBe(200); - - // Dispose of the response body if it exists - await response?.body?.cancel(); - }, { permissions: { net: "inherit" } }); + test("Deno-specific - Network permissions test", async () => { + // This test only runs on Deno and requires network permissions + await using server = Deno.serve( + { port: 8080, onListen: () => null }, + () => new Response(null, { status: 200 }), + ); + + const response = await fetch( + `http://${server.addr.hostname}:${server.addr.port}`, + ); + expect(response.status).toBe(200); + + // Dispose of the response body if it exists + await response?.body?.cancel(); + }, { permissions: { net: "inherit" } }); } if (runtime === "node") { - test("Node.js-specific - Process environment test", () => { - // This test only runs on Node.js - const global = globalThis as any; // Fixed: type assertion for Node.js globals - expect(typeof global?.process).toBe("object"); - expect(global?.process?.versions?.node).toBeDefined(); - }); + test("Node.js-specific - Process environment test", () => { + // This test only runs on Node.js + const global = globalThis as typeof globalThis & { + process?: { versions?: { node?: string } }; + }; + expect(typeof global?.process).toBe("object"); + expect(global?.process?.versions?.node).toBeDefined(); + }); } if (runtime === "bun") { - test("Bun-specific - Bun global test", () => { - const _globalThis = globalThis as { Bun?: { version: string } }; - - // This test only runs on Bun - expect(typeof _globalThis?.Bun).toBe("object"); - expect(_globalThis?.Bun?.version).toBeDefined(); - }); -} \ No newline at end of file + test("Bun-specific - Bun global test", () => { + const _globalThis = globalThis as { Bun?: { version: string } }; + + // This test only runs on Bun + expect(typeof _globalThis?.Bun).toBe("object"); + expect(_globalThis?.Bun?.version).toBeDefined(); + }); +} diff --git a/tests/events_bdd_test.ts b/tests/events_bdd_test.ts index 70ba6fe..6fc846f 100644 --- a/tests/events_bdd_test.ts +++ b/tests/events_bdd_test.ts @@ -3,24 +3,25 @@ * (type-safe routing), withReplay (buffer for late subscribers), and waitForEvent (Promise-based * waiting). These replace EventEmitter patterns with automatic cleanup, full TypeScript inference, * and operator composability. - * + * * EventBus multicasts to all subscribers (loudspeaker analogy), EventDispatcher routes typed * messages to specific handlers, withReplay buffers recent values (DVR: lazy mode records only * when subscribers present, eager always records), waitForEvent returns Promise that resolves * on event (supports AbortSignal cancellation). */ -import { describe, it } from '@std/testing/bdd'; -import { expect } from '@std/expect'; +// deno-lint-ignore-file no-import-prefix +import { describe, it } from "jsr:@std/testing@^1/bdd"; +import { expect } from "jsr:@std/expect@^1"; -import { - EventBus, +import { createEventDispatcher, + EventBus, + type EventMap, waitForEvent, withReplay, - type EventMap, -} from '../events.ts'; -import { Observable } from '../observable.ts'; +} from "../events.ts"; +import { Observable } from "../observable.ts"; describe("EventBus", () => { describe("Basic Operations", () => { @@ -35,13 +36,13 @@ describe("EventBus", () => { const received: string[] = []; bus.events.subscribe({ - next: (value: string) => received.push(value) + next: (value: string) => received.push(value), }); - bus.emit('hello'); - bus.emit('world'); + bus.emit("hello"); + bus.emit("world"); - expect(received).toEqual(['hello', 'world']); + expect(received).toEqual(["hello", "world"]); }); it("should emit to multiple subscribers", () => { @@ -64,14 +65,14 @@ describe("EventBus", () => { const received: string[] = []; const subscription = bus.events.subscribe({ - next: (value: string) => received.push(value) + next: (value: string) => received.push(value), }); - bus.emit('before'); + bus.emit("before"); subscription.unsubscribe(); - bus.emit('after'); + bus.emit("after"); - expect(received).toEqual(['before']); + expect(received).toEqual(["before"]); }); it("should handle different data types", () => { @@ -84,15 +85,17 @@ describe("EventBus", () => { let arrayValue: number[] | undefined; stringBus.events.subscribe({ next: (v: string) => stringValue = v }); - objectBus.events.subscribe({ next: (v: { id: number; name: string }) => objectValue = v }); + objectBus.events.subscribe({ + next: (v: { id: number; name: string }) => objectValue = v, + }); arrayBus.events.subscribe({ next: (v: number[]) => arrayValue = v }); - stringBus.emit('test'); - objectBus.emit({ id: 1, name: 'Alice' }); + stringBus.emit("test"); + objectBus.emit({ id: 1, name: "Alice" }); arrayBus.emit([1, 2, 3]); - expect(stringValue).toBe('test'); - expect(objectValue).toEqual({ id: 1, name: 'Alice' }); + expect(stringValue).toBe("test"); + expect(objectValue).toEqual({ id: 1, name: "Alice" }); expect(arrayValue).toEqual([1, 2, 3]); }); }); @@ -104,7 +107,9 @@ describe("EventBus", () => { bus.events.subscribe({ next: () => {}, - complete: () => { completed = true; } + complete: () => { + completed = true; + }, }); bus.close(); @@ -117,11 +122,11 @@ describe("EventBus", () => { bus.events.subscribe({ next: (v: string) => received.push(v) }); - bus.emit('before'); + bus.emit("before"); bus.close(); - bus.emit('after'); + bus.emit("after"); - expect(received).toEqual(['before']); + expect(received).toEqual(["before"]); }); it("should immediately complete new subscribers after closing", () => { @@ -131,7 +136,9 @@ describe("EventBus", () => { let completed = false; bus.events.subscribe({ next: () => {}, - complete: () => { completed = true; } + complete: () => { + completed = true; + }, }); expect(completed).toBe(true); @@ -142,7 +149,9 @@ describe("EventBus", () => { let completedCount = 0; bus.events.subscribe({ - complete: () => { completedCount++; } + complete: () => { + completedCount++; + }, }); bus.close(); @@ -160,7 +169,9 @@ describe("EventBus", () => { { using bus = new EventBus(); bus.events.subscribe({ - complete: () => { completed = true; } + complete: () => { + completed = true; + }, }); bus.emit(1); @@ -174,7 +185,9 @@ describe("EventBus", () => { const bus = new EventBus(); bus.events.subscribe({ - complete: () => { completed = true; } + complete: () => { + completed = true; + }, }); bus.emit(1); @@ -185,7 +198,7 @@ describe("EventBus", () => { it("should close subscriptions when the bus closes", () => { const bus = new EventBus(); - + const sub1 = bus.events.subscribe({ next: () => {} }); const sub2 = bus.events.subscribe({ next: () => {} }); const sub3 = bus.events.subscribe({ next: () => {} }); @@ -217,7 +230,9 @@ describe("EventBus", () => { const bus = new EventBus(); const received: Array = []; - bus.events.subscribe({ next: (v: number | null | undefined) => received.push(v) }); + bus.events.subscribe({ + next: (v: number | null | undefined) => received.push(v), + }); bus.emit(undefined); bus.emit(null); @@ -232,13 +247,15 @@ describe("EventBus", () => { // First subscriber throws bus.events.subscribe({ - next: () => { throw new Error('Subscriber error'); }, - error: () => {} // Catch the error + next: () => { + throw new Error("Subscriber error"); + }, + error: () => {}, // Catch the error }); // Second subscriber should still work bus.events.subscribe({ - next: (v: number) => received.push(v) + next: (v: number) => received.push(v), }); bus.emit(1); @@ -254,17 +271,17 @@ describe("EventBus", () => { const logs: string[] = []; // Three subscribers - bus.events.subscribe({ next: () => logs.push('sub1') }); - bus.events.subscribe({ next: () => logs.push('sub2') }); - bus.events.subscribe({ next: () => logs.push('sub3') }); + bus.events.subscribe({ next: () => logs.push("sub1") }); + bus.events.subscribe({ next: () => logs.push("sub2") }); + bus.events.subscribe({ next: () => logs.push("sub3") }); - bus.emit('event'); + bus.emit("event"); // All three should receive expect(logs.length).toBe(3); - expect(logs).toContain('sub1'); - expect(logs).toContain('sub2'); - expect(logs).toContain('sub3'); + expect(logs).toContain("sub1"); + expect(logs).toContain("sub2"); + expect(logs).toContain("sub3"); }); it("should only emit to subscribers present at emit time", () => { @@ -306,13 +323,16 @@ describe("EventDispatcher (Type-Safe Event Bus)", () => { const dispatcher = createEventDispatcher(); let received: { text: string; priority: number } | undefined; - dispatcher.on('message', (payload: { text: string; priority: number }) => { - received = payload; - }); + dispatcher.on( + "message", + (payload: { text: string; priority: number }) => { + received = payload; + }, + ); - dispatcher.emit('message', { text: 'Hello', priority: 1 }); + dispatcher.emit("message", { text: "Hello", priority: 1 }); - expect(received).toEqual({ text: 'Hello', priority: 1 }); + expect(received).toEqual({ text: "Hello", priority: 1 }); }); it("should only trigger matching event handlers", () => { @@ -320,12 +340,12 @@ describe("EventDispatcher (Type-Safe Event Bus)", () => { const messageLogs: string[] = []; const statusLogs: string[] = []; - dispatcher.on('message', () => messageLogs.push('message')); - dispatcher.on('status', () => statusLogs.push('status')); + dispatcher.on("message", () => messageLogs.push("message")); + dispatcher.on("status", () => statusLogs.push("status")); - dispatcher.emit('message', { text: 'test', priority: 1 }); - dispatcher.emit('message', { text: 'test2', priority: 2 }); - dispatcher.emit('status', { code: 200, message: 'OK' }); + dispatcher.emit("message", { text: "test", priority: 1 }); + dispatcher.emit("message", { text: "test2", priority: 2 }); + dispatcher.emit("status", { code: 200, message: "OK" }); expect(messageLogs.length).toBe(2); expect(statusLogs.length).toBe(1); @@ -336,24 +356,24 @@ describe("EventDispatcher (Type-Safe Event Bus)", () => { const listener1: string[] = []; const listener2: string[] = []; - dispatcher.on('simple', (text: string) => listener1.push(text)); - dispatcher.on('simple', (text: string) => listener2.push(text)); + dispatcher.on("simple", (text: string) => listener1.push(text)); + dispatcher.on("simple", (text: string) => listener2.push(text)); - dispatcher.emit('simple', 'test'); + dispatcher.emit("simple", "test"); - expect(listener1).toEqual(['test']); - expect(listener2).toEqual(['test']); + expect(listener1).toEqual(["test"]); + expect(listener2).toEqual(["test"]); }); it("should handle void payload events", () => { const dispatcher = createEventDispatcher(); let called = false; - dispatcher.on('noPayload', () => { + dispatcher.on("noPayload", () => { called = true; }); - dispatcher.emit('noPayload', undefined); + dispatcher.emit("noPayload", undefined); expect(called).toBe(true); }); @@ -364,15 +384,15 @@ describe("EventDispatcher (Type-Safe Event Bus)", () => { const dispatcher = createEventDispatcher(); const received: string[] = []; - const subscription = dispatcher.on('simple', (text: string) => { + const subscription = dispatcher.on("simple", (text: string) => { received.push(text); }); - dispatcher.emit('simple', 'before'); + dispatcher.emit("simple", "before"); subscription.unsubscribe(); - dispatcher.emit('simple', 'after'); + dispatcher.emit("simple", "after"); - expect(received).toEqual(['before']); + expect(received).toEqual(["before"]); }); it("should allow selective unsubscription", () => { @@ -380,22 +400,22 @@ describe("EventDispatcher (Type-Safe Event Bus)", () => { const first: string[] = []; const second: string[] = []; - const sub1 = dispatcher.on('simple', (text: string) => first.push(text)); - const sub2 = dispatcher.on('simple', (text: string) => second.push(text)); + const sub1 = dispatcher.on("simple", (text: string) => first.push(text)); + dispatcher.on("simple", (text: string) => second.push(text)); - dispatcher.emit('simple', '1'); + dispatcher.emit("simple", "1"); sub1.unsubscribe(); - dispatcher.emit('simple', '2'); + dispatcher.emit("simple", "2"); - expect(first).toEqual(['1']); - expect(second).toEqual(['1', '2']); + expect(first).toEqual(["1"]); + expect(second).toEqual(["1", "2"]); }); }); describe("Closing the Dispatcher", () => { it("should close subscriptions returned by on()", () => { const dispatcher = createEventDispatcher(); - const subscription = dispatcher.on('message', () => {}); + const subscription = dispatcher.on("message", () => {}); dispatcher.close(); @@ -405,13 +425,13 @@ describe("EventDispatcher (Type-Safe Event Bus)", () => { it("should support using syntax", () => { { using dispatcher = createEventDispatcher(); - dispatcher.emit('simple', 'test'); + dispatcher.emit("simple", "test"); } // Automatically disposed }); it("should support async using syntax", async () => { const dispatcher = createEventDispatcher(); - dispatcher.emit('simple', 'test'); + dispatcher.emit("simple", "test"); await dispatcher[Symbol.asyncDispose](); }); }); @@ -421,10 +441,10 @@ describe("EventDispatcher (Type-Safe Event Bus)", () => { const dispatcher = createEventDispatcher(); // These should work (TypeScript compile-time check) - dispatcher.emit('message', { text: 'test', priority: 1 }); - dispatcher.emit('status', { code: 200, message: 'OK' }); - dispatcher.emit('simple', 'string'); - dispatcher.emit('noPayload', undefined); + dispatcher.emit("message", { text: "test", priority: 1 }); + dispatcher.emit("status", { code: 200, message: "OK" }); + dispatcher.emit("simple", "string"); + dispatcher.emit("noPayload", undefined); // These would fail at compile time: // dispatcher.emit('message', 'wrong'); // Error: wrong type @@ -435,20 +455,23 @@ describe("EventDispatcher (Type-Safe Event Bus)", () => { it("should provide correct types to handlers", () => { const dispatcher = createEventDispatcher(); - dispatcher.on('message', (payload: { text: string; priority: number }) => { - // TypeScript knows payload is { text: string; priority: number } - expect(typeof payload.text).toBe('string'); - expect(typeof payload.priority).toBe('number'); - }); + dispatcher.on( + "message", + (payload: { text: string; priority: number }) => { + // TypeScript knows payload is { text: string; priority: number } + expect(typeof payload.text).toBe("string"); + expect(typeof payload.priority).toBe("number"); + }, + ); - dispatcher.on('status', (payload: { code: number; message: string }) => { + dispatcher.on("status", (payload: { code: number; message: string }) => { // TypeScript knows payload is { code: number; message: string } - expect(typeof payload.code).toBe('number'); - expect(typeof payload.message).toBe('string'); + expect(typeof payload.code).toBe("number"); + expect(typeof payload.message).toBe("string"); }); - dispatcher.emit('message', { text: 'test', priority: 1 }); - dispatcher.emit('status', { code: 200, message: 'OK' }); + dispatcher.emit("message", { text: "test", priority: 1 }); + dispatcher.emit("status", { code: 200, message: "OK" }); }); }); @@ -464,25 +487,31 @@ describe("EventDispatcher (Type-Safe Event Bus)", () => { const loginLog: string[] = []; const updateLog: string[] = []; - events.on('userLogin', ({ userId }: { userId: string; timestamp: number }) => { - loginLog.push(userId); - }); + events.on( + "userLogin", + ({ userId }: { userId: string; timestamp: number }) => { + loginLog.push(userId); + }, + ); - events.on('dataUpdated', ({ collection, count }: { collection: string; count: number }) => { - updateLog.push(`${collection}: ${count}`); - }); + events.on( + "dataUpdated", + ({ collection, count }: { collection: string; count: number }) => { + updateLog.push(`${collection}: ${count}`); + }, + ); - events.emit('userLogin', { userId: 'user123', timestamp: Date.now() }); - events.emit('dataUpdated', { collection: 'users', count: 5 }); - events.emit('userLogin', { userId: 'user456', timestamp: Date.now() }); + events.emit("userLogin", { userId: "user123", timestamp: Date.now() }); + events.emit("dataUpdated", { collection: "users", count: 5 }); + events.emit("userLogin", { userId: "user456", timestamp: Date.now() }); - expect(loginLog).toEqual(['user123', 'user456']); - expect(updateLog).toEqual(['users: 5']); + expect(loginLog).toEqual(["user123", "user456"]); + expect(updateLog).toEqual(["users: 5"]); }); it("should work for error/status notifications", () => { interface SystemEvents extends EventMap { - error: { code: string; message: string; severity: 'low' | 'high' }; + error: { code: string; message: string; severity: "low" | "high" }; warning: { message: string }; info: { message: string }; } @@ -491,22 +520,39 @@ describe("EventDispatcher (Type-Safe Event Bus)", () => { const errors: string[] = []; const warnings: string[] = []; - events.on('error', ({ code, severity }: { code: string; message: string; severity: 'low' | 'high' }) => { - if (severity === 'high') { - errors.push(code); - } - }); - - events.on('warning', ({ message }: { message: string }) => { + events.on( + "error", + ( + { code, severity }: { + code: string; + message: string; + severity: "low" | "high"; + }, + ) => { + if (severity === "high") { + errors.push(code); + } + }, + ); + + events.on("warning", ({ message }: { message: string }) => { warnings.push(message); }); - events.emit('error', { code: 'ERR_001', message: 'Test', severity: 'high' }); - events.emit('warning', { message: 'Watch out' }); - events.emit('error', { code: 'ERR_002', message: 'Test', severity: 'low' }); + events.emit("error", { + code: "ERR_001", + message: "Test", + severity: "high", + }); + events.emit("warning", { message: "Watch out" }); + events.emit("error", { + code: "ERR_002", + message: "Test", + severity: "low", + }); - expect(errors).toEqual(['ERR_001']); // Only high severity - expect(warnings).toEqual(['Watch out']); + expect(errors).toEqual(["ERR_001"]); // Only high severity + expect(warnings).toEqual(["Watch out"]); }); }); }); @@ -523,24 +569,24 @@ describe("waitForEvent()", () => { it("should resolve when the event fires", async () => { const dispatcher = createEventDispatcher(); - const promise = waitForEvent(dispatcher, 'ready'); + const promise = waitForEvent(dispatcher, "ready"); // Emit after a short delay setTimeout(() => { - dispatcher.emit('ready', { status: 'ok' }); + dispatcher.emit("ready", { status: "ok" }); }, 10); const result = await promise; - expect(result).toEqual({ status: 'ok' }); + expect(result).toEqual({ status: "ok" }); }); it("should resolve with correct payload type", async () => { const dispatcher = createEventDispatcher(); - const promise = waitForEvent(dispatcher, 'data'); + const promise = waitForEvent(dispatcher, "data"); setTimeout(() => { - dispatcher.emit('data', { value: 42 }); + dispatcher.emit("data", { value: 42 }); }, 10); const result = await promise; @@ -550,24 +596,24 @@ describe("waitForEvent()", () => { it("should only resolve for the matching event type", async () => { const dispatcher = createEventDispatcher(); - const promise = waitForEvent(dispatcher, 'ready'); + const promise = waitForEvent(dispatcher, "ready"); setTimeout(() => { - dispatcher.emit('data', { value: 1 }); - dispatcher.emit('ready', { status: 'ok' }); + dispatcher.emit("data", { value: 1 }); + dispatcher.emit("ready", { status: "ok" }); }, 10); const result = await promise; - expect(result).toEqual({ status: 'ok' }); + expect(result).toEqual({ status: "ok" }); }); it("should handle void payloads", async () => { const dispatcher = createEventDispatcher(); - const promise = waitForEvent(dispatcher, 'done'); + const promise = waitForEvent(dispatcher, "done"); setTimeout(() => { - dispatcher.emit('done', undefined); + dispatcher.emit("done", undefined); }, 10); const result = await promise; @@ -580,8 +626,8 @@ describe("waitForEvent()", () => { const dispatcher = createEventDispatcher(); const controller = new AbortController(); - const promise = waitForEvent(dispatcher, 'ready', { - signal: controller.signal + const promise = waitForEvent(dispatcher, "ready", { + signal: controller.signal, }); setTimeout(() => controller.abort(), 10); @@ -594,8 +640,8 @@ describe("waitForEvent()", () => { const controller = new AbortController(); controller.abort(); - const promise = waitForEvent(dispatcher, 'ready', { - signal: controller.signal + const promise = waitForEvent(dispatcher, "ready", { + signal: controller.signal, }); await expect(promise).rejects.toThrow(); @@ -605,8 +651,8 @@ describe("waitForEvent()", () => { const dispatcher = createEventDispatcher(); const controller = new AbortController(); - const promise = waitForEvent(dispatcher, 'ready', { - signal: controller.signal + const promise = waitForEvent(dispatcher, "ready", { + signal: controller.signal, }); controller.abort(); @@ -618,7 +664,7 @@ describe("waitForEvent()", () => { } // Emitting after abort should not affect anything - dispatcher.emit('ready', { status: 'ok' }); + dispatcher.emit("ready", { status: "ok" }); }); }); @@ -626,7 +672,7 @@ describe("waitForEvent()", () => { it("should resolve undefined when stream completes (default)", async () => { const dispatcher = createEventDispatcher(); - const promise = waitForEvent(dispatcher, 'ready'); + const promise = waitForEvent(dispatcher, "ready"); setTimeout(() => dispatcher.close(), 10); @@ -637,27 +683,29 @@ describe("waitForEvent()", () => { it("should reject when stream completes with throwOnClose", async () => { const dispatcher = createEventDispatcher(); - const promise = waitForEvent(dispatcher, 'ready', { - throwOnClose: true + const promise = waitForEvent(dispatcher, "ready", { + throwOnClose: true, }); setTimeout(() => dispatcher.close(), 10); - await expect(promise).rejects.toThrow('Stream closed'); - await expect(promise).rejects.toThrow('ready'); + await expect(promise).rejects.toThrow("Stream closed"); + await expect(promise).rejects.toThrow("ready"); }); }); describe("Error Handling", () => { it("should reject when the stream errors", async () => { - const testError = new Error('Stream error'); + const testError = new Error("Stream error"); const bus = { - events: new Observable<{ type: keyof TestEvents; payload: TestEvents[keyof TestEvents] }>((observer) => { + events: new Observable< + { type: keyof TestEvents; payload: TestEvents[keyof TestEvents] } + >((observer) => { observer.error(testError); }), }; - await expect(waitForEvent(bus, 'ready')).rejects.toThrow('Stream error'); + await expect(waitForEvent(bus, "ready")).rejects.toThrow("Stream error"); }); }); @@ -666,28 +714,28 @@ describe("waitForEvent()", () => { const dispatcher = createEventDispatcher(); // Emit first - dispatcher.emit('ready', { status: 'already done' }); + dispatcher.emit("ready", { status: "already done" }); // Then wait - should not resolve with old event - const promise = waitForEvent(dispatcher, 'ready'); + const promise = waitForEvent(dispatcher, "ready"); setTimeout(() => { - dispatcher.emit('ready', { status: 'new event' }); + dispatcher.emit("ready", { status: "new event" }); }, 10); const result = await promise; - expect(result?.status).toBe('new event'); + expect(result?.status).toBe("new event"); }); it("should handle multiple events of same type", async () => { const dispatcher = createEventDispatcher(); - const promise = waitForEvent(dispatcher, 'data'); + const promise = waitForEvent(dispatcher, "data"); setTimeout(() => { - dispatcher.emit('data', { value: 1 }); - dispatcher.emit('data', { value: 2 }); - dispatcher.emit('data', { value: 3 }); + dispatcher.emit("data", { value: 1 }); + dispatcher.emit("data", { value: 2 }); + dispatcher.emit("data", { value: 3 }); }, 10); const result = await promise; @@ -699,29 +747,29 @@ describe("waitForEvent()", () => { it("should cleanup subscription after resolving", async () => { const dispatcher = createEventDispatcher(); - const promise = waitForEvent(dispatcher, 'ready'); + const promise = waitForEvent(dispatcher, "ready"); setTimeout(() => { - dispatcher.emit('ready', { status: 'ok' }); + dispatcher.emit("ready", { status: "ok" }); }, 10); await promise; // After resolving, subscription should be cleaned up // Subsequent emits should not cause issues - dispatcher.emit('ready', { status: 'after' }); + dispatcher.emit("ready", { status: "after" }); }); it("should remove abort listener after completion", async () => { const dispatcher = createEventDispatcher(); const controller = new AbortController(); - const promise = waitForEvent(dispatcher, 'ready', { - signal: controller.signal + const promise = waitForEvent(dispatcher, "ready", { + signal: controller.signal, }); setTimeout(() => { - dispatcher.emit('ready', { status: 'ok' }); + dispatcher.emit("ready", { status: "ok" }); }, 10); await promise; @@ -738,34 +786,38 @@ describe("waitForEvent()", () => { describe("withReplay() - Basic Smoke Tests", () => { describe("Basic Replay", () => { it("should replay last value to new subscribers", async () => { - const source = new Observable((observer: { next: (value: number) => void; complete: () => void }) => { - observer.next(1); - observer.next(2); - observer.next(3); - observer.complete(); - }); + const source = new Observable( + (observer: { next: (value: number) => void; complete: () => void }) => { + observer.next(1); + observer.next(2); + observer.next(3); + observer.complete(); + }, + ); - const replayed = withReplay(source, { count: 2, mode: 'eager' }); + const replayed = withReplay(source, { count: 2, mode: "eager" }); // Let source complete - await new Promise(resolve => setTimeout(resolve, 10)); + await new Promise((resolve) => setTimeout(resolve, 10)); // New subscriber should get last 2 values const received: number[] = []; replayed.subscribe({ - next: (v: number) => received.push(v) + next: (v: number) => received.push(v), }); expect(received).toEqual([2, 3]); }); it("should multicast to multiple subscribers", () => { - const source = new Observable((observer: { next: (value: number) => void }) => { - observer.next(1); - observer.next(2); - }); + const source = new Observable( + (observer: { next: (value: number) => void }) => { + observer.next(1); + observer.next(2); + }, + ); - const replayed = withReplay(source, { count: 10, mode: 'eager' }); + const replayed = withReplay(source, { count: 10, mode: "eager" }); const received1: number[] = []; const received2: number[] = []; @@ -782,8 +834,8 @@ describe("withReplay() - Basic Smoke Tests", () => { it("should throw on invalid count", () => { const source = Observable.of(1, 2, 3); - expect(() => withReplay(source, { count: 0 })).toThrow('positive'); - expect(() => withReplay(source, { count: -1 })).toThrow('positive'); + expect(() => withReplay(source, { count: 0 })).toThrow("positive"); + expect(() => withReplay(source, { count: -1 })).toThrow("positive"); }); }); }); diff --git a/tests/helpers/operations/timing_test.ts b/tests/helpers/operations/timing_test.ts index d106943..f8e6bf7 100644 --- a/tests/helpers/operations/timing_test.ts +++ b/tests/helpers/operations/timing_test.ts @@ -1,8 +1,13 @@ -import { test, expect } from "@libs/testing"; -import { delay as stdDelay } from "@std/async"; +// deno-lint-ignore-file no-import-prefix +import { expect, test } from "@libs/testing"; +import { delay as stdDelay } from "jsr:@std/async@^1"; import { Observable } from "../../../observable.ts"; -import { delay, debounce, throttle } from "../../../helpers/operations/timing.ts"; +import { + debounce, + delay, + throttle, +} from "../../../helpers/operations/timing.ts"; import { ignoreErrors } from "../../../helpers/operations/errors.ts"; import { pipe } from "../../../helpers/pipe.ts"; @@ -16,11 +21,13 @@ async function collectValues(obs: Observable): Promise { } // Helper to measure timing -function measureTime(fn: () => Promise): Promise<{ result: T; duration: number }> { +function measureTime( + fn: () => Promise, +): Promise<{ result: T; duration: number }> { const start = Date.now(); - return fn().then(result => ({ + return fn().then((result) => ({ result, - duration: Date.now() - start + duration: Date.now() - start, })); } @@ -34,7 +41,7 @@ test("delay postpones emission by specified time", async () => { const [{ result: values, duration }] = await Promise.all([ measureTime(() => collectValues(result)), - stdDelay(500) + stdDelay(500), ]); expect(values).toEqual([1, 2, 3]); @@ -48,7 +55,7 @@ test("delay with zero time emits immediately", async () => { const [{ result: values, duration }] = await Promise.all([ measureTime(() => collectValues(result)), - stdDelay(500) + stdDelay(500), ]); expect(values).toEqual([1, 2, 3]); @@ -61,12 +68,12 @@ test("delay with zero time emits immediately", async () => { test("debounce emits last value after quiet period", async () => { // Create a source that emits values with timing - const source = new Observable(observer => { + const source = new Observable((observer) => { observer.next(1); setTimeout(() => observer.next(2), 50); setTimeout(() => observer.next(3), 100); setTimeout(() => observer.complete(), 200); - return () => { }; + return () => {}; }); const result = pipe(source, ignoreErrors(), debounce(75)); @@ -82,13 +89,13 @@ test("debounce emits last value after quiet period", async () => { test("throttle limits emission rate", async () => { // Create a source that emits values rapidly - const source = new Observable(observer => { + const source = new Observable((observer) => { observer.next(1); setTimeout(() => observer.next(2), 10); setTimeout(() => observer.next(3), 20); setTimeout(() => observer.next(4), 30); setTimeout(() => observer.complete(), 150); - return () => { }; + return () => {}; }); const result = pipe(source, ignoreErrors(), throttle(50)); diff --git a/tests/helpers/operators_bdd_test.ts b/tests/helpers/operators_bdd_test.ts index c29fc6f..52d1217 100644 --- a/tests/helpers/operators_bdd_test.ts +++ b/tests/helpers/operators_bdd_test.ts @@ -1,29 +1,34 @@ /** * Tests for `createOperator` and `createStatefulOperator` - the core functions for building * custom Observable operators that wrap Web Streams with ergonomic error handling. - * + * * Operators transform, filter, or combine streams like Array.map but for async data. This suite - * validates basic transformations, state management across values, all four error modes + * validates basic transformations, state management across values, all four error modes * (pass-through, ignore, throw, manual), operator composition, and edge cases like empty streams * and async transforms. - * + * * Error modes control failure behavior: pass-through wraps errors as ObservableError values * (preserves buffered data, good for debugging), ignore silently drops errors (filtering noisy * sources), throw stops immediately (fail-fast validation), and manual gives full control * (custom error handling). - * + * * Built on Web Streams TransformStream for automatic backpressure (slow consumers don't overwhelm * fast producers), chunk-by-chunk processing (memory efficient), and cross-platform compatibility. */ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "@std/expect"; +// deno-lint-ignore-file no-import-prefix +import { describe, it } from "jsr:@std/testing@^1/bdd"; +import { expect } from "jsr:@std/expect@^1"; import { Observable, pull } from "../../observable.ts"; -import { createOperator, createStatefulOperator } from "../../helpers/operators.ts"; +import { + createOperator, + createStatefulOperator, +} from "../../helpers/operators.ts"; import { pipe } from "../../helpers/pipe.ts"; import { ignoreErrors } from "../../helpers/operations/errors.ts"; -import { ObservableError, isObservableError } from "../../error.ts"; +import type { ObservableError } from "../../error.ts"; +import { isObservableError } from "../../error.ts"; /** * Collects all observable values into an array using async iteration (for await...of). @@ -39,7 +44,9 @@ async function collectValues(obs: Observable): Promise { /** * Collects values without throwing when the stream emits ObservableError values. */ -async function collectValuesAllowErrors(obs: Observable): Promise> { +async function collectValuesAllowErrors( + obs: Observable, +): Promise> { const values: Array = []; for await (const value of pull(obs, { throwError: false })) { values.push(value); @@ -47,31 +54,16 @@ async function collectValuesAllowErrors(obs: Observable): Promise { describe("Basic Transformation", () => { it("should create an operator that transforms values", async () => { // Think of this like Array.map(x => x * 2) // but for streams that arrive over time const double = createOperator({ - name: 'double', + name: "double", transform(chunk, controller) { controller.enqueue(chunk * 2); - } + }, }); const source = Observable.of(1, 2, 3); @@ -84,13 +76,13 @@ describe("createOperator()", () => { it("should create an operator that filters values", async () => { // Only let even numbers through const evens = createOperator({ - name: 'evens', + name: "evens", transform(chunk, controller) { if (chunk % 2 === 0) { controller.enqueue(chunk); } // If we don't enqueue, the value is filtered out - } + }, }); const source = Observable.of(1, 2, 3, 4, 5, 6); @@ -104,11 +96,11 @@ describe("createOperator()", () => { // Each input value becomes multiple output values // Like flatMap but synchronous const duplicate = createOperator({ - name: 'duplicate', + name: "duplicate", transform(chunk, controller) { controller.enqueue(chunk); controller.enqueue(chunk); - } + }, }); const source = Observable.of(1, 2, 3); @@ -120,10 +112,10 @@ describe("createOperator()", () => { it("should handle empty streams", async () => { const double = createOperator({ - name: 'double', + name: "double", transform(chunk, controller) { controller.enqueue(chunk * 2); - } + }, }); // Observable.of() with no args creates an empty stream @@ -136,10 +128,10 @@ describe("createOperator()", () => { it("should handle single value streams", async () => { const double = createOperator({ - name: 'double', + name: "double", transform(chunk, controller) { controller.enqueue(chunk * 2); - } + }, }); const source = Observable.of(42); @@ -155,35 +147,37 @@ describe("createOperator()", () => { // Sometimes you want to use an existing TransformStream // This is useful for integrating with other stream-based APIs const stringify = createOperator({ - name: 'stringify', - stream: () => new TransformStream({ - transform(chunk: number, controller) { - controller.enqueue(String(chunk)); - } - }) + name: "stringify", + stream: () => + new TransformStream({ + transform(chunk: number, controller) { + controller.enqueue(String(chunk)); + }, + }), }); const source = Observable.of(1, 2, 3); const result = pipe(source, ignoreErrors(), stringify); const values = await collectValuesAllowErrors(result); - expect(values).toEqual(['1', '2', '3']); + expect(values).toEqual(["1", "2", "3"]); }); it("should support TransformStream with custom queuing strategy", async () => { // You can provide a custom queuing strategy for backpressure control // This is like setting a buffer size for the assembly line const bufferOne = createOperator({ - name: 'bufferOne', - stream: () => new TransformStream( - { - transform(chunk, controller) { - controller.enqueue(chunk); - } - }, - // Queuing strategy limits how many items can be buffered - { highWaterMark: 1 } - ) + name: "bufferOne", + stream: () => + new TransformStream( + { + transform(chunk, controller) { + controller.enqueue(chunk); + }, + }, + // Queuing strategy limits how many items can be buffered + { highWaterMark: 1 }, + ), }); const source = Observable.of(1, 2, 3); @@ -200,58 +194,58 @@ describe("createOperator()", () => { // Errors become special values in the stream // Like putting errors in bubble wrap so they're safe to handle const errorOnTwo = createOperator({ - name: 'errorOnTwo', - errorMode: 'pass-through', // This is the default + name: "errorOnTwo", + errorMode: "pass-through", // This is the default transform(chunk, controller) { if (chunk === 2) { - throw new Error('Cannot process 2'); + throw new Error("Cannot process 2"); } controller.enqueue(chunk); - } + }, }); const source = Observable.of(1, 2, 3); const result = pipe(source, ignoreErrors(), errorOnTwo); const values = await collectValuesAllowErrors(result); - + // We should get 1, an error, and 3 expect(values).toHaveLength(3); expect(values[0]).toBe(1); expect(isObservableError(values[1])).toBe(true); expect(values[2]).toBe(3); - + // The error should have context if (isObservableError(values[1])) { - expect(values[1].operator).toBe('operator:errorOnTwo'); - expect(values[1].message).toContain('Cannot process 2'); + expect(values[1].operator).toBe("operator:errorOnTwo"); + expect(values[1].message).toContain("Cannot process 2"); } }); it("should preserve all values even when errors occur", async () => { // This is key: buffered values aren't lost when errors happen const errorOnEvens = createOperator({ - name: 'errorOnEvens', - errorMode: 'pass-through', + name: "errorOnEvens", + errorMode: "pass-through", transform(chunk, controller) { if (chunk % 2 === 0) { - throw new Error('No evens allowed'); + throw new Error("No evens allowed"); } controller.enqueue(chunk); - } + }, }); const source = Observable.of(1, 2, 3, 4, 5); const result = pipe(source, ignoreErrors(), errorOnEvens); const values = await collectValuesAllowErrors(result); - + // Should have all 5 items: 1, error, 3, error, 5 expect(values).toHaveLength(5); - + const errorCount = values.filter(isObservableError).length; - const valueCount = values.filter(v => !isObservableError(v)).length; - + const valueCount = values.filter((v) => !isObservableError(v)).length; + expect(errorCount).toBe(2); // errors for 2 and 4 expect(valueCount).toBe(3); // values 1, 3, 5 }); @@ -261,35 +255,35 @@ describe("createOperator()", () => { it("should silently drop errors and continue processing", async () => { // Errors are filtered out, like they never happened const errorOnTwo = createOperator({ - name: 'errorOnTwo', - errorMode: 'ignore', + name: "errorOnTwo", + errorMode: "ignore", transform(chunk, controller) { if (chunk === 2) { - throw new Error('Cannot process 2'); + throw new Error("Cannot process 2"); } controller.enqueue(chunk); - } + }, }); const source = Observable.of(1, 2, 3); const result = pipe(source, ignoreErrors(), errorOnTwo); const values = await collectValues(result); - + // The error is completely removed from the stream expect(values).toEqual([1, 3]); }); it("should handle multiple consecutive errors", async () => { const errorOnEvens = createOperator({ - name: 'errorOnEvens', - errorMode: 'ignore', + name: "errorOnEvens", + errorMode: "ignore", transform(chunk, controller) { if (chunk % 2 === 0) { - throw new Error('No evens'); + throw new Error("No evens"); } controller.enqueue(chunk); - } + }, }); const source = Observable.of(1, 2, 3, 4, 5, 6, 7); @@ -301,11 +295,11 @@ describe("createOperator()", () => { it("should handle all values being errors", async () => { const alwaysError = createOperator({ - name: 'alwaysError', - errorMode: 'ignore', + name: "alwaysError", + errorMode: "ignore", transform(_chunk, _controller) { - throw new Error('Always fails'); - } + throw new Error("Always fails"); + }, }); const source = Observable.of(1, 2, 3); @@ -321,28 +315,27 @@ describe("createOperator()", () => { it("should let you handle errors yourself", async () => { // Full control - you decide what to do with errors const manualErrorHandler = createOperator({ - name: 'manualErrorHandler', - errorMode: 'manual', + name: "manualErrorHandler", + errorMode: "manual", transform(chunk, controller) { try { if (chunk === 2) { - throw new Error('Two is problematic'); + throw new Error("Two is problematic"); } controller.enqueue(chunk); } catch (err) { // Custom error handling: convert to string controller.enqueue(`ERROR: ${(err as Error).message}`); } - } + }, }); const source = Observable.of(1, 2, 3); const result = pipe(source, ignoreErrors(), manualErrorHandler); const values = await collectValues(result); - expect(values).toEqual([1, 'ERROR: Two is problematic', 3]); + expect(values).toEqual([1, "ERROR: Two is problematic", 3]); }); - }); }); @@ -350,12 +343,12 @@ describe("createOperator()", () => { it("should handle async transforms", async () => { // Sometimes your transform needs to do async work const asyncDouble = createOperator({ - name: 'asyncDouble', + name: "asyncDouble", async transform(chunk, controller) { // Simulate async operation (e.g., API call) - await new Promise(resolve => setTimeout(resolve, 1)); + await new Promise((resolve) => setTimeout(resolve, 1)); controller.enqueue(chunk * 2); - } + }, }); const source = Observable.of(1, 2, 3); @@ -367,15 +360,15 @@ describe("createOperator()", () => { it("should handle async errors in pass-through mode", async () => { const asyncError = createOperator({ - name: 'asyncError', - errorMode: 'pass-through', + name: "asyncError", + errorMode: "pass-through", async transform(chunk, controller) { - await new Promise(resolve => setTimeout(resolve, 1)); + await new Promise((resolve) => setTimeout(resolve, 1)); if (chunk === 2) { - throw new Error('Async error'); + throw new Error("Async error"); } controller.enqueue(chunk); - } + }, }); const source = Observable.of(1, 2, 3); @@ -391,26 +384,26 @@ describe("createOperator()", () => { it("should compose multiple operators in a pipeline", async () => { // Like chaining Array methods: arr.map().map() const addOne = createOperator({ - name: 'addOne', + name: "addOne", transform(chunk, controller) { controller.enqueue(chunk + 1); - } + }, }); const double = createOperator({ - name: 'double', + name: "double", transform(chunk, controller) { controller.enqueue(chunk * 2); - } + }, }); const source = Observable.of(1, 2, 3); const result = pipe( source, ignoreErrors(), - addOne, // 2, 3, 4 + addOne, // 2, 3, 4 ignoreErrors(), - double // 4, 6, 8 + double, // 4, 6, 8 ); const values = await collectValuesAllowErrors(result); @@ -419,30 +412,30 @@ describe("createOperator()", () => { it("should handle errors in operator chains", async () => { const addOne = createOperator({ - name: 'addOne', + name: "addOne", transform(chunk, controller) { controller.enqueue(chunk + 1); - } + }, }); const errorOnThree = createOperator({ - name: 'errorOnThree', - errorMode: 'pass-through', + name: "errorOnThree", + errorMode: "pass-through", transform(chunk, controller) { if (chunk === 3) { - throw new Error('Three not allowed'); + throw new Error("Three not allowed"); } controller.enqueue(chunk); - } + }, }); const source = Observable.of(1, 2, 3); const result = pipe( source, ignoreErrors(), - addOne, // 2, 3, 4 + addOne, // 2, 3, 4 ignoreErrors(), - errorOnThree // 2, error, 4 + errorOnThree, // 2, error, 4 ); const values = await collectValuesAllowErrors(result); @@ -459,13 +452,17 @@ describe("createStatefulOperator()", () => { it("should maintain state across transformations", async () => { // Stateful operators remember things between values // Like Array.reduce() but for streams - const runningSum = createStatefulOperator({ - name: 'runningSum', + const runningSum = createStatefulOperator< + number, + number, + { sum: number } + >({ + name: "runningSum", createState: () => ({ sum: 0 }), transform(chunk, state, controller) { state.sum += chunk; controller.enqueue(state.sum); - } + }, }); const source = Observable.of(1, 2, 3, 4); @@ -479,16 +476,18 @@ describe("createStatefulOperator()", () => { it("should maintain separate state per stream", async () => { // Each subscription gets its own state // This is important for cold observables - const counter = createStatefulOperator({ - name: 'counter', - createState: () => ({ count: 0 }), - transform(chunk, state, controller) { - state.count++; - controller.enqueue(`${state.count}:${chunk}`); - } - }); + const counter = createStatefulOperator( + { + name: "counter", + createState: () => ({ count: 0 }), + transform(chunk, state, controller) { + state.count++; + controller.enqueue(`${state.count}:${chunk}`); + }, + }, + ); - const source = Observable.of('a', 'b', 'c'); + const source = Observable.of("a", "b", "c"); const stream1 = pipe(source, ignoreErrors(), counter); const stream2 = pipe(source, ignoreErrors(), counter); @@ -496,8 +495,8 @@ describe("createStatefulOperator()", () => { const values1 = await collectValues(stream1); const values2 = await collectValues(stream2); - expect(values1).toEqual(['1:a', '2:b', '3:c']); - expect(values2).toEqual(['1:a', '2:b', '3:c']); + expect(values1).toEqual(["1:a", "2:b", "3:c"]); + expect(values2).toEqual(["1:a", "2:b", "3:c"]); }); it("should support complex state objects", async () => { @@ -508,11 +507,11 @@ describe("createStatefulOperator()", () => { } const bufferTwo = createStatefulOperator({ - name: 'bufferTwo', + name: "bufferTwo", createState: () => ({ buffer: [], maxSize: 2 }), transform(chunk, state, controller) { state.buffer.push(chunk); - + if (state.buffer.length === state.maxSize) { // Emit the buffered values as an array controller.enqueue([...state.buffer]); @@ -524,7 +523,7 @@ describe("createStatefulOperator()", () => { if (state.buffer.length > 0) { controller.enqueue([...state.buffer]); } - } + }, }); const source = Observable.of(1, 2, 3, 4, 5); @@ -538,22 +537,24 @@ describe("createStatefulOperator()", () => { it("should call createState only once per stream", async () => { let createStateCallCount = 0; - const trackCalls = createStatefulOperator({ - name: 'trackCalls', - createState: () => { - createStateCallCount++; - return { id: createStateCallCount }; + const trackCalls = createStatefulOperator( + { + name: "trackCalls", + createState: () => { + createStateCallCount++; + return { id: createStateCallCount }; + }, + transform(chunk, state, controller) { + controller.enqueue(chunk * state.id); + }, }, - transform(chunk, state, controller) { - controller.enqueue(chunk * state.id); - } - }); + ); const source = Observable.of(1, 2, 3); const result = pipe(source, ignoreErrors(), trackCalls); await collectValues(result); - + // State should be created exactly once expect(createStateCallCount).toBe(1); }); @@ -563,8 +564,12 @@ describe("createStatefulOperator()", () => { it("should call flush when stream completes", async () => { let flushed = false; - const withFlush = createStatefulOperator({ - name: 'withFlush', + const withFlush = createStatefulOperator< + number, + number | string, + { items: number[] } + >({ + name: "withFlush", createState: () => ({ items: [] }), transform(chunk, state, controller) { state.items.push(chunk); @@ -573,22 +578,26 @@ describe("createStatefulOperator()", () => { flush(state, controller) { flushed = true; controller.enqueue(`Summary: ${state.items.length} items`); - } + }, }); const source = Observable.of(1, 2, 3); const result = pipe(source, ignoreErrors(), withFlush); const values = await collectValuesAllowErrors(result); - + expect(flushed).toBe(true); - expect(values).toEqual([1, 2, 3, 'Summary: 3 items']); + expect(values).toEqual([1, 2, 3, "Summary: 3 items"]); }); it("should use flush to emit remaining buffered items", async () => { // Common pattern: buffer items and flush remaining on completion - const bufferThree = createStatefulOperator({ - name: 'bufferThree', + const bufferThree = createStatefulOperator< + number, + number[], + { buffer: number[] } + >({ + name: "bufferThree", createState: () => ({ buffer: [] }), transform(chunk, state, controller) { state.buffer.push(chunk); @@ -601,7 +610,7 @@ describe("createStatefulOperator()", () => { if (state.buffer.length > 0) { controller.enqueue([...state.buffer]); } - } + }, }); const source = Observable.of(1, 2, 3, 4, 5, 6, 7); @@ -616,31 +625,31 @@ describe("createStatefulOperator()", () => { describe("Error Handling in Stateful Operators", () => { it("should handle errors in transform with pass-through mode", async () => { const errorOnEven = createStatefulOperator< - number, - number | ObservableError, + number, + number | ObservableError, { count: number } >({ - name: 'errorOnEven', + name: "errorOnEven", createState: () => ({ count: 0 }), - errorMode: 'pass-through', + errorMode: "pass-through", transform(chunk, state, controller) { state.count++; if (chunk % 2 === 0) { - throw new Error('Even numbers not allowed'); + throw new Error("Even numbers not allowed"); } controller.enqueue(chunk); - } + }, }); const source = Observable.of(1, 2, 3, 4, 5); const result = pipe(source, ignoreErrors(), errorOnEven); const values = await collectValuesAllowErrors(result); - + // Should have errors for 2 and 4 const errors = values.filter(isObservableError); - const nums = values.filter(v => typeof v === 'number'); - + const nums = values.filter((v) => typeof v === "number"); + expect(errors).toHaveLength(2); expect(nums).toEqual([1, 3, 5]); }); @@ -652,23 +661,23 @@ describe("createStatefulOperator()", () => { number | ObservableError, { total: number } >({ - name: 'countWithErrors', + name: "countWithErrors", createState: () => ({ total: 0 }), - errorMode: 'pass-through', + errorMode: "pass-through", transform(chunk, state, controller) { state.total++; if (chunk === 2) { - throw new Error('Two causes error'); + throw new Error("Two causes error"); } controller.enqueue(chunk * state.total); - } + }, }); const source = Observable.of(1, 2, 3); const result = pipe(source, ignoreErrors(), countWithErrors); const values = await collectValuesAllowErrors(result); - + // 1 * 1 = 1, error (but total=2 now), 3 * 3 = 9 expect(values[0]).toBe(1); expect(isObservableError(values[1])).toBe(true); @@ -676,17 +685,21 @@ describe("createStatefulOperator()", () => { }); it("should handle errors in ignore mode", async () => { - const silentErrors = createStatefulOperator({ - name: 'silentErrors', + const silentErrors = createStatefulOperator< + number, + number, + { passed: number } + >({ + name: "silentErrors", createState: () => ({ passed: 0 }), - errorMode: 'ignore', + errorMode: "ignore", transform(chunk, state, controller) { if (chunk === 2) { - throw new Error('Silent error'); + throw new Error("Silent error"); } state.passed++; controller.enqueue(chunk); - } + }, }); const source = Observable.of(1, 2, 3); @@ -701,29 +714,33 @@ describe("createStatefulOperator()", () => { it("should implement a moving average", async () => { // Moving average: keep last N values and output their average // Common in data analysis and signal processing - const movingAvg = createStatefulOperator({ - name: 'movingAvg', + const movingAvg = createStatefulOperator< + number, + number, + { window: number[] } + >({ + name: "movingAvg", createState: () => ({ window: [] }), transform(chunk, state, controller) { state.window.push(chunk); - + // Keep only last 3 values if (state.window.length > 3) { state.window.shift(); } - + // Calculate and emit average const sum = state.window.reduce((a, b) => a + b, 0); const avg = sum / state.window.length; controller.enqueue(avg); - } + }, }); const source = Observable.of(1, 2, 3, 4, 5); const result = pipe(source, ignoreErrors(), movingAvg); const values = await collectValues(result); - + // Window: [1], [1,2], [1,2,3], [2,3,4], [3,4,5] // Avgs: 1, 1.5, 2, 3, 4 expect(values).toEqual([1, 1.5, 2, 3, 4]); @@ -733,14 +750,14 @@ describe("createStatefulOperator()", () => { // Only emit values that are different from the previous one // Like Array filter but comparing to previous element const dedupe = createStatefulOperator({ - name: 'dedupe', + name: "dedupe", createState: () => ({}), transform(chunk, state, controller) { if (state.last !== chunk) { controller.enqueue(chunk); state.last = chunk; } - } + }, }); const source = Observable.of(1, 1, 2, 2, 2, 3, 1, 1); @@ -753,16 +770,17 @@ describe("createStatefulOperator()", () => { it("should implement a rate limiter", async () => { // Only let through N items total // Like Array.slice(0, N) but for streams - const takeN = (n: number) => createStatefulOperator({ - name: 'takeN', - createState: () => ({ count: 0 }), - transform(chunk, state, controller) { - if (state.count < n) { - controller.enqueue(chunk); - state.count++; - } - } - }); + const takeN = (n: number) => + createStatefulOperator({ + name: "takeN", + createState: () => ({ count: 0 }), + transform(chunk, state, controller) { + if (state.count < n) { + controller.enqueue(chunk); + state.count++; + } + }, + }); const source = Observable.of(1, 2, 3, 4, 5); const result = pipe(source, ignoreErrors(), takeN(3)); @@ -779,23 +797,24 @@ describe("createStatefulOperator()", () => { size: number; } - const batch = (size: number) => createStatefulOperator({ - name: 'batch', - createState: () => ({ batch: [], size }), - transform(chunk, state, controller) { - state.batch.push(chunk); - - if (state.batch.length >= state.size) { - controller.enqueue([...state.batch]); - state.batch = []; - } - }, - flush(state, controller) { - if (state.batch.length > 0) { - controller.enqueue([...state.batch]); - } - } - }); + const batch = (size: number) => + createStatefulOperator({ + name: "batch", + createState: () => ({ batch: [], size }), + transform(chunk, state, controller) { + state.batch.push(chunk); + + if (state.batch.length >= state.size) { + controller.enqueue([...state.batch]); + state.batch = []; + } + }, + flush(state, controller) { + if (state.batch.length > 0) { + controller.enqueue([...state.batch]); + } + }, + }); const source = Observable.of(1, 2, 3, 4, 5, 6, 7); const result = pipe(source, ignoreErrors(), batch(3)); @@ -807,14 +826,16 @@ describe("createStatefulOperator()", () => { describe("Edge Cases", () => { it("should handle empty streams", async () => { - const counter = createStatefulOperator({ - name: 'counter', - createState: () => ({ count: 0 }), - transform(chunk, state, controller) { - state.count++; - controller.enqueue(state.count); - } - }); + const counter = createStatefulOperator( + { + name: "counter", + createState: () => ({ count: 0 }), + transform(_chunk, state, controller) { + state.count++; + controller.enqueue(state.count); + }, + }, + ); const source = Observable.of(); const result = pipe(source, ignoreErrors(), counter); @@ -824,12 +845,16 @@ describe("createStatefulOperator()", () => { }); it("should handle single value streams", async () => { - const wrapper = createStatefulOperator>({ - name: 'wrapper', + const wrapper = createStatefulOperator< + number, + { value: number }, + Record + >({ + name: "wrapper", createState: () => ({}), transform(chunk, _state, controller) { controller.enqueue({ value: chunk }); - } + }, }); const source = Observable.of(42); @@ -841,15 +866,19 @@ describe("createStatefulOperator()", () => { it("should handle rapid state changes", async () => { // State can change multiple times per value - const fibonacci = createStatefulOperator({ - name: 'fibonacci', + const fibonacci = createStatefulOperator< + number, + number, + { prev: number; curr: number } + >({ + name: "fibonacci", createState: () => ({ prev: 0, curr: 1 }), transform(_chunk, state, controller) { controller.enqueue(state.curr); const next = state.prev + state.curr; state.prev = state.curr; state.curr = next; - } + }, }); // Generate 5 fibonacci numbers diff --git a/tests/helpers/utils_bdd_test.ts b/tests/helpers/utils_bdd_test.ts index 88c2810..ec4547c 100644 --- a/tests/helpers/utils_bdd_test.ts +++ b/tests/helpers/utils_bdd_test.ts @@ -2,7 +2,7 @@ * Tests for stream utilities that connect the Observable and operator ecosystem - type guards * for distinguishing operator option types, stream conversion from arrays/iterables/async iterables, * error injection, and operator application with graceful error handling. - * + * * These utilities provide the plumbing between plain data and streams: type guards help TypeScript * narrow operator options (stream-based vs function-based), toStream converts any iterable into * ReadableStream, injectError safely wraps errors as values, and applyOperator handles failures @@ -10,15 +10,16 @@ * for cross-platform compatibility without low-level boilerplate. */ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "@std/expect"; +// deno-lint-ignore-file no-import-prefix +import { describe, it } from "jsr:@std/testing@^1/bdd"; +import { expect } from "jsr:@std/expect@^1"; import { - isTransformStreamOptions, + applyOperator, + injectError, isTransformFunctionOptions, + isTransformStreamOptions, toStream, - applyOperator, - injectError } from "../../helpers/utils.ts"; import type { CreateOperatorOptions } from "../../helpers/_types.ts"; import type { ObservableError } from "../../error.ts"; @@ -30,7 +31,7 @@ import { isObservableError } from "../../error.ts"; async function collectStream(stream: ReadableStream): Promise { const values: T[] = []; const reader = stream.getReader(); - + try { while (true) { const { done, value } = await reader.read(); @@ -40,7 +41,7 @@ async function collectStream(stream: ReadableStream): Promise { } finally { reader.releaseLock(); } - + return values; } @@ -49,12 +50,13 @@ describe("Type Guard Utilities", () => { it("should identify stream-based operator options", () => { // When you provide a pre-built TransformStream const streamOptions: CreateOperatorOptions = { - name: 'stringify', - stream: () => new TransformStream({ - transform(chunk: number, controller) { - controller.enqueue(String(chunk)); - } - }) + name: "stringify", + stream: () => + new TransformStream({ + transform(chunk: number, controller) { + controller.enqueue(String(chunk)); + }, + }), }; expect(isTransformStreamOptions(streamOptions)).toBe(true); @@ -63,10 +65,10 @@ describe("Type Guard Utilities", () => { it("should reject function-based operator options", () => { // When you provide a transform function instead const functionOptions: CreateOperatorOptions = { - name: 'stringify', + name: "stringify", transform(chunk, controller) { controller.enqueue(String(chunk)); - } + }, }; expect(isTransformStreamOptions(functionOptions)).toBe(false); @@ -76,11 +78,14 @@ describe("Type Guard Utilities", () => { // Edge case: what if someone provides both? // The 'stream' property takes precedence const bothOptions = { - name: 'both', + name: "both", stream: () => new TransformStream(), - transform(_chunk: number, _controller: TransformStreamDefaultController) { + transform( + _chunk: number, + _controller: TransformStreamDefaultController, + ) { // This would be ignored - } + }, }; // Should return true because 'stream' is present @@ -89,7 +94,7 @@ describe("Type Guard Utilities", () => { it("should handle minimal options without stream", () => { const minimalOptions = { - name: 'minimal', + name: "minimal", // No stream or transform } as unknown as CreateOperatorOptions; @@ -100,10 +105,10 @@ describe("Type Guard Utilities", () => { describe("isTransformFunctionOptions()", () => { it("should identify function-based operator options", () => { const functionOptions: CreateOperatorOptions = { - name: 'stringify', + name: "stringify", transform(chunk, controller) { controller.enqueue(String(chunk)); - } + }, }; expect(isTransformFunctionOptions(functionOptions)).toBe(true); @@ -111,8 +116,8 @@ describe("Type Guard Utilities", () => { it("should reject stream-based operator options", () => { const streamOptions: CreateOperatorOptions = { - name: 'stringify', - stream: () => new TransformStream() + name: "stringify", + stream: () => new TransformStream(), }; expect(isTransformFunctionOptions(streamOptions)).toBe(false); @@ -120,11 +125,14 @@ describe("Type Guard Utilities", () => { it("should handle options with both stream and transform", () => { const bothOptions = { - name: 'both', + name: "both", stream: () => new TransformStream(), - transform(_chunk: number, _controller: TransformStreamDefaultController) { + transform( + _chunk: number, + _controller: TransformStreamDefaultController, + ) { // Both are present - } + }, }; // Should return true because 'transform' is present @@ -133,12 +141,12 @@ describe("Type Guard Utilities", () => { it("should handle async transform functions", () => { const asyncOptions: CreateOperatorOptions = { - name: 'asyncStringify', + name: "asyncStringify", async transform(chunk, controller) { // Async transforms are still transform functions await Promise.resolve(); controller.enqueue(String(chunk)); - } + }, }; expect(isTransformFunctionOptions(asyncOptions)).toBe(true); @@ -148,14 +156,14 @@ describe("Type Guard Utilities", () => { describe("Type Guard Precision", () => { it("should narrow types correctly in TypeScript", () => { const options: CreateOperatorOptions = { - name: 'test', - stream: () => new TransformStream() + name: "test", + stream: () => new TransformStream(), }; // TypeScript should narrow the type if (isTransformStreamOptions(options)) { // In this branch, TypeScript knows options has 'stream' - expect(typeof options.stream).toBe('function'); + expect(typeof options.stream).toBe("function"); } if (isTransformFunctionOptions(options)) { @@ -198,7 +206,7 @@ describe("Stream Conversion Utilities", () => { it("should preserve value types", async () => { // Make sure different types work correctly - const strings = ['hello', 'world']; + const strings = ["hello", "world"]; const objects = [{ id: 1 }, { id: 2 }]; const booleans = [true, false, true]; @@ -219,7 +227,7 @@ describe("Stream Conversion Utilities", () => { const stream = toStream(numberGenerator()); const values = await collectStream(stream); - + expect(values).toEqual([1, 2, 3]); }); @@ -232,20 +240,20 @@ describe("Stream Conversion Utilities", () => { const stream = toStream(manyNumbers()); const reader = stream.getReader(); - + // Take only first 5 values const values: number[] = []; for (let i = 0; i < 5; i++) { const { value } = await reader.read(); if (value === undefined) { - throw new Error('Expected value from finite generator'); + throw new Error("Expected value from finite generator"); } if (isObservableError(value)) { throw value; } values.push(value); } - + reader.releaseLock(); expect(values).toEqual([1, 2, 3, 4, 5]); }); @@ -254,12 +262,12 @@ describe("Stream Conversion Utilities", () => { function* errorGenerator() { yield 1; yield 2; - throw new Error('Generator error'); + throw new Error("Generator error"); } const stream = toStream(errorGenerator()); const values: Array = []; - + const reader = stream.getReader(); try { while (true) { @@ -275,7 +283,7 @@ describe("Stream Conversion Utilities", () => { expect(values.length).toBeGreaterThanOrEqual(2); expect(values[0]).toBe(1); expect(values[1]).toBe(2); - + // The error should be wrapped const lastValue = values[values.length - 1]; expect(isObservableError(lastValue)).toBe(true); @@ -287,32 +295,32 @@ describe("Stream Conversion Utilities", () => { // Async iterables emit values asynchronously async function* asyncNumbers() { for (let i = 1; i <= 3; i++) { - await new Promise(resolve => setTimeout(resolve, 1)); + await new Promise((resolve) => setTimeout(resolve, 1)); yield i; } } const stream = toStream(asyncNumbers()); const values = await collectStream(stream); - + expect(values).toEqual([1, 2, 3]); }); it("should handle async generators with delays", async () => { async function* delayedValues() { - yield 'first'; - await new Promise(resolve => setTimeout(resolve, 10)); - yield 'second'; - await new Promise(resolve => setTimeout(resolve, 10)); - yield 'third'; + yield "first"; + await new Promise((resolve) => setTimeout(resolve, 10)); + yield "second"; + await new Promise((resolve) => setTimeout(resolve, 10)); + yield "third"; } const startTime = Date.now(); const stream = toStream(delayedValues()); const values = await collectStream(stream); const elapsed = Date.now() - startTime; - - expect(values).toEqual(['first', 'second', 'third']); + + expect(values).toEqual(["first", "second", "third"]); expect(elapsed).toBeGreaterThanOrEqual(20); // At least 20ms delay }); @@ -320,12 +328,12 @@ describe("Stream Conversion Utilities", () => { async function* rejectingGenerator() { yield 1; await Promise.resolve(); - throw new Error('Async error'); + throw new Error("Async error"); } const stream = toStream(rejectingGenerator()); const values: Array = []; - + const reader = stream.getReader(); try { while (true) { @@ -349,16 +357,16 @@ describe("Stream Conversion Utilities", () => { // Custom iterable with Symbol.iterator const customIterable = { *[Symbol.iterator]() { - yield 'a'; - yield 'b'; - yield 'c'; - } + yield "a"; + yield "b"; + yield "c"; + }, }; const stream = toStream(customIterable); const values = await collectStream(stream); - - expect(values).toEqual(['a', 'b', 'c']); + + expect(values).toEqual(["a", "b", "c"]); }); it("should handle Set as iterable", async () => { @@ -366,30 +374,30 @@ describe("Stream Conversion Utilities", () => { const set = new Set([1, 2, 3, 2, 1]); // Duplicates removed const stream = toStream(set); const values = await collectStream(stream); - + expect(values).toEqual([1, 2, 3]); }); it("should handle Map values", async () => { const map = new Map([ - ['a', 1], - ['b', 2], - ['c', 3] + ["a", 1], + ["b", 2], + ["c", 3], ]); - + const stream = toStream(map); const values = await collectStream(stream); - - expect(values).toEqual([['a', 1], ['b', 2], ['c', 3]]); + + expect(values).toEqual([["a", 1], ["b", 2], ["c", 3]]); }); it("should handle string as iterable", async () => { // Strings are iterable (characters) - const str = 'hello'; + const str = "hello"; const stream = toStream(str); const values = await collectStream(stream); - - expect(values).toEqual(['h', 'e', 'l', 'l', 'o']); + + expect(values).toEqual(["h", "e", "l", "l", "o"]); }); }); @@ -398,31 +406,31 @@ describe("Stream Conversion Utilities", () => { // Stream conversion should be memory-efficient const largeArray = Array.from({ length: 10000 }, (_, i) => i); const stream = toStream(largeArray); - + // Read only first 10 values to verify it works const reader = stream.getReader(); const values: number[] = []; - + for (let i = 0; i < 10; i++) { const { value } = await reader.read(); if (value === undefined) { - throw new Error('Expected value from large array stream'); + throw new Error("Expected value from large array stream"); } if (isObservableError(value)) { throw value; } values.push(value); } - + reader.releaseLock(); expect(values).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); }); it("should handle arrays with mixed types", async () => { - const mixed = [1, 'two', { three: 3 }, [4], null, undefined]; + const mixed = [1, "two", { three: 3 }, [4], null, undefined]; const stream = toStream(mixed); const values = await collectStream(stream); - + expect(values).toEqual(mixed); }); @@ -430,7 +438,7 @@ describe("Stream Conversion Utilities", () => { const nested = [[1, 2], [3, 4], [5, 6]]; const stream = toStream(nested); const values = await collectStream(stream); - + expect(values).toEqual(nested); }); }); @@ -441,31 +449,33 @@ describe("Error Injection Utilities", () => { it("should inject an error into a stream", async () => { // Create a simple stream const stream = toStream([1, 2, 3]); - + // Inject an error - const error = new Error('Injected error'); - const errorStream = stream.pipeThrough(injectError(error, 'test')); - + const error = new Error("Injected error"); + const errorStream = stream.pipeThrough(injectError(error, "test")); + const values = await collectStream(errorStream); - + // Should have an ObservableError at the start expect(values.length).toBeGreaterThan(0); expect(isObservableError(values[0])).toBe(true); - + if (isObservableError(values[0])) { - expect(values[0].message).toContain('Injected error'); - expect(values[0].operator).toBe('test'); + expect(values[0].message).toContain("Injected error"); + expect(values[0].operator).toBe("test"); } }); it("should inject error with context message", async () => { - const stream = toStream(['a', 'b']); - const error = new Error('Something went wrong'); - const contextMessage = 'operator:map:transform'; - - const errorStream = stream.pipeThrough(injectError(error, contextMessage)); + const stream = toStream(["a", "b"]); + const error = new Error("Something went wrong"); + const contextMessage = "operator:map:transform"; + + const errorStream = stream.pipeThrough( + injectError(error, contextMessage), + ); const values = await collectStream(errorStream); - + const firstValue = values[0]; if (isObservableError(firstValue)) { expect(firstValue.operator).toBe(contextMessage); @@ -475,10 +485,12 @@ describe("Error Injection Utilities", () => { it("should preserve original stream values after error", async () => { // The error is injected at the start, original values follow const stream = toStream([1, 2, 3]); - const errorStream = stream.pipeThrough(injectError(new Error('Test'), 'inject')); - + const errorStream = stream.pipeThrough( + injectError(new Error("Test"), "inject"), + ); + const values = await collectStream(errorStream); - + // First value is the error, rest are original values expect(values.length).toBe(4); // error + 3 values expect(isObservableError(values[0])).toBe(true); @@ -489,10 +501,12 @@ describe("Error Injection Utilities", () => { it("should handle injecting into empty stream", async () => { const stream = toStream([]); - const errorStream = stream.pipeThrough(injectError(new Error('Empty error'), 'test')); - + const errorStream = stream.pipeThrough( + injectError(new Error("Empty error"), "test"), + ); + const values = await collectStream(errorStream); - + // Should just have the error expect(values).toHaveLength(1); expect(isObservableError(values[0])).toBe(true); @@ -501,8 +515,10 @@ describe("Error Injection Utilities", () => { it("should wrap non-Error objects", async () => { const stream = toStream([1]); // Sometimes people throw strings or objects, not Error instances - const errorStream = stream.pipeThrough(injectError('string error', 'test')); - + const errorStream = stream.pipeThrough( + injectError("string error", "test"), + ); + const values = await collectStream(errorStream); expect(isObservableError(values[0])).toBe(true); }); @@ -513,112 +529,117 @@ describe("Operator Application Utilities", () => { describe("applyOperator()", () => { it("should apply operator successfully", async () => { const input = toStream([1, 2, 3]); - + // Create a simple doubling operator const double = (stream: ReadableStream) => { - return stream.pipeThrough(new TransformStream({ - transform(chunk, controller) { - controller.enqueue(chunk * 2); - } - })); + return stream.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + controller.enqueue(chunk * 2); + }, + }), + ); }; - + const result = applyOperator(input, double); const values = await collectStream(result); - + expect(values).toEqual([2, 4, 6]); }); it("should catch errors in operator application", async () => { const input = toStream([1, 2, 3]); - + // An operator that throws when applied const throwingOperator = (_stream: ReadableStream) => { - throw new Error('Operator application failed'); + throw new Error("Operator application failed"); }; - - const result = applyOperator(input, throwingOperator, { - message: 'pipe:testOperator' + + const result = applyOperator(input, throwingOperator, { + message: "pipe:testOperator", }); - + const values = await collectStream(result); - + // Error should be injected into the stream expect(values.length).toBeGreaterThan(0); const firstValue = values[0]; expect(isObservableError(firstValue)).toBe(true); - + if (isObservableError(firstValue)) { - expect(firstValue.message).toContain('Operator application failed'); + expect(firstValue.message).toContain("Operator application failed"); } }); it("should preserve values when operator succeeds", async () => { - const input = toStream(['a', 'b', 'c']); - + const input = toStream(["a", "b", "c"]); + const uppercase = (stream: ReadableStream) => { - return stream.pipeThrough(new TransformStream({ - transform(chunk, controller) { - controller.enqueue(chunk.toUpperCase()); - } - })); + return stream.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + controller.enqueue(chunk.toUpperCase()); + }, + }), + ); }; - + const result = applyOperator(input, uppercase); const values = await collectStream(result); - - expect(values).toEqual(['A', 'B', 'C']); + + expect(values).toEqual(["A", "B", "C"]); }); it("should work with identity operator (no-op)", async () => { const input = toStream([1, 2, 3]); - + // Identity: returns stream unchanged const identity = (stream: ReadableStream) => stream; - + const result = applyOperator(input, identity); const values = await collectStream(result); - + expect(values).toEqual([1, 2, 3]); }); it("should handle operator that filters all values", async () => { const input = toStream([1, 2, 3, 4, 5]); - + const filterAll = (stream: ReadableStream) => { - return stream.pipeThrough(new TransformStream({ - transform(_chunk, _controller) { - // Don't enqueue anything - filter everything out - } - })); + return stream.pipeThrough( + new TransformStream({ + transform(_chunk, _controller) { + // Don't enqueue anything - filter everything out + }, + }), + ); }; - + const result = applyOperator(input, filterAll); const values = await collectStream(result); - + expect(values).toEqual([]); }); it("should use custom error message", async () => { const input = toStream([1]); - + const failingOp = () => { - throw new Error('Boom'); + throw new Error("Boom"); }; - - const result = applyOperator(input, failingOp, { - message: 'custom:error:context' + + const result = applyOperator(input, failingOp, { + message: "custom:error:context", }); - + const values = await collectStream(result); const firstValue = values[0]; - + if (isObservableError(firstValue)) { - expect(firstValue.operator).toBe('custom:error:context'); + expect(firstValue.operator).toBe("custom:error:context"); } }); }); - }); describe("Integration Tests", () => { @@ -626,20 +647,22 @@ describe("Integration Tests", () => { it("should convert iterable → stream → observable → values", async () => { // This tests the full conversion pipeline const input = [1, 2, 3, 4, 5]; - + // Convert to stream const stream = toStream(input); - + // Apply transformation - const doubled = stream.pipeThrough(new TransformStream({ - transform(chunk: number, controller) { - controller.enqueue(chunk * 2); - } - })); - + const doubled = stream.pipeThrough( + new TransformStream({ + transform(chunk: number, controller) { + controller.enqueue(chunk * 2); + }, + }), + ); + // Collect results const values = await collectStream(doubled); - + expect(values).toEqual([2, 4, 6, 8, 10]); }); @@ -647,15 +670,15 @@ describe("Integration Tests", () => { function* generatorWithError() { yield 1; yield 2; - throw new Error('Generator failed'); + throw new Error("Generator failed"); } - + const stream = toStream(generatorWithError()); const values = await collectStream(stream); - + // Should have values and error expect(values.length).toBeGreaterThanOrEqual(2); - + // At least one should be an error const hasError = values.some(isObservableError); expect(hasError).toBe(true); @@ -663,25 +686,31 @@ describe("Integration Tests", () => { it("should support multiple operator applications", async () => { const input = toStream([1, 2, 3]); - - const double = (s: ReadableStream) => s.pipeThrough(new TransformStream({ - transform(chunk, controller) { - controller.enqueue(chunk * 2); - } - })); - - const addTen = (s: ReadableStream) => s.pipeThrough(new TransformStream({ - transform(chunk, controller) { - controller.enqueue(chunk + 10); - } - })); - + + const double = (s: ReadableStream) => + s.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + controller.enqueue(chunk * 2); + }, + }), + ); + + const addTen = (s: ReadableStream) => + s.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + controller.enqueue(chunk + 10); + }, + }), + ); + // Apply multiple operators const result = applyOperator( applyOperator(input, double), - addTen + addTen, ); - + const values = await collectStream(result); expect(values).toEqual([12, 14, 16]); // (1*2)+10, (2*2)+10, (3*2)+10 }); @@ -694,25 +723,25 @@ describe("Integration Tests", () => { yield i; } } - + const stream = toStream(largeSequence()); const reader = stream.getReader(); - + // Read just the first 100 values const values: number[] = []; for (let i = 0; i < 100; i++) { const { value } = await reader.read(); if (value === undefined) { - throw new Error('Expected value from large sequence stream'); + throw new Error("Expected value from large sequence stream"); } if (isObservableError(value)) { throw value; } values.push(value); } - + reader.releaseLock(); - + // Should get first 100 numbers expect(values.length).toBe(100); expect(values[0]).toBe(0); @@ -723,14 +752,14 @@ describe("Integration Tests", () => { // This tests that streams respect backpressure async function* slowProducer() { for (let i = 0; i < 5; i++) { - await new Promise(resolve => setTimeout(resolve, 10)); + await new Promise((resolve) => setTimeout(resolve, 10)); yield i; } } - + const stream = toStream(slowProducer()); const values = await collectStream(stream); - + expect(values).toEqual([0, 1, 2, 3, 4]); }); }); diff --git a/tests/integration_bdd_test.ts b/tests/integration_bdd_test.ts index 0d09fcd..1e78ca0 100644 --- a/tests/integration_bdd_test.ts +++ b/tests/integration_bdd_test.ts @@ -2,46 +2,30 @@ * Integration tests validating operator composition in real-world patterns. Unlike unit tests * focused on individual operators, these ensure complete pipelines handle order dependencies, * error propagation, state isolation, performance, and memory management correctly. - * + * * Patterns tested: search-as-you-type (debounce + filter + switchMap for request cancellation), * ETL pipelines (map + filter + batch), error recovery (catchErrors + fallback), rate limiting * (throttle + batch + concurrency), aggregation (scan + moving averages), fan-out/fan-in * (mergeMap for parallelism, concatMap for ordering). */ -import { describe, it } from '@std/testing/bdd'; -import { expect } from '@std/expect'; - -import { Observable } from '../observable.ts'; -import { pipe } from '../helpers/pipe.ts'; -import { - map, - filter, - scan, - tap, - take, -} from '../helpers/operations/core.ts'; -import { - debounce, - throttle -} from '../helpers/operations/timing.ts'; -import { - mergeMap, - switchMap, - concatMap -} from '../helpers/operations/combination.ts'; -import { - catchErrors, - ignoreErrors, -} from '../helpers/operations/errors.ts'; -import { - batch, -} from '../helpers/operations/batch.ts'; -import { - find, - unique -} from '../helpers/operations/conditional.ts'; -import { ObservableError, isObservableError } from '../error.ts'; +// deno-lint-ignore-file no-import-prefix +import { describe, it } from "jsr:@std/testing@^1/bdd"; +import { expect } from "jsr:@std/expect@^1"; + +import { Observable } from "../observable.ts"; +import { pipe } from "../helpers/pipe.ts"; +import { filter, map, scan, take, tap } from "../helpers/operations/core.ts"; +import { debounce, throttle } from "../helpers/operations/timing.ts"; +import { + concatMap, + mergeMap, + switchMap, +} from "../helpers/operations/combination.ts"; +import { catchErrors, ignoreErrors } from "../helpers/operations/errors.ts"; +import { batch } from "../helpers/operations/batch.ts"; +import { find, unique } from "../helpers/operations/conditional.ts"; +import { isObservableError, ObservableError } from "../error.ts"; /** * Collects values from an Observable. @@ -57,14 +41,14 @@ async function collect(obs: Observable): Promise { /** * Creates a delay using setTimeout. */ -const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); describe("Integration Tests - Real World Patterns", () => { describe("Search-as-you-type Pattern", () => { it("should debounce rapid inputs and process only final value", async () => { // Simulate rapid typing: "h" -> "he" -> "hel" -> "hello" - const inputs = ['h', 'he', 'hel', 'hello']; - + const inputs = ["h", "he", "hel", "hello"]; + // Create observable that emits quickly const source = new Observable((observer) => { inputs.forEach((input, i) => { @@ -77,34 +61,34 @@ describe("Integration Tests - Real World Patterns", () => { source, ignoreErrors(), debounce(50), // Wait 50ms after last input - map(query => `Search: ${query}`) + map((query) => `Search: ${query}`), ); const values = await collect(result); - + // Should only process the final value after debounce settles expect(values).toHaveLength(1); - expect(values[0]).toBe('Search: hello'); + expect(values[0]).toBe("Search: hello"); }); it("should filter short queries and transform results", async () => { - const queries = ['a', 'ab', 'abc', 'abcd']; - + const queries = ["a", "ab", "abc", "abcd"]; + const source = Observable.of(...queries); const result = pipe( source, ignoreErrors(), - filter(query => query.length >= 3), // Only 3+ chars - map(query => query.toUpperCase()), - map(query => `Result for: ${query}`) + filter((query) => query.length >= 3), // Only 3+ chars + map((query) => query.toUpperCase()), + map((query) => `Result for: ${query}`), ); const values = await collect(result); - + expect(values).toEqual([ - 'Result for: ABC', - 'Result for: ABCD' + "Result for: ABC", + "Result for: ABCD", ]); }); }); @@ -123,10 +107,10 @@ describe("Integration Tests - Real World Patterns", () => { it("should extract, transform, and load data through pipeline", async () => { const rawData: RawData[] = [ - { id: 1, value: ' hello ', valid: true }, - { id: 2, value: 'WORLD', valid: true }, - { id: 3, value: 'invalid', valid: false }, - { id: 4, value: ' Test ', valid: true }, + { id: 1, value: " hello ", valid: true }, + { id: 2, value: "WORLD", valid: true }, + { id: 3, value: "invalid", valid: false }, + { id: 4, value: " Test ", valid: true }, ]; const source = Observable.of(...rawData); @@ -135,25 +119,25 @@ describe("Integration Tests - Real World Patterns", () => { source, ignoreErrors(), // Extract: filter valid records - filter(item => item.valid), + filter((item) => item.valid), // Transform: normalize data - map(item => ({ + map((item) => ({ id: item.id, - normalized: item.value.trim().toLowerCase() + normalized: item.value.trim().toLowerCase(), })), // Load: collect in batches batch(2), - ignoreErrors() + ignoreErrors(), ); const batches = await collect(result); - + expect(batches).toHaveLength(2); expect(batches[0]).toHaveLength(2); expect(batches[1]).toHaveLength(1); - expect(batches[0][0].normalized).toBe('hello'); - expect(batches[0][1].normalized).toBe('world'); - expect(batches[1][0].normalized).toBe('test'); + expect(batches[0][0].normalized).toBe("hello"); + expect(batches[0][1].normalized).toBe("world"); + expect(batches[1][0].normalized).toBe("test"); }); it("should compute running statistics during processing", async () => { @@ -166,11 +150,11 @@ describe("Integration Tests - Real World Patterns", () => { // Running sum scan((acc, val) => acc + val, 0), // Take every 5th value - filter((_, index) => index % 5 === 4) + filter((_, index) => index % 5 === 4), ); const values = await collect(result); - + // scan emits the seed first, so the 5th and 10th emitted running totals // are 10 and 45 rather than 15 and 55. expect(values).toEqual([10, 45]); @@ -185,15 +169,15 @@ describe("Integration Tests - Real World Patterns", () => { source, map((x) => { if (x === 3) { - throw new Error('Network error'); + throw new Error("Network error"); } return x * 2; }), - catchErrors([] as number[]) // Fallback to empty array + catchErrors([] as number[]), // Fallback to empty array ); const values = await collect(result); - + // map uses pass-through error handling, so catchErrors replaces the // ObservableError value with the fallback while keeping earlier values. expect(values).toContain(2); @@ -204,7 +188,9 @@ describe("Integration Tests - Real World Patterns", () => { it("should isolate errors and continue processing", async () => { const operations = [ () => 10, - () => { throw new Error('Failed'); }, + () => { + throw new Error("Failed"); + }, () => 20, () => 30, ]; @@ -213,33 +199,33 @@ describe("Integration Tests - Real World Patterns", () => { const result = pipe( source, - map(fn => { + map((fn) => { try { return fn(); } catch (err) { - return ObservableError.from(err, 'map'); + return ObservableError.from(err, "map"); } }), - tap(val => { + tap((val) => { // Log errors but don't stop if (isObservableError(val)) { - console.log('Error logged:', val.message); + console.log("Error logged:", val.message); } }), - ignoreErrors() // Remove errors from stream + ignoreErrors(), // Remove errors from stream ); const values = await collect(result); - + expect(values).toEqual([10, 20, 30]); }); it("should separate errors from successes", async () => { const mixed = [ - { type: 'success', value: 1 }, - { type: 'error', value: 'Error 1' }, - { type: 'success', value: 2 }, - { type: 'error', value: 'Error 2' }, + { type: "success", value: 1 }, + { type: "error", value: "Error 1" }, + { type: "success", value: 2 }, + { type: "error", value: "Error 2" }, ]; const source = Observable.of(...mixed); @@ -247,19 +233,19 @@ describe("Integration Tests - Real World Patterns", () => { const successes = pipe( source, ignoreErrors(), - filter(item => item.type === 'success'), - map(item => item.value) + filter((item) => item.type === "success"), + map((item) => item.value), ); const errors = pipe( source, ignoreErrors(), - filter(item => item.type === 'error'), - map(item => item.value) + filter((item) => item.type === "error"), + map((item) => item.value), ); expect(await collect(successes)).toEqual([1, 2]); - expect(await collect(errors)).toEqual(['Error 1', 'Error 2']); + expect(await collect(errors)).toEqual(["Error 1", "Error 2"]); }); }); @@ -274,20 +260,21 @@ describe("Integration Tests - Real World Patterns", () => { const result = pipe( source, ignoreErrors(), - mergeMap((id) => Observable.from((async () => { - activeCount++; - maxConcurrent = Math.max(maxConcurrent, activeCount); - - await wait(10); // Simulate async work - - activeCount--; - return `Result ${id}`; - })()), 2), // Max 2 concurrent - ignoreErrors() + mergeMap((id) => + Observable.from((async () => { + activeCount++; + maxConcurrent = Math.max(maxConcurrent, activeCount); + + await wait(10); // Simulate async work + + activeCount--; + return `Result ${id}`; + })()), 2), // Max 2 concurrent + ignoreErrors(), ); const values = await collect(result); - + expect(values).toHaveLength(5); expect(maxConcurrent).toBeLessThanOrEqual(2); }); @@ -299,22 +286,24 @@ describe("Integration Tests - Real World Patterns", () => { const result = pipe( source, ignoreErrors(), - concatMap((n) => Observable.from((async () => { - await wait(n * 10); // Longer delay for larger numbers - return `Item ${n}`; - })())), - ignoreErrors() + concatMap((n) => + Observable.from((async () => { + await wait(n * 10); // Longer delay for larger numbers + return `Item ${n}`; + })()) + ), + ignoreErrors(), ); const values = await collect(result); - + // concatMap preserves input order, not processing time order // Input order was [3, 1, 2], so output is also [3, 1, 2] - expect(values).toEqual(['Item 3', 'Item 1', 'Item 2']); + expect(values).toEqual(["Item 3", "Item 1", "Item 2"]); }); it("should cancel previous requests with switchMap", async () => { - const requests = ['req1', 'req2', 'req3']; + const requests = ["req1", "req2", "req3"]; const source = new Observable((observer) => { requests.forEach((req, i) => { setTimeout(() => observer.next(req), i * 10); @@ -333,23 +322,25 @@ describe("Integration Tests - Real World Patterns", () => { const result = pipe( source, ignoreErrors(), - switchMap((req) => new Observable((observer) => { - processedCount++; - const id = setTimeout(() => { - observer.next(`Result: ${req}`); - observer.complete(); - }, 30); - - return () => clearTimeout(id); - })), - ignoreErrors() + switchMap((req) => + new Observable((observer) => { + processedCount++; + const id = setTimeout(() => { + observer.next(`Result: ${req}`); + observer.complete(); + }, 30); + + return () => clearTimeout(id); + }) + ), + ignoreErrors(), ); const values = await collect(result); - + // Only the last request should complete // Earlier ones are canceled when new ones arrive - expect(values).toEqual(['Result: req3']); + expect(values).toEqual(["Result: req3"]); expect(processedCount).toBe(3); }); }); @@ -369,14 +360,14 @@ describe("Integration Tests - Real World Patterns", () => { ignoreErrors(), scan((state: AvgState, value) => ({ sum: state.sum + value, - count: state.count + 1 + count: state.count + 1, }), { sum: 0, count: 0 }), filter((state) => state.count > 0), - map(state => state.sum / state.count) + map((state) => state.sum / state.count), ); const averages = await collect(result); - + // Running averages: 10, 15, 20, 25, 30 expect(averages).toEqual([10, 15, 20, 25, 30]); }); @@ -396,12 +387,12 @@ describe("Integration Tests - Real World Patterns", () => { } return newWindow; }, []), - filter(window => window.length === windowSize), - map(window => window.reduce((a, b) => a + b, 0) / window.length) + filter((window) => window.length === windowSize), + map((window) => window.reduce((a, b) => a + b, 0) / window.length), ); const movingAvg = await collect(result); - + // Windows: [1,2,3]=2, [2,3,4]=3, [3,4,5]=4, etc. expect(movingAvg[0]).toBe(2); expect(movingAvg[1]).toBe(3); @@ -415,11 +406,11 @@ describe("Integration Tests - Real World Patterns", () => { const result = pipe( source, ignoreErrors(), - unique() + unique(), ); const uniqueValues = await collect(result); - + expect(uniqueValues).toEqual([1, 2, 3, 4, 5]); }); }); @@ -437,11 +428,11 @@ describe("Integration Tests - Real World Patterns", () => { const result = pipe( source, ignoreErrors(), - throttle(20) // One event per 20ms max + throttle(20), // One event per 20ms max ); const values = await collect(result); - + // Should significantly reduce event count expect(values.length).toBeLessThan(events.length); }); @@ -461,17 +452,17 @@ describe("Integration Tests - Real World Patterns", () => { throttle(10), batch(3), ignoreErrors(), - map(batch => ({ + map((batch) => ({ count: batch.length, - sum: batch.reduce((a, b) => a + b, 0) + sum: batch.reduce((a, b) => a + b, 0), })), - ignoreErrors() + ignoreErrors(), ); const batches = await collect(result); - + expect(batches.length).toBeGreaterThan(0); - batches.forEach(batch => { + batches.forEach((batch) => { expect(batch.count).toBeGreaterThan(0); expect(batch.count).toBeLessThanOrEqual(3); }); @@ -485,15 +476,15 @@ describe("Integration Tests - Real World Patterns", () => { const result = pipe( source, ignoreErrors(), - map(x => x * 2), // Double: 2,4,6,8,10,12,14,16,18,20 - filter(x => x % 3 === 0), // Divisible by 3: 6,12,18 - map(x => x / 3), // Divide by 3: 2,4,6 + map((x) => x * 2), // Double: 2,4,6,8,10,12,14,16,18,20 + filter((x) => x % 3 === 0), // Divisible by 3: 6,12,18 + map((x) => x / 3), // Divide by 3: 2,4,6 scan((sum, x) => sum + x, 0), // Running sum with seed: 0,2,6,12 - take(2) // Take first 2 emissions: 0,2 + take(2), // Take first 2 emissions: 0,2 ); const values = await collect(result); - + expect(values).toEqual([0, 2]); }); @@ -502,32 +493,32 @@ describe("Integration Tests - Real World Patterns", () => { const result = pipe( source, - map(x => { - if (x === 3) throw new Error('Three is bad'); + map((x) => { + if (x === 3) throw new Error("Three is bad"); return x; }), catchErrors(-1), // Replace errors with -1 - filter(x => x > 0), // Remove error markers - map(x => x * 10) + filter((x) => x > 0), // Remove error markers + map((x) => x * 10), ); const values = await collect(result); - + expect(values).toEqual([10, 20, 40, 50]); // Skip the error value }); it("should support early termination with find", async () => { let processed = 0; - + const source = pipe( Observable.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10), ignoreErrors(), tap(() => processed++), - find(x => x > 5) + find((x) => x > 5), ); const result = await collect(source); - + // Should stop after finding first value > 5 expect(result).toEqual([6]); expect(processed).toBeLessThanOrEqual(6); @@ -546,12 +537,12 @@ describe("Integration Tests - Real World Patterns", () => { const result = pipe( source, ignoreErrors(), - filter(x => x % 100 === 0), - take(5) + filter((x) => x % 100 === 0), + take(5), ); const values = await collect(result); - + expect(values).toEqual([0, 100, 200, 300, 400]); }); @@ -572,14 +563,14 @@ describe("Integration Tests - Real World Patterns", () => { const result = pipe( source, ignoreErrors(), - take(5) + take(5), ); await collect(result); - + // Give cleanup time to run await wait(50); - + expect(cleanedUp).toBe(true); }); }); diff --git a/tests/publishing_setup_test.ts b/tests/publishing_setup_test.ts index 2f936c3..df1b241 100644 --- a/tests/publishing_setup_test.ts +++ b/tests/publishing_setup_test.ts @@ -1,39 +1,45 @@ -import { describe, it } from '@std/testing/bdd'; -import { expect } from '@std/expect'; +// deno-lint-ignore-file no-import-prefix +import { describe, it } from "jsr:@std/testing@^1/bdd"; +import { expect } from "jsr:@std/expect@^1"; -const repo_root = new URL('../', import.meta.url); +const repo_root = new URL("../", import.meta.url); function readRepoFile(path: string): string { - return Deno.readTextFileSync(new URL(path, repo_root)); + return Deno.readTextFileSync(new URL(path, repo_root)); } -describe('publishing setup', () => { - it('exposes the npm build task and JSR publish filtering', () => { - const deno_config = readRepoFile('deno.jsonc'); +describe("publishing setup", () => { + it("exposes the npm build task and JSR publish filtering", () => { + const deno_config = readRepoFile("deno.jsonc"); - expect(deno_config).toContain('"build:npm": "deno run -A scripts/build_npm.ts"'); + expect(deno_config).toContain( + '"build:npm": "deno run -A scripts/build_npm.ts"', + ); expect(deno_config).toContain('"publish": {'); expect(deno_config).toContain('"npm/"'); expect(deno_config).toContain('"scripts/"'); + expect(deno_config).toContain('"exclude": ['); }); - it('stores publishing files in their expected repository locations', () => { - for (const path of [ - '.github/workflows/ci.yml', - '.github/workflows/publish.yml', - 'scripts/build_npm.ts', - ]) { - const stat = Deno.statSync(new URL(path, repo_root)); - expect(stat.isFile).toBe(true); - } - }); + it("stores publishing files in their expected repository locations", () => { + for ( + const path of [ + ".github/workflows/ci.yml", + ".github/workflows/publish.yml", + "scripts/build_npm.ts", + ] + ) { + const stat = Deno.statSync(new URL(path, repo_root)); + expect(stat.isFile).toBe(true); + } + }); - it('documents npm install before the JSR bridge fallback', () => { - const readme = readRepoFile('README.md'); + it("documents npm install before the JSR bridge fallback", () => { + const readme = readRepoFile("README.md"); - expect(readme).toContain('npm install @okikio/observables'); - expect(readme).toContain( - 'If you prefer to install through the JSR bridge instead of the npm registry:', - ); - }); + expect(readme).toContain("npm install @okikio/observables"); + expect(readme).toContain( + "If you prefer to install through the JSR bridge instead of the npm registry:", + ); + }); }); diff --git a/tests/queue_bdd_test.ts b/tests/queue_bdd_test.ts index 9c44d39..0530a23 100644 --- a/tests/queue_bdd_test.ts +++ b/tests/queue_bdd_test.ts @@ -3,38 +3,39 @@ * instead of O(n) Array.shift(). Think of it as a ring of parking spaces: when you reach the last * slot and add another item, it wraps to the first slot (if empty). Array.shift() moves every * element forward on each dequeue (expensive), while circular buffer just moves a head pointer. - * + * * Visual: Array.shift() [A,B,C,D,E] → shift() → [B,C,D,E] (everyone moved!) * Circular [A,B,C,D,E] ↑head ↑tail → dequeue() → [_,B,C,D,E] ↑head ↑tail (pointer moved) - * + * * Tests cover basic operations (enqueue/dequeue/peek), FIFO order, circular wrapping when head/tail * reach capacity, status checks (isEmpty/isFull), advanced operations (clear/toArray/forEach), edge * cases (capacity 1, rapid cycles, null/undefined), and real-world scenarios (task queues, message * buffers, rate limiting). */ -import { describe, it } from '@std/testing/bdd'; -import { expect } from '@std/expect'; +// deno-lint-ignore-file no-import-prefix +import { describe, it } from "jsr:@std/testing@^1/bdd"; +import { expect } from "jsr:@std/expect@^1"; -import { +import { + clear, createQueue, - enqueue, dequeue, - peek, + enqueue, + forEach, + getSize, isEmpty, isFull, - getSize, + peek, remainingSpace, - clear, toArray, - forEach -} from '../queue.ts'; +} from "../queue.ts"; describe("Queue Creation", () => { describe("createQueue()", () => { it("should create an empty queue with default capacity", () => { const queue = createQueue(); - + expect(isEmpty(queue)).toBe(true); expect(getSize(queue)).toBe(0); expect(queue.capacity).toBe(1000); // Default capacity @@ -44,7 +45,7 @@ describe("Queue Creation", () => { it("should create a queue with custom capacity", () => { const queue = createQueue(50); - + expect(isEmpty(queue)).toBe(true); expect(queue.capacity).toBe(50); expect(remainingSpace(queue)).toBe(50); @@ -53,7 +54,7 @@ describe("Queue Creation", () => { it("should create queue with capacity of 1", () => { // Edge case: minimal queue const queue = createQueue(1); - + expect(queue.capacity).toBe(1); expect(remainingSpace(queue)).toBe(1); }); @@ -63,7 +64,7 @@ describe("Queue Creation", () => { const numberQueue = createQueue(); const stringQueue = createQueue(); const objectQueue = createQueue<{ id: number; name: string }>(); - + expect(numberQueue).toBeDefined(); expect(stringQueue).toBeDefined(); expect(objectQueue).toBeDefined(); @@ -75,44 +76,44 @@ describe("Basic Queue Operations", () => { describe("enqueue() - Adding items", () => { it("should add items to the queue", () => { const queue = createQueue(5); - + enqueue(queue, 1); enqueue(queue, 2); enqueue(queue, 3); - + expect(getSize(queue)).toBe(3); expect(isEmpty(queue)).toBe(false); }); it("should maintain FIFO order", () => { const queue = createQueue(5); - - enqueue(queue, 'first'); - enqueue(queue, 'second'); - enqueue(queue, 'third'); - + + enqueue(queue, "first"); + enqueue(queue, "second"); + enqueue(queue, "third"); + // First in should be first out - expect(dequeue(queue)).toBe('first'); - expect(dequeue(queue)).toBe('second'); - expect(dequeue(queue)).toBe('third'); + expect(dequeue(queue)).toBe("first"); + expect(dequeue(queue)).toBe("second"); + expect(dequeue(queue)).toBe("third"); }); it("should update tail pointer correctly", () => { const queue = createQueue(5); - + enqueue(queue, 1); expect(queue.tail).toBe(1); - + enqueue(queue, 2); expect(queue.tail).toBe(2); - + enqueue(queue, 3); expect(queue.tail).toBe(3); }); it("should increment size correctly", () => { const queue = createQueue(10); - + for (let i = 0; i < 5; i++) { enqueue(queue, i); expect(getSize(queue)).toBe(i + 1); @@ -121,26 +122,26 @@ describe("Basic Queue Operations", () => { it("should throw when queue is full", () => { const queue = createQueue(3); - + enqueue(queue, 1); enqueue(queue, 2); enqueue(queue, 3); - + // Queue is full, next enqueue should throw - expect(() => enqueue(queue, 4)).toThrow('Queue overflow'); - expect(() => enqueue(queue, 4)).toThrow('capacity 3 reached'); + expect(() => enqueue(queue, 4)).toThrow("Queue overflow"); + expect(() => enqueue(queue, 4)).toThrow("capacity 3 reached"); }); it("should handle different data types", () => { const stringQueue = createQueue(3); const objectQueue = createQueue<{ x: number }>(3); const arrayQueue = createQueue(3); - - enqueue(stringQueue, 'hello'); + + enqueue(stringQueue, "hello"); enqueue(objectQueue, { x: 42 }); enqueue(arrayQueue, [1, 2, 3]); - - expect(dequeue(stringQueue)).toBe('hello'); + + expect(dequeue(stringQueue)).toBe("hello"); expect(dequeue(objectQueue)).toEqual({ x: 42 }); expect(dequeue(arrayQueue)).toEqual([1, 2, 3]); }); @@ -149,10 +150,10 @@ describe("Basic Queue Operations", () => { describe("dequeue() - Removing items", () => { it("should remove and return the front item", () => { const queue = createQueue(5); - + enqueue(queue, 10); enqueue(queue, 20); - + const first = dequeue(queue); expect(first).toBe(10); expect(getSize(queue)).toBe(1); @@ -160,49 +161,49 @@ describe("Basic Queue Operations", () => { it("should return undefined when queue is empty", () => { const queue = createQueue(5); - + const result = dequeue(queue); expect(result).toBeUndefined(); }); it("should update head pointer correctly", () => { const queue = createQueue(5); - + enqueue(queue, 1); enqueue(queue, 2); enqueue(queue, 3); - + expect(queue.head).toBe(0); - + dequeue(queue); expect(queue.head).toBe(1); - + dequeue(queue); expect(queue.head).toBe(2); }); it("should decrement size correctly", () => { const queue = createQueue(5); - + enqueue(queue, 1); enqueue(queue, 2); enqueue(queue, 3); - + expect(getSize(queue)).toBe(3); - + dequeue(queue); expect(getSize(queue)).toBe(2); - + dequeue(queue); expect(getSize(queue)).toBe(1); }); it("should clear the dequeued slot for garbage collection", () => { const queue = createQueue<{ data: string }>(5); - - enqueue(queue, { data: 'test' }); + + enqueue(queue, { data: "test" }); dequeue(queue); - + // The slot should be cleared (set to undefined) // This helps the garbage collector reclaim memory expect(queue.items[0]).toBeUndefined(); @@ -210,11 +211,11 @@ describe("Basic Queue Operations", () => { it("should handle dequeuing all items", () => { const queue = createQueue(3); - + enqueue(queue, 1); enqueue(queue, 2); enqueue(queue, 3); - + expect(dequeue(queue)).toBe(1); expect(dequeue(queue)).toBe(2); expect(dequeue(queue)).toBe(3); @@ -226,9 +227,9 @@ describe("Basic Queue Operations", () => { describe("peek() - Looking at front item", () => { it("should return the front item without removing it", () => { const queue = createQueue(5); - + enqueue(queue, 42); - + const peeked = peek(queue); expect(peeked).toBe(42); expect(getSize(queue)).toBe(1); // Size unchanged @@ -236,57 +237,57 @@ describe("Basic Queue Operations", () => { it("should return undefined for empty queue", () => { const queue = createQueue(5); - + expect(peek(queue)).toBeUndefined(); }); it("should allow multiple peeks without side effects", () => { const queue = createQueue(5); - - enqueue(queue, 'hello'); - - expect(peek(queue)).toBe('hello'); - expect(peek(queue)).toBe('hello'); - expect(peek(queue)).toBe('hello'); + + enqueue(queue, "hello"); + + expect(peek(queue)).toBe("hello"); + expect(peek(queue)).toBe("hello"); + expect(peek(queue)).toBe("hello"); expect(getSize(queue)).toBe(1); // Still there }); it("should return correct item after dequeues", () => { const queue = createQueue(5); - + enqueue(queue, 1); enqueue(queue, 2); enqueue(queue, 3); - + dequeue(queue); // Remove 1 expect(peek(queue)).toBe(2); - + dequeue(queue); // Remove 2 expect(peek(queue)).toBe(3); }); it("should be useful for conditional processing", () => { interface Task { - priority: 'high' | 'low'; + priority: "high" | "low"; action: string; } - + const queue = createQueue(10); - - enqueue(queue, { priority: 'low', action: 'send-email' }); - enqueue(queue, { priority: 'high', action: 'process-payment' }); - + + enqueue(queue, { priority: "low", action: "send-email" }); + enqueue(queue, { priority: "high", action: "process-payment" }); + // Peek helps decide whether to process now or leave the queue alone. // The queue stays FIFO, so a low-priority item at the front does not let // us skip ahead to the later high-priority item. const next = peek(queue); - if (next && next.priority === 'high') { + if (next && next.priority === "high") { // Process immediately only when the front item is high priority. dequeue(queue); } - + // The low-priority task is still at the front because peek is non-destructive. - expect(peek(queue)?.priority).toBe('low'); + expect(peek(queue)?.priority).toBe("low"); }); }); }); @@ -295,70 +296,70 @@ describe("Circular Buffer Wrapping", () => { describe("Wrap-around behavior", () => { it("should wrap tail pointer around when reaching capacity", () => { const queue = createQueue(5); - + // Fill queue for (let i = 0; i < 5; i++) { enqueue(queue, i); } - + expect(queue.tail).toBe(0); // Wrapped to start expect(isFull(queue)).toBe(true); }); it("should wrap head pointer when dequeuing", () => { const queue = createQueue(3); - + // Fill queue enqueue(queue, 1); enqueue(queue, 2); enqueue(queue, 3); - + // Dequeue all dequeue(queue); dequeue(queue); dequeue(queue); - + // Head wraps around expect(queue.head).toBe(0); }); it("should allow reuse of space after wrap-around", () => { const queue = createQueue(3); - + // Fill queue enqueue(queue, 1); enqueue(queue, 2); enqueue(queue, 3); - + // Remove first two items dequeue(queue); dequeue(queue); - + // Now we have space, add two more // These will wrap around and use slots 0 and 1 enqueue(queue, 4); enqueue(queue, 5); - + expect(getSize(queue)).toBe(3); expect(toArray(queue)).toEqual([3, 4, 5]); }); it("should handle multiple wrap-arounds correctly", () => { const queue = createQueue(3); - + // Simulate continuous enqueue/dequeue pattern for (let cycle = 0; cycle < 5; cycle++) { // Fill queue enqueue(queue, cycle * 10 + 1); enqueue(queue, cycle * 10 + 2); enqueue(queue, cycle * 10 + 3); - + // Empty queue dequeue(queue); dequeue(queue); dequeue(queue); } - + // Queue should still work correctly expect(isEmpty(queue)).toBe(true); expect(queue.head).toBe(0); @@ -367,47 +368,47 @@ describe("Circular Buffer Wrapping", () => { it("should maintain FIFO order across wrap-around", () => { const queue = createQueue(4); - + // Fill queue - enqueue(queue, 'A'); - enqueue(queue, 'B'); - enqueue(queue, 'C'); - enqueue(queue, 'D'); - + enqueue(queue, "A"); + enqueue(queue, "B"); + enqueue(queue, "C"); + enqueue(queue, "D"); + // Remove two items to make space - expect(dequeue(queue)).toBe('A'); - expect(dequeue(queue)).toBe('B'); - + expect(dequeue(queue)).toBe("A"); + expect(dequeue(queue)).toBe("B"); + // Add two more (these wrap around) - enqueue(queue, 'E'); - enqueue(queue, 'F'); - + enqueue(queue, "E"); + enqueue(queue, "F"); + // Verify order is maintained - expect(toArray(queue)).toEqual(['C', 'D', 'E', 'F']); - expect(dequeue(queue)).toBe('C'); - expect(dequeue(queue)).toBe('D'); - expect(dequeue(queue)).toBe('E'); - expect(dequeue(queue)).toBe('F'); + expect(toArray(queue)).toEqual(["C", "D", "E", "F"]); + expect(dequeue(queue)).toBe("C"); + expect(dequeue(queue)).toBe("D"); + expect(dequeue(queue)).toBe("E"); + expect(dequeue(queue)).toBe("F"); }); }); describe("Complex wrap-around scenarios", () => { it("should handle head and tail meeting after wrap", () => { const queue = createQueue(5); - + // Add items for (let i = 0; i < 5; i++) { enqueue(queue, i); } - + // head=0, tail=0 (wrapped), size=5 expect(queue.head).toBe(0); expect(queue.tail).toBe(0); expect(isFull(queue)).toBe(true); - + // Dequeue one dequeue(queue); - + // head=1, tail=0, size=4 expect(queue.head).toBe(1); expect(queue.tail).toBe(0); @@ -416,21 +417,21 @@ describe("Circular Buffer Wrapping", () => { it("should distinguish full from empty when pointers equal", () => { const queue = createQueue(3); - + // Initially: head=0, tail=0, size=0 (empty) expect(queue.head).toBe(queue.tail); expect(isEmpty(queue)).toBe(true); expect(isFull(queue)).toBe(false); - + // Fill: head=0, tail=0, size=3 (full, wrapped) enqueue(queue, 1); enqueue(queue, 2); enqueue(queue, 3); - + expect(queue.head).toBe(queue.tail); expect(isEmpty(queue)).toBe(false); expect(isFull(queue)).toBe(true); - + // The size property is what distinguishes empty from full expect(queue.size).toBe(3); }); @@ -477,7 +478,7 @@ describe("Queue Status and Utilities", () => { enqueue(queue, 1); enqueue(queue, 2); enqueue(queue, 3); - + dequeue(queue); expect(isFull(queue)).toBe(false); }); @@ -485,7 +486,7 @@ describe("Queue Status and Utilities", () => { it("should work with capacity of 1", () => { const queue = createQueue(1); expect(isFull(queue)).toBe(false); - + enqueue(queue, 1); expect(isFull(queue)).toBe(true); }); @@ -499,7 +500,7 @@ describe("Queue Status and Utilities", () => { it("should return correct size as items are added", () => { const queue = createQueue(5); - + expect(getSize(queue)).toBe(0); enqueue(queue, 1); expect(getSize(queue)).toBe(1); @@ -512,7 +513,7 @@ describe("Queue Status and Utilities", () => { enqueue(queue, 1); enqueue(queue, 2); enqueue(queue, 3); - + expect(getSize(queue)).toBe(3); dequeue(queue); expect(getSize(queue)).toBe(2); @@ -537,7 +538,7 @@ describe("Queue Status and Utilities", () => { it("should decrease as items are added", () => { const queue = createQueue(5); - + expect(remainingSpace(queue)).toBe(5); enqueue(queue, 1); expect(remainingSpace(queue)).toBe(4); @@ -558,7 +559,7 @@ describe("Queue Status and Utilities", () => { enqueue(queue, 1); enqueue(queue, 2); enqueue(queue, 3); - + expect(remainingSpace(queue)).toBe(2); dequeue(queue); expect(remainingSpace(queue)).toBe(3); @@ -566,18 +567,18 @@ describe("Queue Status and Utilities", () => { it("should be useful for monitoring queue health", () => { const queue = createQueue(100); - + // Fill halfway for (let i = 0; i < 50; i++) { enqueue(queue, i); } - + // Check if we need to alert about capacity if (remainingSpace(queue) < 20) { // Would trigger alert in real system expect(true).toBe(false); // Shouldn't reach here } - + expect(remainingSpace(queue)).toBe(50); }); }); @@ -590,9 +591,9 @@ describe("Advanced Operations", () => { enqueue(queue, 1); enqueue(queue, 2); enqueue(queue, 3); - + clear(queue); - + expect(isEmpty(queue)).toBe(true); expect(getSize(queue)).toBe(0); }); @@ -602,9 +603,9 @@ describe("Advanced Operations", () => { enqueue(queue, 1); enqueue(queue, 2); dequeue(queue); - + clear(queue); - + expect(queue.head).toBe(0); expect(queue.tail).toBe(0); }); @@ -612,44 +613,44 @@ describe("Advanced Operations", () => { it("should maintain capacity", () => { const queue = createQueue(50); enqueue(queue, 1); - + clear(queue); - + expect(queue.capacity).toBe(50); expect(remainingSpace(queue)).toBe(50); }); it("should be safe on empty queue", () => { const queue = createQueue(5); - + clear(queue); // Clear empty queue - + expect(isEmpty(queue)).toBe(true); expect(getSize(queue)).toBe(0); }); it("should allow reuse after clearing", () => { const queue = createQueue(3); - enqueue(queue, 'A'); - enqueue(queue, 'B'); - + enqueue(queue, "A"); + enqueue(queue, "B"); + clear(queue); - - enqueue(queue, 'C'); - enqueue(queue, 'D'); - - expect(toArray(queue)).toEqual(['C', 'D']); + + enqueue(queue, "C"); + enqueue(queue, "D"); + + expect(toArray(queue)).toEqual(["C", "D"]); }); it("should help garbage collection by releasing references", () => { const queue = createQueue<{ data: number[] }>(3); - + // Add objects with large arrays enqueue(queue, { data: new Array(1000).fill(1) }); enqueue(queue, { data: new Array(1000).fill(2) }); - + clear(queue); - + // All references should be cleared expect(queue.size).toBe(0); // The items array is reset, allowing GC to reclaim memory @@ -667,7 +668,7 @@ describe("Advanced Operations", () => { enqueue(queue, 1); enqueue(queue, 2); enqueue(queue, 3); - + expect(toArray(queue)).toEqual([1, 2, 3]); }); @@ -675,28 +676,28 @@ describe("Advanced Operations", () => { const queue = createQueue(5); enqueue(queue, 1); enqueue(queue, 2); - + const arr = toArray(queue); - + expect(getSize(queue)).toBe(2); expect(arr).toEqual([1, 2]); }); it("should handle wrapped queue correctly", () => { const queue = createQueue(4); - + // Fill and wrap enqueue(queue, 1); enqueue(queue, 2); enqueue(queue, 3); enqueue(queue, 4); - + dequeue(queue); dequeue(queue); - + enqueue(queue, 5); enqueue(queue, 6); - + // Internal state: [5, 6, 3, 4] with head=2, tail=2 // toArray reads in order: starting from head (index 2), we get 3, 4, 5, 6 expect(toArray(queue)).toEqual([3, 4, 5, 6]); @@ -706,24 +707,24 @@ describe("Advanced Operations", () => { const queue = createQueue(5); enqueue(queue, 1); enqueue(queue, 2); - + const arr1 = toArray(queue); enqueue(queue, 3); const arr2 = toArray(queue); - + expect(arr1).toEqual([1, 2]); expect(arr2).toEqual([1, 2, 3]); }); it("should be useful for debugging", () => { const queue = createQueue(10); - enqueue(queue, 'task1'); - enqueue(queue, 'task2'); - enqueue(queue, 'task3'); - + enqueue(queue, "task1"); + enqueue(queue, "task2"); + enqueue(queue, "task3"); + // In real code, you might log this const snapshot = toArray(queue); - expect(snapshot.join(' → ')).toBe('task1 → task2 → task3'); + expect(snapshot.join(" → ")).toBe("task1 → task2 → task3"); }); }); @@ -733,26 +734,26 @@ describe("Advanced Operations", () => { enqueue(queue, 1); enqueue(queue, 2); enqueue(queue, 3); - + const collected: number[] = []; forEach(queue, (item) => { collected.push(item); }); - + expect(collected).toEqual([1, 2, 3]); }); it("should provide index to callback", () => { const queue = createQueue(5); - enqueue(queue, 'A'); - enqueue(queue, 'B'); - enqueue(queue, 'C'); - + enqueue(queue, "A"); + enqueue(queue, "B"); + enqueue(queue, "C"); + const indices: number[] = []; forEach(queue, (_, index) => { indices.push(index); }); - + expect(indices).toEqual([0, 1, 2]); }); @@ -760,12 +761,12 @@ describe("Advanced Operations", () => { const queue = createQueue(5); enqueue(queue, 1); enqueue(queue, 2); - + forEach(queue, (item) => { // Do something with item expect(item).toBeDefined(); }); - + expect(getSize(queue)).toBe(2); }); @@ -774,26 +775,26 @@ describe("Advanced Operations", () => { enqueue(queue, 1); enqueue(queue, 2); enqueue(queue, 3); - + dequeue(queue); enqueue(queue, 4); - + const collected: number[] = []; forEach(queue, (item) => { collected.push(item); }); - + expect(collected).toEqual([2, 3, 4]); }); it("should do nothing for empty queue", () => { const queue = createQueue(5); - + let callCount = 0; forEach(queue, () => { callCount++; }); - + expect(callCount).toBe(0); }); @@ -802,19 +803,19 @@ describe("Advanced Operations", () => { id: number; name: string; } - + const queue = createQueue(10); - enqueue(queue, { id: 1, name: 'Task 1' }); - enqueue(queue, { id: 2, name: 'Task 2' }); - + enqueue(queue, { id: 1, name: "Task 1" }); + enqueue(queue, { id: 2, name: "Task 2" }); + const logs: string[] = []; forEach(queue, (task, index) => { logs.push(`[${index}] Task ${task.id}: ${task.name}`); }); - + expect(logs).toEqual([ - '[0] Task 1: Task 1', - '[1] Task 2: Task 2' + "[0] Task 1: Task 1", + "[1] Task 2: Task 2", ]); }); }); @@ -824,11 +825,11 @@ describe("Edge Cases and Stress Tests", () => { describe("Edge cases", () => { it("should handle capacity of 1", () => { const queue = createQueue(1); - + enqueue(queue, 42); expect(isFull(queue)).toBe(true); expect(peek(queue)).toBe(42); - + const value = dequeue(queue); expect(value).toBe(42); expect(isEmpty(queue)).toBe(true); @@ -836,42 +837,42 @@ describe("Edge Cases and Stress Tests", () => { it("should handle alternating enqueue/dequeue", () => { const queue = createQueue(5); - + for (let i = 0; i < 100; i++) { enqueue(queue, i); expect(dequeue(queue)).toBe(i); } - + expect(isEmpty(queue)).toBe(true); }); it("should handle filling and emptying multiple times", () => { const queue = createQueue(3); - + for (let cycle = 0; cycle < 5; cycle++) { // Fill enqueue(queue, cycle * 3 + 1); enqueue(queue, cycle * 3 + 2); enqueue(queue, cycle * 3 + 3); - + expect(isFull(queue)).toBe(true); - + // Empty dequeue(queue); dequeue(queue); dequeue(queue); - + expect(isEmpty(queue)).toBe(true); } }); it("should handle undefined as valid value", () => { const queue = createQueue(3); - + enqueue(queue, undefined); enqueue(queue, 42); enqueue(queue, undefined); - + // Note: dequeue returns undefined for empty queue // So we need to check size to distinguish expect(dequeue(queue)).toBeUndefined(); @@ -883,11 +884,11 @@ describe("Edge Cases and Stress Tests", () => { it("should handle null values", () => { const queue = createQueue(3); - + enqueue(queue, null); enqueue(queue, 1); enqueue(queue, null); - + expect(dequeue(queue)).toBeNull(); expect(dequeue(queue)).toBe(1); expect(dequeue(queue)).toBeNull(); @@ -897,41 +898,41 @@ describe("Edge Cases and Stress Tests", () => { describe("Performance characteristics", () => { it("should handle large number of operations efficiently", () => { const queue = createQueue(1000); - + // Enqueue 1000 items for (let i = 0; i < 1000; i++) { enqueue(queue, i); } - + expect(getSize(queue)).toBe(1000); - + // Dequeue 500 items for (let i = 0; i < 500; i++) { expect(dequeue(queue)).toBe(i); } - + expect(getSize(queue)).toBe(500); - + // Enqueue 500 more (testing wrap-around) for (let i = 1000; i < 1500; i++) { enqueue(queue, i); } - + expect(isFull(queue)).toBe(true); }); it("should maintain O(1) operations even with wrapping", () => { const queue = createQueue(100); - + // Simulate continuous queue usage for (let i = 0; i < 10000; i++) { enqueue(queue, i); - + if (getSize(queue) > 50) { dequeue(queue); } } - + // Queue should still be in valid state expect(getSize(queue)).toBeGreaterThan(0); expect(getSize(queue)).toBeLessThanOrEqual(100); @@ -944,33 +945,33 @@ describe("Edge Cases and Stress Tests", () => { id: number; action: () => void; } - + const taskQueue = createQueue(100); const executed: number[] = []; - + // Add tasks for (let i = 0; i < 5; i++) { enqueue(taskQueue, { id: i, - action: () => executed.push(i) + action: () => executed.push(i), }); } - + // Process tasks in order while (!isEmpty(taskQueue)) { const task = dequeue(taskQueue); task?.action(); } - + expect(executed).toEqual([0, 1, 2, 3, 4]); }); it("should work as a message buffer with overflow handling", () => { const messageBuffer = createQueue(3); - - const messages = ['msg1', 'msg2', 'msg3', 'msg4']; + + const messages = ["msg1", "msg2", "msg3", "msg4"]; const processed: string[] = []; - + for (const msg of messages) { if (isFull(messageBuffer)) { // Buffer full, process oldest message first @@ -979,14 +980,14 @@ describe("Edge Cases and Stress Tests", () => { } enqueue(messageBuffer, msg); } - + // Process remaining while (!isEmpty(messageBuffer)) { const msg = dequeue(messageBuffer); if (msg) processed.push(msg); } - - expect(processed).toEqual(['msg1', 'msg2', 'msg3', 'msg4']); + + expect(processed).toEqual(["msg1", "msg2", "msg3", "msg4"]); }); it("should work for rate limiting", () => { @@ -994,10 +995,10 @@ describe("Edge Cases and Stress Tests", () => { id: number; timestamp: number; } - + const RATE_LIMIT = 5; // Max 5 requests const requestQueue = createQueue(RATE_LIMIT); - + // Simulate incoming requests for (let i = 0; i < 10; i++) { if (!isFull(requestQueue)) { @@ -1006,7 +1007,7 @@ describe("Edge Cases and Stress Tests", () => { // Rate limit exceeded, could reject or queue elsewhere } } - + // Only first 5 requests made it through expect(getSize(requestQueue)).toBe(5); });