diff --git a/docs/main.go b/docs/main.go index d6c8dcea..fa446b59 100644 --- a/docs/main.go +++ b/docs/main.go @@ -110,6 +110,11 @@ func main() { Href: "/fx", Page: pager("fx.md"), }, + { + Text: "Managing Resources", + Href: "/managing-resources", + Page: pager("managing-resources.md"), + }, { Text: "Error Handling", Href: "/error-handling", diff --git a/docs/posts/endpoints.md b/docs/posts/endpoints.md index 604874c6..78542ed3 100644 --- a/docs/posts/endpoints.md +++ b/docs/posts/endpoints.md @@ -29,11 +29,11 @@ export const updateUser = api.post<{ id: string; name: string }>( body: JSON.stringify({ name: ctx.payload.name }), }); yield* next(); - }, + } ); const store = createStore(initialState); -store.run(api.register); +store.initialize(api.register); store.dispatch(fetchUsers()); // now accessible with useCache(fetchUsers) diff --git a/docs/posts/getting-started.md b/docs/posts/getting-started.md index e949fb4f..27577bf6 100644 --- a/docs/posts/getting-started.md +++ b/docs/posts/getting-started.md @@ -102,7 +102,7 @@ const fetchRepo = api.get( api.cache(), ); -store.run(api.register); +store.initialize(api.register); function App() { return ( diff --git a/docs/posts/managing-resources.md b/docs/posts/managing-resources.md new file mode 100644 index 00000000..17c84ee8 --- /dev/null +++ b/docs/posts/managing-resources.md @@ -0,0 +1,66 @@ +--- +title: Managing resources +description: How to use .manage() to register effection resources with store/thunks/api +--- + +# Managed resources ✅ + +`starfx` supports managing Effection `resource`s and exposing them via a `Context` using the `.manage()` helper on `store`, `thunks` (from `createThunks`) and `api` (from `createApi`). The `manage` call will start the resource inside the scope and return a `Context` you can `get()` or `expect()` inside your operations to access the provided value. + +A `resource` is useful in encapsulating logic or functionality. It is particularly useful for managing connections such as a WebSocket connections, Web Workers, auth or telemetry. These processes can be wired up, including failure, restart and shutdown logic, and then used in any of your actions. See the [`effectionx` repo](https://github.com/thefrontside/effectionx) for published packages around an effection `resource` and examples. + +Note: when using this API at the store, you need to use the new `store.initialize()` API to ensure that the resources are properly started. If you don't make use of `store.manage()`, this is not a required at this time, but `store.run()` for initial startup will be deprecated in the future preferring `store.initialize()`. + +Example (contrived) pattern: + +```ts +import { resource } from "effection"; + +function guessAge(): Operation<{ guess: number; cumulative: number | null }> { + return resource(function* (provide) { + let cumulative: number | null = 0; + try { + // this wouldn't be valuable per se, but demonstrates how the functionality is exposed + yield* provide({ + get guess() { + const n = Math.floor(Math.random() * 100); + if (cumulative !== null) cumulative += n; + return n; + }, + get cumulative() { + return cumulative; + }, + }); + } finally { + // cleanup when the resource is closed + cumulative = null; + } + }); +} +``` + +Manage the resource: + +```ts +// on a `store`: +const store = createStore({ initialState: {} }); +const GuesserCtx = store.manage("guesser", guessAge()); +// Or with `createThunks` (the pattern is the same for `createApi`): +const thunks = createThunks(); +const GuesserCtx = thunks.manage("guesser", guessAge()); + +// inside an operation (thunk, middleware, etc.) +const action = thunks.create("do-thing", function* (ctx, next) { + // use the managed resource inside an action + const g = yield* GuesserCtx.get(); // may return undefined + const g2 = yield* GuesserCtx.expect(); // will throw if resource is not available + + console.log(g2.guess, g2.cumulative); + yield* next(); +}); + +store.initilize(thunks.register); +store.dispatch(action()); +``` + +This API exists both at the overall store "level" and at the thunk "level". Resources managed at the store level are available in all registered thunks/apis whereas a resource managed at a thunk is _only available_ in that thunk. This would, for example, allow you to only enable auth for a single `createAPI()` subset of thunks. diff --git a/docs/posts/schema.md b/docs/posts/schema.md index 16b3b41f..0624ab69 100644 --- a/docs/posts/schema.md +++ b/docs/posts/schema.md @@ -254,7 +254,7 @@ const [schema, initialState] = createSchema({ }); const store = createStore(initialState); -store.run(function* () { +store.initialize(function* () { yield* schema.update([ schema.counter.increment(), schema.counter.increment(), diff --git a/docs/posts/store.md b/docs/posts/store.md index 9e4fcce2..424a4032 100644 --- a/docs/posts/store.md +++ b/docs/posts/store.md @@ -94,7 +94,7 @@ const fetchUsers = api.get( ); const store = createStore(schema); -store.run(api.register); +store.initialize(api.register); store.dispatch(fetchUsers()); ``` diff --git a/examples/basic/src/main.tsx b/examples/basic/src/main.tsx index a76c9041..a187ce16 100644 --- a/examples/basic/src/main.tsx +++ b/examples/basic/src/main.tsx @@ -17,7 +17,7 @@ const fetchRepo = api.get( api.cache(), ); -store.run(api.register); +store.initialize(api.register); function App() { return ( diff --git a/examples/parcel-react/src/index.jsx b/examples/parcel-react/src/index.jsx index 55487c80..88bac2b3 100644 --- a/examples/parcel-react/src/index.jsx +++ b/examples/parcel-react/src/index.jsx @@ -1,6 +1,6 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { createStore, take } from "starfx"; +import { createStore, take, parallel } from "starfx"; import { Provider } from "starfx/react"; import { api, initialState, schema } from "./api.js"; import { App } from "./app.jsx"; @@ -11,15 +11,17 @@ function init() { const store = createStore({ initialState }); window.fx = store; - store.run([ - function* logger() { - while (true) { - const action = yield* take("*"); - console.log("action", action); - } - }, - api.register, - ]); + store.initialize(() => + parallel([ + function* logger() { + while (true) { + const action = yield* take("*"); + console.log("action", action); + } + }, + api.register, + ]), + ); ReactDOM.createRoot(document.getElementById("root")).render( diff --git a/examples/tests-rtl/src/api.ts b/examples/tests-rtl/src/api.ts index 23e39d19..592828b7 100644 --- a/examples/tests-rtl/src/api.ts +++ b/examples/tests-rtl/src/api.ts @@ -12,20 +12,17 @@ api.use(mdw.api({ schema })); api.use(api.routes()); api.use(mdw.fetch({ baseUrl: "https://jsonplaceholder.typicode.com" })); -export const fetchUsers = api.get( - "/users", - function* (ctx, next) { - yield* next(); +export const fetchUsers = api.get("/users", function* (ctx, next) { + yield* next(); - if (!ctx.json.ok) { - return; - } + if (!ctx.json.ok) { + return; + } - const users = ctx.json.value.reduce((acc, user) => { - acc[user.id] = user; - return acc; - }, {}); + const users = ctx.json.value.reduce((acc, user) => { + acc[user.id] = user; + return acc; + }, {}); - yield* schema.update(schema.users.add(users)); - }, -); + yield* schema.update(schema.users.add(users)); +}); diff --git a/examples/tests-rtl/src/store.ts b/examples/tests-rtl/src/store.ts index 4c30ef6c..cb0004ec 100644 --- a/examples/tests-rtl/src/store.ts +++ b/examples/tests-rtl/src/store.ts @@ -9,7 +9,7 @@ export function setupStore({ initialState = {} }) { }, }); - store.run(api.register); + store.initialize(api.register); return store; } diff --git a/examples/vite-react/src/main.tsx b/examples/vite-react/src/main.tsx index 96f60a66..f0d96936 100644 --- a/examples/vite-react/src/main.tsx +++ b/examples/vite-react/src/main.tsx @@ -1,6 +1,6 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { createStore, take } from "starfx"; +import { createStore, take, parallel } from "starfx"; import { Provider } from "starfx/react"; import { api, initialState, schema } from "./api.ts"; import App from "./App.tsx"; @@ -13,15 +13,17 @@ function init() { // makes `fx` available in devtools (window as any).fx = store; - store.run([ - function* logger() { - while (true) { - const action = yield* take("*"); - console.log("action", action); - } - }, - api.register, - ]); + store.initialize(() => + parallel([ + function* logger() { + while (true) { + const action = yield* take("*"); + console.log("action", action); + } + }, + api.register, + ]), + ); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/package-lock.json b/package-lock.json index e6e89d74..22f8fcda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@types/node": "^22.15.29", "@types/react": "^19.1.6", "@types/react-dom": "^19.1.5", + "effection": "4.0.2", "execa": "^9.6.0", "nock": "^14.0.5", "react": "^19.1.0", @@ -1258,11 +1259,11 @@ } }, "node_modules/effection": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/effection/-/effection-4.0.0.tgz", - "integrity": "sha512-eW2yqhyBdey4k8lkp7hpiev2FSHvJvQqvaIebI3EGikHZvfUWvNy7SmkwOnJa6WcsUtSh7VHUwdjHTbV++8M9w==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/effection/-/effection-4.0.2.tgz", + "integrity": "sha512-O8WMGP10nPuJDwbNGILcaCNWS+CvDYjcdsUSD79nWZ+WtUQ8h1MEV7JJwCSZCSeKx8+TdEaZ/8r6qPTR2o/o8w==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 16" } diff --git a/package.json b/package.json index 77c501f8..9f3c4309 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@types/react": "^19.1.6", "@types/react-dom": "^19.1.5", "execa": "^9.6.0", + "effection": "4.0.2", "nock": "^14.0.5", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/src/query/thunk.ts b/src/query/thunk.ts index def36bbf..40a6c597 100644 --- a/src/query/thunk.ts +++ b/src/query/thunk.ts @@ -349,11 +349,11 @@ export function createThunks>( function manage(name: string, inputResource: Operation) { const CustomContext = createContext(name); function curVisor(scope: Scope) { - return function* () { + return supervise(function* () { const providedResource = yield* inputResource; scope.set(CustomContext, providedResource); yield* suspend(); - }; + }); } watch.send(curVisor); diff --git a/src/store/index.ts b/src/store/index.ts index 527075d9..3103c843 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,9 +1,41 @@ +import { any } from "./slice/any.js"; +import { loaders } from "./slice/loaders.js"; +import { num } from "./slice/num.js"; +import { obj } from "./slice/obj.js"; +import { str } from "./slice/str.js"; +import { table } from "./slice/table.js"; + +export { createSchema } from "./schema.js"; +export { + createStore, + configureStore, + IdContext, + type CreateStore, +} from "./store.js"; +export const slice = { + str, + num, + table, + any, + obj, + loaders, + /** + * @deprecated Use `slice.loaders` instead + */ + loader: loaders, +}; + +export { defaultLoader, defaultLoaderItem } from "./slice/loaders.js"; +export type { AnyOutput } from "./slice/any.js"; +export type { LoaderOutput } from "./slice/loaders.js"; +export type { NumOutput } from "./slice/num.js"; +export type { ObjOutput } from "./slice/obj.js"; +export type { StrOutput } from "./slice/str.js"; +export type { TableOutput } from "./slice/table.js"; + +export * from "./types.js"; export * from "./context.js"; export * from "./fx.js"; -export * from "./store.js"; -export * from "./types.js"; export { createSelector } from "reselect"; -export * from "./slice/index.js"; -export * from "./schema.js"; export * from "./batch.js"; export * from "./persist.js"; diff --git a/src/store/schema.ts b/src/store/schema.ts index 75ba7a99..c4720646 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -1,10 +1,8 @@ import { updateStore } from "./fx.js"; -import { slice } from "./slice/index.js"; +import { loaders } from "./slice/loaders.js"; +import { table } from "./slice/table.js"; import type { FxMap, FxSchema, StoreUpdater } from "./types.js"; -const defaultSchema = (): O => - ({ cache: slice.table(), loaders: slice.loaders() }) as O; - /** * Creates a schema object and initial state from slice factories. * @@ -74,7 +72,9 @@ const defaultSchema = (): O => export function createSchema< O extends FxMap, S extends { [key in keyof O]: ReturnType["initialState"] }, ->(slices: O = defaultSchema()): [FxSchema, S] { +>( + slices: O = { cache: table(), loaders: loaders() } as O, +): [FxSchema, S] { const db = Object.keys(slices).reduce>( (acc, key) => { (acc as any)[key] = slices[key](key); diff --git a/src/store/slice/index.ts b/src/store/slice/index.ts deleted file mode 100644 index 30b241c3..00000000 --- a/src/store/slice/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { type AnyOutput, any } from "./any.js"; -import { - type LoaderOutput, - defaultLoader, - defaultLoaderItem, - loaders, -} from "./loaders.js"; -import { type NumOutput, num } from "./num.js"; -import { type ObjOutput, obj } from "./obj.js"; -import { type StrOutput, str } from "./str.js"; -import { type TableOutput, table } from "./table.js"; - -export const slice = { - str, - num, - table, - any, - obj, - loaders, - /** - * @deprecated Use `slice.loaders` instead - */ - loader: loaders, -}; -export { defaultLoader, defaultLoaderItem }; -export type { - AnyOutput, - LoaderOutput, - NumOutput, - ObjOutput, - StrOutput, - TableOutput, -}; diff --git a/src/store/store.ts b/src/store/store.ts index bb4947fd..03393da3 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,13 +1,20 @@ import { Ok, + type Operation, type Scope, + type Task, createContext, createScope, createSignal, + each, + lift, + suspend, } from "effection"; import { enablePatches, produceWithPatches } from "immer"; import { API_ACTION_PREFIX, ActionContext, emit } from "../action.js"; import { type BaseMiddleware, compose } from "../compose.js"; +import { createReplaySignal } from "../fx/replay-signal.js"; +import { supervise } from "../index.js"; import type { AnyAction, AnyState, Next } from "../types.js"; import { StoreContext, StoreUpdateContext } from "./context.js"; import { createRun } from "./run.js"; @@ -99,6 +106,7 @@ export function createStore({ enablePatches(); const signal = createSignal(); + const watch = createReplaySignal(); scope.set(ActionContext, signal); scope.set(IdContext, id++); @@ -137,7 +145,7 @@ export function createStore({ } function* logMdw(ctx: UpdaterCtx, next: Next) { - dispatch({ + yield* lift(dispatch)({ type: `${API_ACTION_PREFIX}store`, payload: ctx, }); @@ -178,7 +186,7 @@ export function createStore({ yield* mdw(ctx); if (!ctx.result.ok) { - dispatch({ + yield* lift(dispatch)({ type: `${API_ACTION_PREFIX}store`, payload: ctx.result.error, }); @@ -211,13 +219,43 @@ export function createStore({ }); } + function manage(name: string, inputResource: Operation) { + const CustomContext = createContext(name); + function* manager() { + const providedResource = yield* inputResource; + scope.set(CustomContext, providedResource); + yield* suspend(); + } + watch.send(supervise(manager)); + + // returns to the user so they can use this resource from + // anywhere this context is available + return CustomContext; + } + + const run = createRun(scope); + + function initialize(op: () => Operation): Task { + return scope.run(function* (): Operation { + yield* scope.spawn(function* () { + for (const watched of yield* each(watch)) { + yield* scope.spawn(watched); + yield* each.next(); + } + }); + yield* op(); + }); + } + const store: FxStore = { getScope, getState, subscribe, + initialize, + manage, update, reset, - run: createRun(scope), + run, // instead of actions relating to store mutation, they // refer to pieces of business logic -- that can also mutate state dispatch, diff --git a/src/store/types.ts b/src/store/types.ts index ffcf76e5..7be1e830 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -1,4 +1,4 @@ -import type { Operation, Scope } from "effection"; +import type { Context, Operation, Scope, Task } from "effection"; import type { Patch } from "immer"; import type { BaseCtx } from "../index.js"; import type { AnyAction, AnyState } from "../types.js"; @@ -63,14 +63,89 @@ export type FxSchema = { * Runtime store instance exposing state, update, and effect helpers. */ export interface FxStore { + /** + * Return the Effection Scope associated with this store. + */ getScope: () => Scope; + + /** + * Return the current state value. + */ getState: () => S; + + /** + * Subscribe to state changes. Returns an unsubscribe function. + * + * @param fn - listener called on every update + */ subscribe: (fn: Listener) => () => void; + + /** + * Apply an immutable update (or array of updates) to the state. + * Returns an Operation yielding the updater context (including patches). + * + * @param u - StoreUpdater or array of StoreUpdater + */ update: (u: StoreUpdater | StoreUpdater[]) => Operation>; + + /** + * Reset the state to the initial state, optionally preserving keys in ignoreList. + * + * @param ignoreList - keys to retain from the current state + */ reset: (ignoreList?: (keyof S)[]) => Operation>; + + /** + * Start and expose an Effection resource within the store scope. + * + * @param name - unique name for the resource Context + * @param resource - an Effection Operation (usually created with `resource(...)`) + * @returns a `Context` that can `get()` or `expect()` in thunks/apis + */ + manage: ( + name: string, + resource: Operation, + ) => Context; + + /** + * Run a single or array of operation(s) inside the store's scope. + * + * Use `store.run(...)` to execute ad-hoc tasks or one-off operations. For + * long-running background watchers or to start resources registered via + * `.manage()` you must call `initialize(...)` which starts the store's + * internal watcher loop (and is required for `.manage()` and + * `thunks.register`). It is unlikely you will need to call `store.run(...)` + * directly in typical usage. + */ run: ReturnType; + + /** + * Initialize the store along with passed operations. + * + * `initialize(op)` starts `op` inside the store scope and spawns the + * internal watcher loop that starts resources registered via `.manage()`. + * Typical usage: + * ```ts + * store.initialize(thunks.register); + * ``` + * + * @param op - function returning an Operation that will run in the store scope + */ + initialize: (op: () => Operation) => Task; + + /** + * Dispatch an action (or array of actions) into the store's action channel. + */ dispatch: (a: AnyAction | AnyAction[]) => any; + + /** + * Stubbed for compatibility with redux APIs. Not implemented. + */ replaceReducer: (r: (s: S, a: AnyAction) => S) => void; + + /** + * Return the initial state used when the store was created. + */ getInitialState: () => S; [Symbol.observable]: () => any; } diff --git a/src/test/store.test.ts b/src/test/store.test.ts index 6c75897b..d428f453 100644 --- a/src/test/store.test.ts +++ b/src/test/store.test.ts @@ -2,8 +2,10 @@ import { type Operation, type Result, createScope, + createThunks, parallel, put, + resource, sleep, take, } from "../index.js"; @@ -13,7 +15,7 @@ import { createStore, updateStore, } from "../store/index.js"; -import { expect, test } from "../test.js"; +import { describe, expect, test } from "../test.js"; interface User { id: string; @@ -180,3 +182,66 @@ test("resets store", async () => { token: "", }); }); + +describe(".manage", () => { + function guessAge(): Operation<{ guess: number; cumulative: null | number }> { + return resource(function* (provide) { + let cumulative = 0 as null | number; + try { + yield* provide({ + get guess() { + const random = Math.floor(Math.random() * 100); + if (cumulative !== null) cumulative += random; + return random; + }, + get cumulative() { + return cumulative; + }, + }); + } finally { + cumulative = null; + } + }); + } + + test("expects resource", async () => { + expect.assertions(1); + + const thunk = createThunks(); + thunk.use(thunk.routes()); + const store = createStore({ initialState: {} }); + const TestContext = store.manage("test:context", guessAge()); + store.initialize(thunk.register); + let acc = "bla"; + const action = thunk.create("/users", function* (payload, next) { + const c = yield* TestContext.get(); + if (c) acc += "b"; + next(); + }); + store.dispatch(action()); + + expect(acc).toBe("blab"); + }); + + test("uses resource", async () => { + expect.assertions(2); + + const thunk = createThunks(); + thunk.use(thunk.routes()); + const store = createStore({ initialState: {} }); + const TestContext = store.manage("test:context", guessAge()); + store.initialize(thunk.register); + let guess = 0; + let acc = 0; + const action = thunk.create("/users", function* (payload, next) { + const c = yield* TestContext.expect(); + guess += c.guess; + acc += c.cumulative ?? 0; + next(); + }); + store.dispatch(action()); + + expect(guess).toBeGreaterThan(0); + expect(acc).toEqual(guess); + }); +});