diff --git a/.gitignore b/.gitignore index 00b192d..fcd405a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ operators/gdocs assets/_private examples/codegen .parcel-cache +.claude/settings.local.json diff --git a/effect/package.json b/effect/package.json index 9fb9da0..43b0de7 100644 --- a/effect/package.json +++ b/effect/package.json @@ -1,6 +1,6 @@ { "name": "@rxfx/effect", - "version": "1.1.4", + "version": "1.1.6", "license": "MIT", "author": "Dean Radcliffe", "repository": "https://github.com/deanrad/rxfx", diff --git a/effect/src/createEffect.ts b/effect/src/createEffect.ts index 2f63341..cbcc155 100644 --- a/effect/src/createEffect.ts +++ b/effect/src/createEffect.ts @@ -26,7 +26,12 @@ import { takeUntil, tap, } from 'rxjs/operators'; -import { EffectRunner, EffectSource } from './types'; +import { + EffectRunner, + EffectSource, + LifecycleReducerEvent, + ProcessLifecycleCallbacks, +} from './types'; const allShutdowns = new Subject(); @@ -41,16 +46,22 @@ export function shutdownAll() { * @param handler The Promise or Observable-returning effect function - the EffectSource * @param concurrencyOperator The concurrency-control function (defaults to `mergeMap` aka Immediate) * @summary ![immediate mode](https://d2jksv3bi9fv68.cloudfront.net/rxfx/mode-immediate-sm.png) */ -export function createEffect( +export function createEffect< + Request, + Response = void, + TError extends Error = Error, + TState = Response +>( handler: EffectSource, concurrencyOperator = mergeMap -): EffectRunner { +): EffectRunner { const errors = new Subject(); const currentError = new BehaviorSubject(null); const lastResponse = new BehaviorSubject(null); - const state = new BehaviorSubject(null); + const state = new BehaviorSubject(null); + const incoming = new Subject(); const requests = new Subject(); const responses = new Subject(); const starts = new Subject(); @@ -160,6 +171,7 @@ export function createEffect( const executor = function Effect(req: Request) { handlings.next( new Observable((notify) => { + incoming.next(req); requests.next(req); notify.complete(); }) @@ -200,6 +212,56 @@ export function createEffect( isHandling, isActive, state, + + reduceWith( + reducer: ( + state: TState, + evt: LifecycleReducerEvent + ) => TState, + initial: TState + ) { + const events$ = merge( + incoming.pipe( + map((payload) => ({ type: 'request' as const, payload })) + ), + starts.pipe(map((payload) => ({ type: 'started' as const, payload }))), + responses.pipe( + map((payload) => ({ type: 'response' as const, payload })) + ), + completions.pipe( + map((payload) => ({ type: 'complete' as const, payload })) + ), + cancelations.pipe( + map((payload) => ({ type: 'canceled' as const, payload })) + ) + ); + state.next(initial); + mainSub.add(events$.pipe(scan(reducer, initial)).subscribe(state)); + return state as BehaviorSubject; + }, + observe(fns: Partial>) { + const streams = [ + tap(fns.request ? fns.request : noop)(requests), + tap(fns.started ? fns.started : noop)(starts), + tap(fns.response ? fns.response : noop)(responses), + tap(fns.complete ? fns.complete : noop)(completions), + tap(fns.canceled ? fns.canceled : noop)(cancelations), + tap(fns.error ? fns.error : noop)(errors), + ]; + const allEvents = merge(...streams).pipe( + tap({ + finalize: fns.finalized, + }) + ); + const sub = allEvents.subscribe(); + sub.add( + merge(completions, cancelations) + .pipe(tap(fns.finalized ?? noop)) + .subscribe() + ); + + return sub; + }, }; // The first batch starts us listening @@ -211,18 +273,28 @@ export function createEffect( /** Creates an Effect - A higher-order wrapper around a Promise-or-Observable returning function. * The effect is cancelable if it returns an Observable. `createQueueingEffect` runs in concurrency mode: "queueing" aka `concatMap`. * @summary ![queueing mode](https://d2jksv3bi9fv68.cloudfront.net/rxfx/mode-queueing-sm.png) */ -export function createQueueingEffect( +export function createQueueingEffect< + Request, + Response = void, + TError extends Error = Error, + TState = Response +>( handler: EffectSource -) { +): EffectRunner { return createEffect(handler, concatMap); } /** Creates an Effect - A higher-order wrapper around a Promise-or-Observable returning function. * The effect is cancelable if it returns an Observable. `createSwitchingEffect` runs in concurrency mode: "switching" aka `switchMap`. * @summary ![switching mode](https://d2jksv3bi9fv68.cloudfront.net/rxfx/mode-switching-sm.png) */ -export function createSwitchingEffect( +export function createSwitchingEffect< + Request, + Response = void, + TError extends Error = Error, + TState = Response +>( handler: EffectSource -) { +): EffectRunner { return createEffect(handler, switchMap); } @@ -230,9 +302,14 @@ export function createSwitchingEffect( * The effect is cancelable if it returns an Observable. `createBlockingEffect` runs in concurrency mode: "blocking" aka `exhaustMap`. * @summary ![blocking mode](https://d2jksv3bi9fv68.cloudfront.net/rxfx/mode-blocking-sm.png) */ -export function createBlockingEffect( +export function createBlockingEffect< + Request, + Response = void, + TError extends Error = Error, + TState = Response +>( handler: EffectSource -) { +): EffectRunner { return createEffect(handler, exhaustMap); } @@ -240,9 +317,14 @@ export function createBlockingEffect( * The effect is cancelable if it returns an Observable. `createTogglingEffect` runs in concurrency mode: "blocking" aka `exhaustMap`. * @summary ![toggling mode](https://d2jksv3bi9fv68.cloudfront.net/rxfx/mode-toggling-sm.png) */ -export function createTogglingEffect( +export function createTogglingEffect< + Request, + Response = void, + TError extends Error = Error, + TState = Response +>( handler: EffectSource -) { +): EffectRunner { return createEffect(handler, toggleMap as typeof mergeMap); } @@ -257,9 +339,14 @@ export const DEFAULT_DEBOUNCE_INTERVAL = 330; export function createThrottledEffect( msec: number = DEFAULT_DEBOUNCE_INTERVAL ) { - return function ( + return function < + Request, + Response = void, + TError extends Error = Error, + TState = Response + >( handler: EffectSource - ) { + ): EffectRunner { return createEffect((args: Request) => { return concat( // do the work up front @@ -279,9 +366,14 @@ export function createThrottledEffect( export function createDebouncedEffect( msec: number = DEFAULT_DEBOUNCE_INTERVAL ) { - return function ( + return function < + Request, + Response = void, + TError extends Error = Error, + TState = Response + >( handler: EffectSource - ) { + ): EffectRunner { return createEffect((args: Request) => { return concat( // wait initially @@ -301,3 +393,5 @@ export function createCustomEffect( ) { return createEffect(handler, concurrencyOperator); } + +function noop() {} diff --git a/effect/src/index.ts b/effect/src/index.ts index 8532057..8ed8db0 100644 --- a/effect/src/index.ts +++ b/effect/src/index.ts @@ -1,2 +1,10 @@ export { concat, EMPTY, throwError } from 'rxjs'; export * from './createEffect'; +// Concurrency mode operator aliases +export { + mergeMap as immediate, + concatMap as queueing, + exhaustMap as blocking, + switchMap as switching, + switchMap as replacing, +} from 'rxjs/operators'; diff --git a/effect/src/types.ts b/effect/src/types.ts index 0ca264b..043de5a 100644 --- a/effect/src/types.ts +++ b/effect/src/types.ts @@ -1,4 +1,9 @@ -import { BehaviorSubject, Observable, ObservableInput } from 'rxjs'; +import { + BehaviorSubject, + Observable, + ObservableInput, + Subscription, +} from 'rxjs'; /** An EffectSource is an async function, or a function with a Promise, Observable, or Iterable return value, * whose lifecycle events will be exposed to the EffectRunner. @@ -25,20 +30,19 @@ export interface Cancelable { /** The Stateful interface represents information the EffectRunner retains about previous or current effect executions. */ -export interface Stateful { +export interface Stateful { lastResponse: BehaviorSubject; currentError: BehaviorSubject; isHandling: BehaviorSubject; isActive: BehaviorSubject; - /** Reserved for future use. */ - state: BehaviorSubject; + state: BehaviorSubject; } /** The Events interface contains Observables that an effect triggerer may use to get updates on executions. */ export interface Events { - errors: Observable; - responses: Observable; starts: Observable; + responses: Observable; + errors: Observable; completions: Observable; cancelations: Observable; } @@ -46,15 +50,58 @@ export interface Events { /** * An EffectRunner is a function, enhanced with Observable properties */ -export interface EffectRunner - extends EffectTriggerer, +export interface EffectRunner< + Request, + Response, + TError extends Error = Error, + TState = Response +> extends EffectTriggerer, Cancelable, Events, - Stateful { + Stateful { request: (req: Request) => void; send: ( req: Request, matcher?: (req: Request, res: Response) => boolean ) => Promise; + + /** Populates #state via the reducer. Meant to be called only once, before */ + reduceWith: ( + reducer: ( + state: TState, + evt: LifecycleReducerEvent + ) => TState, + initial: TState + ) => BehaviorSubject; + + observe( + callbacks: Partial> + ): Subscription; +} + +export type LifecycleReducerEvent = + | { type: 'request'; payload: Req } + | { type: 'started'; payload: Req } + | { type: 'response'; payload: Res } + | { type: 'complete'; payload?: Req } + | { type: 'error'; payload: Err } + | { type: 'canceled'; payload?: Req }; + +/** Callbacks corresponding to lifecycle events of a process. */ +export interface ProcessLifecycleCallbacks { + /** invokes the effects */ + request: (r: TRequest) => void; + /** an invocation has begun */ + started: (r: TRequest) => void; + /** an invocation has produced data */ + response: (next: TNext) => void; + /** an invocation has terminated with an error */ + error: (err: TError) => void; + /** an invocation has terminated successfully */ + complete: (r: TRequest) => void; + /** an invocation was canceled by a subscriber */ + canceled: (r: TRequest) => void; + /** an invocation concluded, in any fashion */ + finalized: () => void; } diff --git a/effect/test/createEffect.spec.ts b/effect/test/createEffect.spec.ts index 034da47..a295902 100644 --- a/effect/test/createEffect.spec.ts +++ b/effect/test/createEffect.spec.ts @@ -9,22 +9,13 @@ import { createSwitchingEffect, createThrottledEffect, createTogglingEffect, - EffectRunner, shutdownAll, } from '../src/createEffect'; -import { concat, Observable, of, throwError } from 'rxjs'; +import { EffectRunner, ProcessLifecycleCallbacks } from '../src/types'; +import { concat, of, throwError } from 'rxjs'; const DELAY = 10; -function makeArray(obs: Observable) { - const arr = [] as T[]; - obs.subscribe((v) => { - arr.push(v); - }); - - return arr; -} - /** * Related ChatGPT sessions for test writing: * [Immediate vs Queueing](https://chatgpt.com/share/d324a2b3-e8b6-471b-8b46-5aa0b0bc36a2) @@ -131,6 +122,7 @@ describe('createEffect - returns a function which', () => { likePost(234); } ops.push(`end ${postId}`); + return []; }); likePost(123); @@ -155,6 +147,106 @@ describe('createEffect - returns a function which', () => { // just strict sequencing. }); + it('can track state with a reducer', async () => { + const likePost = createEffect( + (postId: number) => { + return after(10, () => { + return `post liked ${postId}`; + }); + } + ); + + // Supply a reducer so that likePost.state is populated + likePost.reduceWith((s = ['YYY'], e) => { + if (e.type === 'response') { + return [...s, e.payload]; + } + return [...s, e]; + }, []); + expect(likePost.state.value).toEqual([]); + + likePost(123); + likePost(345); + await after(10); + likePost(456); + likePost.cancelCurrent(); + + expect(likePost.state.value).toMatchInlineSnapshot(` + [ + { + "payload": 123, + "type": "request", + }, + { + "payload": 123, + "type": "started", + }, + { + "payload": 345, + "type": "request", + }, + { + "payload": 345, + "type": "started", + }, + "post liked 123", + { + "payload": 123, + "type": "complete", + }, + "post liked 345", + { + "payload": 345, + "type": "complete", + }, + { + "payload": 456, + "type": "request", + }, + { + "payload": 456, + "type": "started", + }, + { + "payload": 456, + "type": "canceled", + }, + ] + `); + }); + + const spies: Partial> = { + request: jest.fn(), + started: jest.fn(), + response: jest.fn(), + complete: jest.fn(), + canceled: jest.fn(), + error: jest.fn(), + finalized: jest.fn(), + }; + + it('can be observed', async () => { + const countFx = createEffect((i) => + after(1, () => i * 2) + ); + + countFx.observe(spies); + // trigger + countFx.request(1); // req 0 + + expect(spies.request).toHaveBeenCalledWith(1); + expect(spies.started).toHaveBeenCalledTimes(1); + + await after(1); + expect(spies.complete).toHaveBeenCalledTimes(1); + + // XXX // + expect(spies.finalized).toHaveBeenCalledTimes(1); + + countFx(3); + expect(spies.request).toHaveBeenCalledWith(3); + }); + describe('Effect Handler', () => { it('Can return a Promise', async () => { const VALUE = 1.1; @@ -242,7 +334,7 @@ describe('createEffect - returns a function which', () => { }); describe('Errors: Do not affect caller, requests are still handled, and errors appear on #errors', () => { - let errs = []; + let errs = [] as Error[]; let counterFx: EffectRunner; beforeEach(() => { @@ -304,6 +396,7 @@ describe('createEffect - returns a function which', () => { if (i === 1) { throw new Error(`req ${i} errored`); } + return []; }); expect(counterFx?.currentError.value).toBeNull(); @@ -767,6 +860,7 @@ describe('createCustomEffect', () => { return after(DELAY, () => { liked.push(`post liked ${postId}`); }); + // @ts-ignore }, queueOnlyLatest); likeIt(2718); diff --git a/service/package.json b/service/package.json index ac9a7d6..a2d04e4 100644 --- a/service/package.json +++ b/service/package.json @@ -1,6 +1,6 @@ { "name": "@rxfx/service", - "version": "1.5.1", + "version": "1.5.2", "license": "MIT", "author": "Dean Radcliffe", "repository": "https://github.com/deanrad/rxfx", diff --git a/service/src/createService.ts b/service/src/createService.ts index 9058518..afa390a 100644 --- a/service/src/createService.ts +++ b/service/src/createService.ts @@ -97,6 +97,10 @@ export function createServiceListener< ); const isActive = new BehaviorSubject(false); + const onceInactive = () => { + return firstValueFrom(isActive.pipe(filter((x) => x === false))); + }; + allSubscriptions.add( isHandling .asObservable() @@ -127,7 +131,7 @@ export function createServiceListener< isCancelation: (e: any): e is Action => ACs.canceled.match(e), }; - // ACs should only enumerate ACs + // ACs have special isRequest, isResponse etc functions Object.entries(matchers).forEach(([name, fn]) => { Object.defineProperty(ACs, name, { enumerable: false, @@ -361,6 +365,7 @@ export function createServiceListener< // Queryable ...queries, isActive, + onceInactive, isHandling, currentError, observe, diff --git a/service/src/types.ts b/service/src/types.ts index e7fea8c..8269523 100644 --- a/service/src/types.ts +++ b/service/src/types.ts @@ -2,6 +2,29 @@ import { Bus } from '@rxfx/bus'; import { Action, ActionCreator, ProcessLifecycleActions } from '@rxfx/fsa'; import { Subscription, Observable, BehaviorSubject, Subject } from 'rxjs'; +/** A dictionary of matchers for use in reducers */ +export interface LifecycleEventMatchers { + isRequest: (e: Action) => e is Action; + isCancel: (e: Action) => e is Action; + isStart: (e: Action) => e is Action; + isResponse: (e: Action) => e is Action; + isError: (e: Action) => e is Action; + isCompletion: (e: Action) => e is Action; + isCancelation: (e: Action) => e is Action; +} + +export type LifecycleEventAttributes = Record< + keyof LifecycleEventMatchers, + boolean +>; + +export type LifecycleEventFlags = Partial< + Record< + keyof LifecycleEventMatchers, + boolean /* LEFTOFF - the flags aren't good enough they can't be type-guards */ + > +>; + /** The interface for a reducer with an optional getInitialState synchronous property. */ export interface ServiceReducer< TRequest, @@ -13,6 +36,19 @@ export interface ServiceReducer< getInitialState?: () => TState; } +export interface ServiceReducerWithACs< + TRequest, + TNext = void, + TError = Error, + TState = {} +> { + ( + state: TState, + action: Action, + eventAttrs: LifecycleEventAttributes + ): TState; + getInitialState?: () => TState; +} /** Signature for a function that closes over action creators and returns a Producer */ export type ReducerProducer< TRequest, @@ -80,17 +116,6 @@ export interface ProcessLifecycleCallbacks { finalized: () => void; } -/** A dictionary of matchers for use in reducers */ -export interface LifecycleEventMatchers { - isRequest: (e: Action) => e is Action; - isCancel: (e: Action) => e is Action; - isStart: (e: Action) => e is Action; - isResponse: (e: Action) => e is Action; - isError: (e: Action) => e is Action; - isCompletion: (e: Action) => e is Action; - isCancelation: (e: Action) => e is Action; -} - /** A dictionary of action creators assigned to the service */ export interface EventActionCreators { /** Creates an event which invokes the service. */ @@ -138,6 +163,8 @@ export interface Queryable { isHandling: BehaviorSubject; /** Contains the last error object, but becomes `null` at the start of the next handling. */ currentError: BehaviorSubject; + /** Useful when: 1-or-more requests are made, and you want a Promise for all their completions (success or error).` */ + onceInactive: () => Promise; /** Creates an independent subscription, invoking callbacks on process lifecycle events */ observe: ( cbs: Partial> diff --git a/service/test/createService.spec.ts b/service/test/createService.spec.ts index 793ae4d..3ff136c 100644 --- a/service/test/createService.spec.ts +++ b/service/test/createService.spec.ts @@ -40,6 +40,7 @@ describe('createServiceListener', () => { bus.reset(); // stops existing services, handlings }); const counterReducer = (s = 0, e) => (e ? s + 1 : s); + const ASYNC_DELAY = 10; it('triggers a request to the bus when called', () => { const seen = eventsOf(bus); @@ -593,8 +594,6 @@ describe('createServiceListener', () => { }); describe('#isActive', () => { - const ASYNC_DELAY = 10; - describe('Immediate', () => { it('stays true across multiple activities (F, T, F)', async () => { const statuses: boolean[] = []; @@ -715,6 +714,31 @@ describe('createServiceListener', () => { }); }); + describe('#onceInactive', () => { + it('resolves once .isActive has become false ', async () => { + const statuses: boolean[] = []; + const svc = createServiceListener( + 'onceActive-immediate', + bus, + () => after(ASYNC_DELAY, '3.14') + ); + + svc.isActive.subscribe((s) => statuses.push(s)); + + svc.request(); + expect(statuses).toEqual([false, true]); + + // + svc.request(); + expect(statuses).toEqual([false, true]); + + // await after(ASYNC_DELAY * 3); + await svc.onceInactive(); + + expect(statuses).toEqual([false, true, false]); // YAY! + }); + }); + describe('#currentError', () => { const ex = new Error('foo'); diff --git a/service/test/createStatefulService.spec.ts b/service/test/createStatefulService.spec.ts new file mode 100644 index 0000000..3ba169b --- /dev/null +++ b/service/test/createStatefulService.spec.ts @@ -0,0 +1,176 @@ +import type { EventHandler } from '@rxfx/bus'; +import { createService } from '../src/createService'; +import type { + Service, + ServiceReducerWithACs, + LifecycleEventMatchers, + LifecycleEventFlags, +} from '../src/types'; +import { produce } from 'immer'; + +import { Action } from '@rxfx/fsa'; + +interface ServiceWithFluidReducer + extends Service { + withReducer: ( + r: ServiceReducerWithACs + ) => Service; +} + +interface Config { + name?: string; +} +type State = { count: number; items: string[] }; + +const initialState = { count: 0, items: [] }; + +describe(createStatefulService, () => { + it('infers state from reducer without explicit typing', () => { + const service = createStatefulService( + // Handler with inferred request/response types + (id: number) => Promise.resolve(`Item ${id}`), + { name: 'items' } + ).withReducer((state = initialState, event, { isRequest, isResponse }) => { + if (isRequest) { + return { ...state, count: state.count + 1 }; + } + if (isResponse) { + return { ...state, items: [...state.items, event.payload as string] }; + } + return state; + }); + + service.responses; // Observerable yay! + service.state; // BS yay + service; + + // Prevent unused var warning + expect(service).toBeDefined(); + }); + + it('infers state from reducer when explicitly defined earlier ', () => { + type State = { count: number; items: string[] }; + + const reducer = ( + state: State = { count: 0, items: [] }, + event: Action, + { isRequest, isResponse }: LifecycleEventFlags + ): State => { + if (isRequest) { + return { ...state, count: state.count + 1 }; + } + if (isResponse) { + return { ...state, items: [...state.items, event.payload as string] }; + } + return state; + }; + + const service = createStatefulService( + // Handler with inferred request/response types + (id: number) => Promise.resolve(`Item ${id}`), + { name: 'items' } + ).withReducer(reducer); + + // Prevent unused var warning + expect(service).toBeDefined(); + }); + + it('infers complex state shape from handler response', () => { + interface UserData { + id: number; + name: string; + roles: string[]; + } + + const service = createStatefulService( + // Handler returning complex type + (_userId: string): Promise => + Promise.resolve({ + id: 1, + name: 'Test User', + roles: ['admin'], + }), + { name: 'users' } + ).withReducer( + ( + state, + event, + { isResponse }: LifecycleEventFlags + ): UserData | null => { + if (isResponse) { + return event.payload; + } + return state; + } + ); + + // Prevent unused var warning + expect(service).toBeDefined(); + }); + + it('infers types when using request/response matchers', () => { + interface SearchState { + searchTerm: string; + results: string[]; + } + + interface SearchRequest { + searchTerm: string; + } + + interface SearchResponse { + results: string[]; + } + + const service = createStatefulService< + SearchState, + SearchRequest, + SearchResponse + >( + (_query: SearchRequest) => + Promise.resolve({ results: ['result1', 'result2'] }), + { name: 'search' } + ).withReducer( + ( + state: SearchState = { searchTerm: '', results: [] }, + event: Action, + { + isRequest, + isResponse, + }: LifecycleEventMatchers + ): SearchState => { + if (isRequest(event)) { + return { ...state, searchTerm: event.payload.searchTerm }; + } + if (isResponse(event)) { + return { ...state, results: event.payload.results }; + } + return state; + } + ); + + // Prevent unused var warning + expect(service).toBeDefined(); + }); + + it.todo('infers types when using request/response matchers'); +}); + +// Base implementation +// ALMOST has it - just - needs to attach the reducer.. +function createStatefulService( + handler: EventHandler, + config?: Config +): ServiceWithFluidReducer { + const name = config?.name ?? '__default'; + + const service = createService(name, handler); + + return { + ...service, + withReducer: (_fn) => { + // TODO: implement reducer attachment + return service; + }, + } as ServiceWithFluidReducer; +}