├── .github └── workflows │ ├── docs.yml │ ├── preview.yml │ ├── release.yml │ ├── test-ecosystem.yml │ └── test.yml ├── .gitignore ├── LICENSE.md ├── Makefile ├── README.md ├── action.ts ├── api-type-template.ts ├── compose.ts ├── deno.json ├── deno.lock ├── deps.ts ├── docs ├── Makefile ├── go.mod ├── go.sum ├── main.go ├── posts │ ├── caching.md │ ├── controllers.md │ ├── dependent.md │ ├── design-philosophy.md │ ├── dispatch.md │ ├── endpoints.md │ ├── error-handling.md │ ├── fx.md │ ├── getting-started.md │ ├── home.md │ ├── learn.md │ ├── loaders.md │ ├── mdw.md │ ├── models.md │ ├── react.md │ ├── resources.md │ ├── schema.md │ ├── selectors.md │ ├── sitemap.md │ ├── store.md │ ├── structured-concurrency.md │ ├── supervisors.md │ ├── testing.md │ └── thunks.md ├── static │ ├── logo.png │ ├── logo.svg │ └── main.css └── tmpl │ ├── base.layout.tmpl │ ├── footer.partial.tmpl │ ├── home.page.tmpl │ ├── nav.partial.tmpl │ ├── pager.partial.tmpl │ ├── post.page.tmpl │ ├── sitemap-footer.partial.tmpl │ ├── sitemap.page.tmpl │ └── toc.partial.tmpl ├── fx ├── mod.ts ├── parallel.ts ├── race.ts ├── request.ts ├── safe.ts └── supervisor.ts ├── matcher.ts ├── mdw ├── fetch.ts ├── mod.ts ├── query.ts └── store.ts ├── mod.ts ├── query ├── api-types.ts ├── api.ts ├── create-key.ts ├── mod.ts ├── thunk.ts ├── types.ts └── util.ts ├── queue.ts ├── react.ts ├── scripts ├── branch-exists.ts ├── npm.ts └── sync.ts ├── store ├── batch.ts ├── context.ts ├── fx.ts ├── mod.ts ├── persist.ts ├── run.ts ├── schema.ts ├── slice │ ├── any.ts │ ├── loaders.ts │ ├── mod.ts │ ├── num.ts │ ├── obj.test.ts │ ├── obj.ts │ ├── str.ts │ ├── table.test.ts │ └── table.ts ├── store.ts └── types.ts ├── supervisor.ts ├── test.ts ├── test ├── action.test.ts ├── api.test.ts ├── batch.test.ts ├── compose.test.ts ├── create-key.test.ts ├── create-store.test.ts ├── fetch.test.ts ├── mdw.test.ts ├── parallel.test.ts ├── persist.test.ts ├── put.test.ts ├── react.test.ts ├── safe.test.ts ├── schema.test.ts ├── store.test.ts ├── supervisor.test.ts ├── take-helper.test.ts ├── take.test.ts ├── thunk.test.ts └── timer.test.ts └── types.ts /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: docs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | static: 8 | runs-on: ubuntu-latest 9 | defaults: 10 | run: 11 | working-directory: ./docs 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | # need entire history 16 | fetch-depth: 0 17 | - uses: actions/setup-go@v4 18 | with: 19 | go-version: "1.22" 20 | - name: generate site 21 | run: | 22 | go mod tidy 23 | make ssg 24 | - name: Set outputs 25 | id: vars 26 | run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT 27 | - name: publish to pgs 28 | uses: picosh/pgs-action@main 29 | with: 30 | user: erock 31 | key: ${{ secrets.PRIVATE_KEY }} 32 | src: "./docs/public/" 33 | project: "starfx-docs-${{ steps.vars.outputs.sha_short }}" 34 | promote: "starfx-prod" 35 | retain: "starfx-docs" 36 | retain_num: 1 37 | -------------------------------------------------------------------------------- /.github/workflows/preview.yml: -------------------------------------------------------------------------------- 1 | name: preview 2 | 3 | on: [pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | preview: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: checkout 13 | uses: actions/checkout@v4 14 | - name: setup deno 15 | uses: denoland/setup-deno@v2 16 | - name: get version 17 | id: vars 18 | run: echo ::set-output name=version::$(echo ${{github.ref_name}} | sed 's/^v//') 19 | - name: setup node 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: 18.x 23 | registry-url: https://registry.npmjs.com 24 | - name: build 25 | run: deno task npm $NPM_VERSION 26 | env: 27 | NPM_VERSION: ${{steps.vars.outputs.version}} 28 | 29 | - name: checkout neurosnap/starfx-examples 30 | uses: actions/checkout@v4 31 | with: 32 | repository: neurosnap/starfx-examples 33 | path: examples 34 | 35 | - name: Publish Preview Versions 36 | run: npx pkg-pr-new publish './npm' --template './examples/*' 37 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release to npm 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: checkout 15 | uses: actions/checkout@v4 16 | - name: setup deno 17 | uses: denoland/setup-deno@v2 18 | - name: get version 19 | id: vars 20 | run: echo ::set-output name=version::$(echo ${{github.ref_name}} | sed 's/^v//') 21 | - name: setup node 22 | uses: actions/setup-node@v2 23 | with: 24 | node-version: 18.x 25 | registry-url: https://registry.npmjs.com 26 | - name: build 27 | run: deno task npm $NPM_VERSION 28 | env: 29 | NPM_VERSION: ${{steps.vars.outputs.version}} 30 | - name: publish 31 | run: npm publish --access=public 32 | working-directory: ./npm 33 | env: 34 | NODE_AUTH_TOKEN: ${{secrets.NPM_AUTH_TOKEN}} 35 | -------------------------------------------------------------------------------- /.github/workflows/test-ecosystem.yml: -------------------------------------------------------------------------------- 1 | name: test-ecosystem 2 | 3 | on: 4 | push: 5 | branches: main 6 | pull_request: 7 | branches: main 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | test-ecosystem: 14 | name: ${{ matrix.example.repo }}/${{ matrix.example.folder }} 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | example: 20 | - owner: neurosnap 21 | repo: starfx-examples 22 | folder: vite-react 23 | - owner: neurosnap 24 | repo: starfx-examples 25 | folder: parcel-react 26 | - owner: neurosnap 27 | repo: starfx-examples 28 | folder: tests-rtl 29 | steps: 30 | - name: checkout main repo 31 | uses: actions/checkout@v4 32 | with: 33 | repository: "neurosnap/starfx" 34 | path: "starfx" 35 | 36 | - name: setup deno 37 | uses: denoland/setup-deno@v2 38 | 39 | # determines branch and sets it as output available through the `id` 40 | - name: dynamically determine ${{ matrix.example.owner }}/${{ matrix.example.repo }} branch 41 | id: conditionalBranch 42 | shell: bash 43 | run: deno run -A ./starfx/scripts/branch-exists.ts "$GITHUB_HEAD_REF" neurosnap/starfx-examples 44 | 45 | - name: checkout ${{ matrix.example.owner }}/${{ matrix.example.repo }} on ${{ steps.conditionalBranch.outputs.branch }} 46 | uses: actions/checkout@v4 47 | with: 48 | repository: ${{ matrix.example.owner }}/${{ matrix.example.repo }} 49 | path: ${{ matrix.example.repo }} 50 | ref: ${{ steps.conditionalBranch.outputs.branch }} 51 | 52 | - name: bundle for npm 53 | shell: bash 54 | run: deno task npm 0.0.0 55 | working-directory: starfx 56 | 57 | # install in example repos 58 | - name: install ${{ matrix.example.owner }}/${{ matrix.example.repo }} 59 | shell: bash 60 | working-directory: ${{ matrix.example.repo }}/${{ matrix.example.folder }} 61 | run: npm install 62 | 63 | # symlink example repos 64 | - name: symlink built assets 65 | shell: bash 66 | run: deno task sync-build-to install ${{ matrix.example.repo }}/${{ matrix.example.folder }} 67 | working-directory: starfx 68 | 69 | # run build and test in example repos 70 | - name: build ${{ matrix.example.owner }}/${{ matrix.example.repo }} 71 | working-directory: ${{ matrix.example.repo }}/${{ matrix.example.folder }} 72 | run: npm run build --if-present 73 | - name: test ${{ matrix.example.owner }}/${{ matrix.example.repo }} 74 | working-directory: ${{ matrix.example.repo }}/${{ matrix.example.folder }} 75 | run: npm run test --if-present 76 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: main 6 | pull_request: 7 | branches: main 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: setup deno 20 | uses: denoland/setup-deno@v2 21 | 22 | - name: format 23 | run: deno fmt --check 24 | 25 | - name: lint 26 | run: deno lint 27 | 28 | - name: test 29 | run: deno task test 30 | 31 | - name: npm 32 | run: deno task npm 0.0.0 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.log 3 | node_modules/ 4 | npm/ 5 | .DS_Store 6 | *.bak 7 | .vscode 8 | docs/public/* 9 | !docs/public/.gitkeep 10 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright © `2023` `Eric Bower` 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the “Software”), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | fmt: 2 | deno fmt 3 | .PHONY: 4 | 5 | lint: 6 | deno lint 7 | .PHONY: lint 8 | 9 | test: 10 | deno task test 11 | .PHONY: test 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![starfx](./docs/static/logo.svg) 2 | 3 | # starfx 4 | 5 | A micro-mvc framework for react apps. 6 | 7 | If we think in terms of MVC, if `react` is the **View**, then `starfx` is the 8 | **Model** and **Controller**. 9 | 10 | [Get started](https://starfx.bower.sh) 11 | 12 | Features: 13 | 14 | - A powerful middleware system to fetch API data 15 | - An immutable and reactive data store 16 | - A task tree side-effect system for handling complex business logic using 17 | structured concurrency 18 | - React integration 19 | 20 | ```tsx 21 | import { createApi, createSchema, createStore, mdw, timer } from "starfx"; 22 | import { Provider, useCache } from "starfx/react"; 23 | 24 | const [schema, initialState] = createSchema(); 25 | const store = createStore({ initialState }); 26 | 27 | const api = createApi(); 28 | // mdw = middleware 29 | api.use(mdw.api({ schema })); 30 | api.use(api.routes()); 31 | api.use(mdw.fetch({ baseUrl: "https://api.github.com" })); 32 | 33 | const fetchRepo = api.get( 34 | "/repos/neurosnap/starfx", 35 | { supervisor: timer() }, 36 | api.cache(), 37 | ); 38 | 39 | store.run(api.bootup); 40 | 41 | function App() { 42 | return ( 43 | 44 | 45 | 46 | ); 47 | } 48 | 49 | function Example() { 50 | const { isInitialLoading, isError, message, data } = useCache(fetchRepo()); 51 | 52 | if (isInitialLoading) return "Loading ..."; 53 | 54 | if (isError) return `An error has occurred: ${message}`; 55 | 56 | return ( 57 |
58 |

{data.name}

59 |

{data.description}

60 | 👀 {data.subscribers_count}{" "} 61 | ✨ {data.stargazers_count}{" "} 62 | 🍴 {data.forks_count} 63 |
64 | ); 65 | } 66 | ``` 67 | -------------------------------------------------------------------------------- /action.ts: -------------------------------------------------------------------------------- 1 | import { 2 | call, 3 | Callable, 4 | createContext, 5 | createSignal, 6 | each, 7 | Operation, 8 | Signal, 9 | SignalQueueFactory, 10 | spawn, 11 | Stream, 12 | } from "effection"; 13 | import { ActionPattern, matcher } from "./matcher.ts"; 14 | import type { Action, ActionWithPayload, AnyAction } from "./types.ts"; 15 | import { createFilterQueue } from "./queue.ts"; 16 | import { ActionFnWithPayload } from "./types.ts"; 17 | 18 | export const ActionContext = createContext( 19 | "starfx:action", 20 | createSignal(), 21 | ); 22 | 23 | export function useActions(pattern: ActionPattern): Stream { 24 | return { 25 | *subscribe() { 26 | const actions = yield* ActionContext; 27 | const match = matcher(pattern); 28 | yield* SignalQueueFactory.set(() => createFilterQueue(match) as any); 29 | return yield* actions.subscribe(); 30 | }, 31 | }; 32 | } 33 | 34 | export function emit({ 35 | signal, 36 | action, 37 | }: { 38 | signal: Signal; 39 | action: AnyAction | AnyAction[]; 40 | }) { 41 | if (Array.isArray(action)) { 42 | if (action.length === 0) { 43 | return; 44 | } 45 | action.map((a) => signal.send(a)); 46 | } else { 47 | signal.send(action); 48 | } 49 | } 50 | 51 | export function* put(action: AnyAction | AnyAction[]) { 52 | const signal = yield* ActionContext; 53 | return emit({ 54 | signal, 55 | action, 56 | }); 57 | } 58 | 59 | export function take

( 60 | pattern: ActionPattern, 61 | ): Operation>; 62 | export function* take(pattern: ActionPattern): Operation { 63 | const fd = useActions(pattern); 64 | for (const action of yield* each(fd)) { 65 | return action; 66 | } 67 | 68 | return { type: "take failed, this should not be possible" }; 69 | } 70 | 71 | export function* takeEvery( 72 | pattern: ActionPattern, 73 | op: (action: AnyAction) => Operation, 74 | ) { 75 | const fd = useActions(pattern); 76 | for (const action of yield* each(fd)) { 77 | yield* spawn(() => op(action)); 78 | yield* each.next(); 79 | } 80 | } 81 | 82 | export function* takeLatest( 83 | pattern: ActionPattern, 84 | op: (action: AnyAction) => Operation, 85 | ) { 86 | const fd = useActions(pattern); 87 | let lastTask; 88 | 89 | for (const action of yield* each(fd)) { 90 | if (lastTask) { 91 | yield* lastTask.halt(); 92 | } 93 | lastTask = yield* spawn(() => op(action)); 94 | yield* each.next(); 95 | } 96 | } 97 | 98 | export function* takeLeading( 99 | pattern: ActionPattern, 100 | op: (action: AnyAction) => Operation, 101 | ) { 102 | while (true) { 103 | const action = yield* take(pattern); 104 | yield* call(() => op(action)); 105 | } 106 | } 107 | 108 | export function* waitFor(predicate: Callable) { 109 | const init = yield* call(predicate as any); 110 | if (init) { 111 | return; 112 | } 113 | 114 | while (true) { 115 | yield* take("*"); 116 | const result = yield* call(() => predicate as any); 117 | if (result) { 118 | return; 119 | } 120 | } 121 | } 122 | 123 | export function getIdFromAction( 124 | action: ActionWithPayload<{ key: string }> | ActionFnWithPayload, 125 | ): string { 126 | return typeof action === "function" ? action.toString() : action.payload.key; 127 | } 128 | 129 | export const API_ACTION_PREFIX = ""; 130 | 131 | export function createAction(actionType: string): () => Action; 132 | export function createAction

( 133 | actionType: string, 134 | ): (p: P) => ActionWithPayload

; 135 | export function createAction(actionType: string) { 136 | if (!actionType) { 137 | throw new Error("createAction requires non-empty string"); 138 | } 139 | const fn = (payload?: unknown) => ({ 140 | type: actionType, 141 | payload, 142 | }); 143 | fn.toString = () => actionType; 144 | 145 | return fn; 146 | } 147 | -------------------------------------------------------------------------------- /compose.ts: -------------------------------------------------------------------------------- 1 | import { Instruction, Operation } from "effection"; 2 | import type { Next } from "./types.ts"; 3 | 4 | export interface BaseCtx { 5 | // deno-lint-ignore no-explicit-any 6 | [key: string]: any; 7 | } 8 | 9 | export type BaseMiddleware = ( 10 | ctx: Ctx, 11 | next: Next, 12 | ) => Operation; 13 | 14 | export function compose( 15 | middleware: BaseMiddleware[], 16 | ) { 17 | if (!Array.isArray(middleware)) { 18 | throw new TypeError("Middleware stack must be an array!"); 19 | } 20 | 21 | for (const fn of middleware) { 22 | if (typeof fn !== "function") { 23 | throw new TypeError("Middleware must be composed of functions!"); 24 | } 25 | } 26 | 27 | return function* composeFn(context: Ctx, mdw?: BaseMiddleware) { 28 | // last called middleware # 29 | let index = -1; 30 | 31 | function* dispatch(i: number): Generator { 32 | if (i <= index) { 33 | throw new Error("next() called multiple times"); 34 | } 35 | index = i; 36 | let fn: BaseMiddleware | undefined = middleware[i]; 37 | if (i === middleware.length) { 38 | fn = mdw; 39 | } 40 | if (!fn) { 41 | return; 42 | } 43 | const nxt = dispatch.bind(null, i + 1); 44 | yield* fn(context, nxt); 45 | } 46 | 47 | yield* dispatch(0); 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": { 3 | "types": "deno run --allow-write ./api-type-template.ts", 4 | "npm": "deno run -A ./scripts/npm.ts", 5 | "test": "deno test --allow-env --allow-read --allow-import", 6 | "sync-build-to": "deno run -A ./scripts/sync.ts" 7 | }, 8 | "lint": { 9 | "exclude": ["npm/", "examples/"], 10 | "rules": { 11 | "tags": ["recommended"], 12 | "exclude": ["no-explicit-any", "require-yield"] 13 | } 14 | }, 15 | "fmt": { 16 | "exclude": ["npm/", "examples/"] 17 | }, 18 | "compilerOptions": { 19 | "strict": true, 20 | "lib": ["deno.window", "dom"], 21 | "jsx": "react", 22 | "jsxFactory": "React.createElement", 23 | "jsxFragmentFactory": "React.Fragment" 24 | }, 25 | "imports": { 26 | "react": "npm:react@^18.2.0", 27 | "react-dom": "npm:react-dom@^18.2.0", 28 | "react-redux": "npm:react-redux@^8.0.5", 29 | "reselect": "npm:reselect@^4.1.8", 30 | "immer": "npm:immer@^10.0.2", 31 | "effection": "https://deno.land/x/effection@3.0.0-beta.3/mod.ts" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /deps.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | Callable, 3 | Channel, 4 | Instruction, 5 | Operation, 6 | Predicate, 7 | Queue, 8 | Reject, 9 | Resolve, 10 | Result, 11 | Scope, 12 | Signal, 13 | Stream, 14 | Subscription, 15 | Task, 16 | } from "https://deno.land/x/effection@3.0.0-beta.3/mod.ts"; 17 | export { 18 | action, 19 | call, 20 | createChannel, 21 | createContext, 22 | createQueue, 23 | createScope, 24 | createSignal, 25 | each, 26 | ensure, 27 | Err, 28 | Ok, 29 | race, 30 | resource, 31 | run, 32 | SignalQueueFactory, 33 | sleep, 34 | spawn, 35 | suspend, 36 | useAbortSignal, 37 | useScope, 38 | } from "https://deno.land/x/effection@3.0.0-beta.3/mod.ts"; 39 | 40 | import React from "https://esm.sh/react@18.2.0?pin=v135"; 41 | 42 | export type { JSX } from "https://esm.sh/react@18.2.0?pin=v135"; 43 | 44 | export { React }; 45 | export { 46 | Provider, 47 | useDispatch, 48 | useSelector, 49 | useStore, 50 | } from "https://esm.sh/react-redux@8.0.5?pin=v135"; 51 | export type { 52 | TypedUseSelectorHook, 53 | } from "https://esm.sh/react-redux@8.0.5?pin=v135"; 54 | export { createSelector } from "https://esm.sh/reselect@4.1.8?pin=v135"; 55 | 56 | export { 57 | enablePatches, 58 | produce, 59 | produceWithPatches, 60 | } from "https://esm.sh/immer@10.0.2?pin=v135"; 61 | export type { Patch } from "https://esm.sh/immer@10.0.2?pin=v135"; 62 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | fmt: 2 | go fmt ./... 3 | deno fmt 4 | .PHONY: fmt 5 | 6 | clean: 7 | rm -rf ./public/* 8 | .PHONY: clean 9 | 10 | ssg: 11 | go run ./main.go 12 | cp ./static/* ./public 13 | .PHONY: ssg 14 | 15 | dev: ssg 16 | rsync -vr ./public/ erock@pgs.sh:/starfx-local 17 | .PHONY: dev 18 | 19 | prod: ssg 20 | rsync -vr ./public/ erock@pgs.sh:/starfx-prod 21 | .PHONY: prod 22 | -------------------------------------------------------------------------------- /docs/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/neurosnap/starfx/docs 2 | 3 | go 1.22 4 | 5 | // replace github.com/picosh/pdocs => /home/erock/dev/pico/pdocs 6 | 7 | require github.com/picosh/pdocs v0.0.0-20241118044720-1a43b70d33b7 8 | 9 | require ( 10 | github.com/alecthomas/chroma v0.10.0 // indirect 11 | github.com/dlclark/regexp2 v1.10.0 // indirect 12 | github.com/yuin/goldmark v1.7.0 // indirect 13 | github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594 // indirect 14 | github.com/yuin/goldmark-meta v1.1.0 // indirect 15 | go.abhg.dev/goldmark/anchor v0.1.1 // indirect 16 | go.abhg.dev/goldmark/toc v0.10.0 // indirect 17 | gopkg.in/yaml.v2 v2.4.0 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /docs/go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= 2 | github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 7 | github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0= 8 | github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 9 | github.com/picosh/pdocs v0.0.0-20241118044720-1a43b70d33b7 h1:bFTqN2+VzAvv3+///We9tgKsZt84lcEAi9Jp9YmDV20= 10 | github.com/picosh/pdocs v0.0.0-20241118044720-1a43b70d33b7/go.mod h1:KXO3Z0EVdA811AX6mlK4lwFDT+KgmegRVrEmZU5uLXU= 11 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 12 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 13 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 14 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 15 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 16 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 17 | github.com/yuin/goldmark v1.4.5/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg= 18 | github.com/yuin/goldmark v1.7.0 h1:EfOIvIMZIzHdB/R/zVrikYLPPwJlfMcNczJFMs1m6sA= 19 | github.com/yuin/goldmark v1.7.0/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 20 | github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594 h1:yHfZyN55+5dp1wG7wDKv8HQ044moxkyGq12KFFMFDxg= 21 | github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594/go.mod h1:U9ihbh+1ZN7fR5Se3daSPoz1CGF9IYtSvWwVQtnzGHU= 22 | github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc= 23 | github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0= 24 | go.abhg.dev/goldmark/anchor v0.1.1 h1:NUH3hAzhfeymRqZKOkSoFReZlEAmfXBZlbXEzpD2Qgc= 25 | go.abhg.dev/goldmark/anchor v0.1.1/go.mod h1:zYKiaHXTdugwVJRZqInVdmNGQRM3ZRJ6AGBC7xP7its= 26 | go.abhg.dev/goldmark/toc v0.10.0 h1:de3LrIimwtGhBMKh7aEl1c6n4XWwOdukIO5wOAMYZzg= 27 | go.abhg.dev/goldmark/toc v0.10.0/go.mod h1:OpH0qqRP9v/eosCV28ZeqGI78jZ8rri3C7Jh8fzEo2M= 28 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 29 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 30 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 31 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 32 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 33 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 34 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 35 | pgregory.net/rapid v1.1.0 h1:CMa0sjHSru3puNx+J0MIAuiiEV4N0qj8/cMWGBBCsjw= 36 | pgregory.net/rapid v1.1.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= 37 | -------------------------------------------------------------------------------- /docs/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log/slog" 5 | "math/rand" 6 | "strconv" 7 | 8 | "github.com/picosh/pdocs" 9 | ) 10 | 11 | func main() { 12 | pager := pdocs.Pager("./posts") 13 | sitemap := &pdocs.Sitemap{ 14 | Children: []*pdocs.Sitemap{ 15 | { 16 | Text: "Home", 17 | Href: "/", 18 | Page: pager("home.md"), 19 | }, 20 | { 21 | Text: "Sitemap", 22 | Href: "/sitemap", 23 | Page: pager("sitemap.md"), 24 | }, 25 | { 26 | Text: "Getting started", 27 | Href: "/getting-started", 28 | Page: pager("getting-started.md"), 29 | }, 30 | { 31 | Text: "Learn", 32 | Href: "/learn", 33 | Page: pager("learn.md"), 34 | }, 35 | { 36 | Text: "Controllers", 37 | Href: "/controllers", 38 | Page: pager("controllers.md"), 39 | Children: []*pdocs.Sitemap{ 40 | { 41 | Text: "Thunks", 42 | Href: "/thunks", 43 | Page: pager("thunks.md"), 44 | }, 45 | { 46 | Text: "Endpoints", 47 | Href: "/endpoints", 48 | Page: pager("endpoints.md"), 49 | }, 50 | { 51 | Text: "Dispatch", 52 | Href: "/dispatch", 53 | Page: pager("dispatch.md"), 54 | }, 55 | }, 56 | }, 57 | { 58 | Text: "Models", 59 | Href: "/models", 60 | Page: pager("models.md"), 61 | Children: []*pdocs.Sitemap{ 62 | { 63 | 64 | Text: "Store", 65 | Href: "/store", 66 | Page: pager("store.md"), 67 | }, 68 | { 69 | Text: "Schema", 70 | Href: "/schema", 71 | Page: pager("schema.md"), 72 | }, 73 | { 74 | Text: "Selectors", 75 | Href: "/selectors", 76 | Page: pager("selectors.md"), 77 | }, 78 | }, 79 | }, 80 | { 81 | Text: "React", 82 | Href: "/react", 83 | Page: pager("react.md"), 84 | }, 85 | { 86 | Text: "Guides & Concepts", 87 | Children: []*pdocs.Sitemap{ 88 | { 89 | Text: "Caching", 90 | Href: "/caching", 91 | Page: pager("caching.md"), 92 | }, 93 | { 94 | Text: "Dependent Queries", 95 | Href: "/dependent-queries", 96 | Page: pager("dependent.md"), 97 | }, 98 | { 99 | Text: "Middleware", 100 | Href: "/middleware", 101 | Page: pager("mdw.md"), 102 | }, 103 | { 104 | Text: "Loaders", 105 | Href: "/loaders", 106 | Page: pager("loaders.md"), 107 | }, 108 | { 109 | Text: "FX", 110 | Href: "/fx", 111 | Page: pager("fx.md"), 112 | }, 113 | { 114 | Text: "Error Handling", 115 | Href: "/error-handling", 116 | Page: pager("error-handling.md"), 117 | }, 118 | { 119 | Text: "Structured Concurrency", 120 | Href: "/structured-concurrency", 121 | Page: pager("structured-concurrency.md"), 122 | }, 123 | { 124 | Text: "Supervisors", 125 | Href: "/supervisors", 126 | Page: pager("supervisors.md"), 127 | }, 128 | { 129 | Text: "Testing", 130 | Href: "/testing", 131 | Page: pager("testing.md"), 132 | }, 133 | { 134 | Text: "Design Philosophy", 135 | Href: "/design-philosophy", 136 | Page: pager("design-philosophy.md"), 137 | }, 138 | }, 139 | }, 140 | { 141 | Text: "Resources", 142 | Href: "/resources", 143 | Page: pager("resources.md"), 144 | }, 145 | }, 146 | } 147 | 148 | logger := slog.Default() 149 | config := &pdocs.DocConfig{ 150 | Logger: logger, 151 | Sitemap: sitemap, 152 | Out: "./public", 153 | Tmpl: "./tmpl", 154 | PageTmpl: "post.page.tmpl", 155 | CacheId: strconv.Itoa(rand.Intn(10000)), 156 | } 157 | 158 | err := config.GenSite() 159 | if err != nil { 160 | panic(err) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /docs/posts/caching.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Caching 3 | description: How to store data in starfx 4 | --- 5 | 6 | There are two primary ways to store data in `starfx`: 7 | 8 | - Manual 9 | - Automatic 10 | 11 | # Manual 12 | 13 | You have full control over how data is stored in your app, however, the cost is 14 | managing it. 15 | 16 | For anything beyond the simplest of apps, actively managing your state is going 17 | to promote a more robust and managable codebase. When you are performing CRUD 18 | operations and want to store those records in a database table that is strongly 19 | typed, you probably want manually managed. 20 | 21 | The good news this is really easy in `starfx` because we can leverage 22 | [schemas](/schema) to do most of the heavy lifting. 23 | 24 | # Automatic 25 | 26 | This one is simpler to setup, easy for it to "just work" and is more like 27 | `react-query`. 28 | 29 | When using an endpoint, this method simply stores whatever is put inside 30 | `ctx.json`. Then you can access that data via `useCache`. 31 | 32 | ```tsx 33 | import { createApi } from "starfx"; 34 | import { useCache } from "starfx/react"; 35 | 36 | const api = createApi(); 37 | const fetchUsers = api.get("/users", api.cache()); 38 | 39 | function App() { 40 | const { data = [] } = useCache(fetchUsers()); 41 | return

{data.map((user) =>
{user.name}
)}
; 42 | } 43 | ``` 44 | 45 | `api.cache()` opts into automatic caching. This is really just an alias for: 46 | 47 | ```ts 48 | function*(ctx, next) { 49 | ctx.cache = true; 50 | yield* next(); 51 | } 52 | ``` 53 | 54 | The state slice for `cache` is simple, every thunk action has 55 | [special properties](/thunks#anatomy-of-an-action) of which one is a `key` field 56 | that is a hash of the entire user-defined action payload: 57 | 58 | ```js 59 | { 60 | [action.payload.key]: {}, 61 | } 62 | ``` 63 | 64 | # `timer` supervisor 65 | 66 | This supervisor can help us with how often we refetch data. This will help us 67 | call the same endpoint many times but only fetching the data on an interval. 68 | 69 | [Read more about it in Supervisors](/supervisors#timer) 70 | 71 | This, cominbed with [Automatic caching](#automatic) provides us with the 72 | fundamental features built into `react-query`. 73 | -------------------------------------------------------------------------------- /docs/posts/controllers.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Controllers 3 | description: How controllers work in starfx 4 | --- 5 | 6 | Why do we call this a micro-mvc framework? Well, our controllers are lighter 7 | weight than traditional MVC frameworks. 8 | 9 | Controllers do not relate to pages, they most often relate to centralized pieces 10 | of business logic. This could be as simple as making a single API endpoint and 11 | caching the results or as complex as making multiple dependent API calls and 12 | combinatory logic. 13 | 14 | Not only do have a centralized place for handling complex business logic, 15 | fetching API data, and updating our FE global state, but we also have a robust 16 | middleware system similar to `express` or `koa`! 17 | 18 | In the following sections we will discuss how to create controllers and the 19 | different use cases for them inside `starfx`. 20 | -------------------------------------------------------------------------------- /docs/posts/dependent.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Dependent Queries 3 | slug: dependent-queries 4 | description: How to call other thunks and endpoints within one 5 | --- 6 | 7 | In this context, thunks and endpoints are identical, so I will just talk about 8 | thunks throughout this guide. 9 | 10 | There are two ways to call a thunk within another thunk. 11 | 12 | # Dispatch the thunk as an action 13 | 14 | Features: 15 | 16 | - Non-blocking 17 | - Thunk is still controlled by supervisor 18 | - Works identical to `dispatch(action)` 19 | 20 | ```ts 21 | import { put } from "starfx"; 22 | const fetchMailboxes = api.get("/mailboxes"); 23 | const fetchMail = thunks.create("fetch-mail", function* (ctx, next) { 24 | yield* put(fetchMailboxes()); 25 | }); 26 | ``` 27 | 28 | This is the equivalent of using `useDispatch` in your view. As a result, it is 29 | also controlled by the thunk's supervisor task. If that thunk has a supervisor 30 | that might drop the middleware stack from activating (e.g. `takeLeading` or 31 | `timer`) then it might not actually get called. Further, this operation 32 | completes immediately, it does **not** wait for the thunk to complete before 33 | moving to the next yield point. 34 | 35 | If you want to make a blocking call to the thunk and wait for it to complete 36 | then you want to call the thunk's middleware stack directly. 37 | 38 | # Call the middleware stack directly 39 | 40 | Features: 41 | 42 | - Blocking 43 | - Middleware stack guarenteed to run 44 | - Does **not** go through supervisor task 45 | 46 | What do we mean by "middleware stack"? That is the stack of functions that you 47 | define for a thunk. It does **not** include the supervisor task that manages the 48 | thunk. Because a supervisor task could drop, pause, or delay the execution of a 49 | thunk, we need a way to escape hatch out of it and just call the middleware 50 | stack directly. 51 | 52 | ```ts 53 | import { parallel, put } from "starfx"; 54 | // imaginary schema 55 | import { schema } from "./schema"; 56 | 57 | const fetchMailboxes = api.get("/mailboxes"); 58 | const fetchMessages = api.get<{ id: string }>("/mailboxes/:id/messages"); 59 | const fetchMail = thunks.create("fetch-mail", function* (ctx, next) { 60 | const boxesCtx = yield* fetchMailboxes.run(); 61 | if (!boxesCtx.json.ok) { 62 | return; 63 | } 64 | 65 | const boxes = yield* select(schema.mailboxes.selectTableAsList); 66 | const group = yield* parallel(boxes.map((box) => { 67 | return fetchMessages.run({ id: box.id }); 68 | })); 69 | const messages = yield* select(schema.messages.selectTableAsList); 70 | console.log(messages); 71 | }); 72 | ``` 73 | -------------------------------------------------------------------------------- /docs/posts/design-philosophy.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Design Philosophy 3 | --- 4 | 5 | - user interaction is a side-effect of using a web app 6 | - side-effect management is the central processing unit to manage user 7 | interaction, app features, and state 8 | - leverage structured concurrency to manage side-effects 9 | - leverage supervisor tasks to provide powerful design patterns 10 | - side-effect and state management decoupled from the view 11 | - user has full control over state management (opt-in to automatic data 12 | synchronization) 13 | - state is just a side-effect (of user interaction and app features) 14 | -------------------------------------------------------------------------------- /docs/posts/dispatch.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Dispatch 3 | description: How to activate controllers 4 | --- 5 | 6 | We use the term `dispatch` when we are emitting an event with a specific type 7 | signature 8 | ([flux standard action](https://github.com/redux-utilities/flux-standard-action)). 9 | 10 | There are two ways to activate a thunk: by dispatching an action or calling it 11 | within another thunk. 12 | 13 | The type signature of `dispatch`: 14 | 15 | ```ts 16 | type Dispatch = (a: Action | Action[]) => any; 17 | ``` 18 | 19 | Within `starfx`, the `dispatch` function lives on the store. 20 | 21 | ```ts 22 | const { createSchema, createStore } from "starfx"; 23 | const [schema, initialState] = createSchema(); 24 | const store = createStore({ initialState }); 25 | 26 | store.dispatch({ type: "action", payload: {} }); 27 | ``` 28 | 29 | # Dispatch in thunk 30 | 31 | ```ts 32 | import { put } from "starfx"; 33 | 34 | function* thunk(ctx, next) { 35 | yield* put({ type: "click" }); 36 | yield* next(); 37 | } 38 | ``` 39 | 40 | # Dispatch in react 41 | 42 | You can also use dispatch with a `react` hook: 43 | 44 | ```tsx 45 | import { useDispatch } from "starfx/react"; 46 | 47 | function App() { 48 | const dispatch = useDispatch(); 49 | 50 | return ; 51 | } 52 | ``` 53 | 54 | # Listening to actions 55 | 56 | This is a pubsub system after all. How can we listen to action events? 57 | 58 | ```ts 59 | import { take } from "starfx"; 60 | 61 | function* watch() { 62 | while (true) { 63 | const action = yield* take("click"); 64 | // -or- const action = yield* take("*"); 65 | // -or- const action = yield* take((act) => act.type === "click"); 66 | // -or- const action = yield* take(["click", "me"]); 67 | console.log(action.payload); 68 | } 69 | } 70 | 71 | store.run(watch); 72 | ``` 73 | 74 | `watch` is what we call a [supervisor](/supervisors). Click that link to learn 75 | more about how they provide powerful flow control mechanisms. 76 | -------------------------------------------------------------------------------- /docs/posts/error-handling.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Error handling 3 | description: How to manage errors 4 | --- 5 | 6 | By leveraging `effection` and [structured concurrency](/structured-concurrency) 7 | we can let it do most of the heavy lifting for managing errors. 8 | 9 | > Read [error handling](https://frontside.com/effection/docs/errors) doc at 10 | > `effection`! 11 | 12 | There are some tools `starfx` provides to make it a little easier. 13 | 14 | By default in `effection`, if a child task raises an exception, it will bubble 15 | up the ancestry and eventually try to kill the root task. Within `starfx`, we 16 | prevent that from happening with [supervisor](/supervisors) tasks. Having said 17 | that, child tasks can also control how children tasks are managed. Sometimes you 18 | want to kill the child task tree, sometimes you want to recover and restart, and 19 | sometimes you want to bubble the error up the task ancestry. 20 | 21 | If you want to capture a task and prevent it from bubbling an exception up, then 22 | you have two `fx`: `call` and `safe`. 23 | 24 | ```ts 25 | import { call, run, safe } from "starfx"; 26 | 27 | function* main() { 28 | try { 29 | // use `call` to enable JS try/catch 30 | yield* call(fetch("api.com")); 31 | } catch (err) { 32 | console.error(err); 33 | } 34 | 35 | // -or- if you don't want to use try/catch 36 | const result = yield* safe(fetch("api.com")); 37 | if (!result.ok) { 38 | console.error(result.err); 39 | } 40 | } 41 | 42 | await run(main); 43 | ``` 44 | 45 | Both functions will catch the child task and prevent it from bubbling up the 46 | error. 47 | -------------------------------------------------------------------------------- /docs/posts/fx.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: fx 3 | description: Utilities to handle complex async flow control 4 | --- 5 | 6 | `fx` (pronounced Effects) are helper functions to make async flow control 7 | easier. 8 | 9 | # parallel 10 | 11 | The goal of `parallel` is to make it easier to cooridnate multiple async 12 | operations in parallel, with different ways to receive completed tasks. 13 | 14 | All tasks are called with `fx.safe` which means they will never throw an 15 | exception. Instead all tasks will return a Result object that the end 16 | development must evaluate in order to grab the value. 17 | 18 | ```ts 19 | import { parallel } from "starfx"; 20 | 21 | function* run() { 22 | const task = yield* parallel([job1, job2]); 23 | // wait for all tasks to complete before moving to next yield point 24 | const results = yield* task; 25 | // job1 = results[0]; 26 | // job2 = results[1]; 27 | } 28 | ``` 29 | 30 | Instead of waiting for all tasks to complete, we can instead loop over tasks as 31 | they arrive: 32 | 33 | ```ts 34 | function* run() { 35 | const task = yield* parallel([job1, job2]); 36 | for (const job of yield* each(task.immediate)) { 37 | // job2 completes first then it will be first in list 38 | console.log(job); 39 | yield* each.next(); 40 | } 41 | } 42 | ``` 43 | 44 | Or we can instead loop over tasks in order of the array provided to parallel: 45 | 46 | ```ts 47 | function* run() { 48 | const task = yield* parallel([job1, job2]); 49 | for (const job of yield* each(task.sequence)) { 50 | // job1 then job2 will be returned regardless of when the jobs 51 | // complete 52 | console.log(job); 53 | yield* each.next(); 54 | } 55 | } 56 | ``` 57 | -------------------------------------------------------------------------------- /docs/posts/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting Started 3 | description: Use starfx with deno, node, or the browser 4 | toc: 1 5 | --- 6 | 7 | # Motivation 8 | 9 | We think we need a react framework and server-side rendering (SSR) because 10 | that's where money is being made. If we are building a highly dynamic and 11 | interactive web application then we probably don't need SSR. These frameworks 12 | sell us that they are an easier way to build web apps, but that's not strictly 13 | true. Just think of it this way: if we can build a web app using **only** static 14 | assets, isn't that simpler than having static assets **and** a react framework 15 | server? 16 | 17 | React hook-based fetching and caching libraries dramatically simplify data 18 | synchronization but are so tightly coupled to a component's life cycle that it 19 | creates waterfall fetches and loading spinners everywhere. We also have the 20 | downside of not being able to normalize our cache which means we have to spend 21 | time thinking about how and when to invalidate our cache. 22 | 23 | Further, all of these data caching libraries don't handle data normalization. In 24 | many similar libraries we are going to see a line like: "Data normalization is 25 | hard and it isn't worth it." Their libraries are not built with data 26 | normalization in mind so they claim it's an anti-feature. Why do we want to 27 | normalize data in the backend but not the frontend? Data normalization is 28 | critically important because it makes CRUD operations automatically update our 29 | web app without having to invalidate our cache. 30 | 31 | So what if we are building a highly interactive web app that doesn't need SEO 32 | and we also need more control over data synchronization and caching? 33 | 34 | Are you frustrated by the following issues in your react app? 35 | 36 | - Prop drilling 37 | - Waterfall fetching data 38 | - Loading spinners everywhere 39 | - Extraneous network calls 40 | - Business logic tightly coupled to react component lifecycle hooks 41 | - State management boilerplate 42 | - Lack of state management 43 | - Lack of async flow control tooling 44 | 45 | We built `starfx` because we looked at the web app landscape and felt like there 46 | was something missing. 47 | 48 | The benefits of using this library: 49 | 50 | - The missing model and controller (MC) in react (V) 51 | - Designed for single-page applications (SPAs) 52 | - Makes data normalization easy and straightforward 53 | - Tools to preload and refresh data 54 | - Has a powerful middleware system similar to `express` to handle requests and 55 | responses 56 | - Reduces state management boilerplate to its absolute essentials 57 | - Has a robust side-effect management system using structured concurrency 58 | - Has data synchronization and caching separated from `react` 59 | 60 | # When to use this library? 61 | 62 | The primary target for this library are SPAs. This is for an app that might be 63 | hosted inside an object store (like s3) or with a simple web server (like nginx) 64 | that serves files and that's it. 65 | 66 | Is your app highly interactive, requiring it to persist data across pages? This 67 | is the sweet spot for `starfx`. 68 | 69 | This library is **not** a great fit for ecommerce, tiny projects, or blogs. This 70 | is for web apps that are generally behind a login screen that require a 71 | desktop-class user experience. This library is designed to scale, so it might 72 | feel a little overwhelming. Just know if you use this library, your code will be 73 | easier to read, easier to write, easier to refactor, all while handling a 74 | massive amount of business complexity. 75 | 76 | # Code 77 | 78 | Here we demonstrate a complete example so you can glimpse at how `starfx` works. 79 | In this example, we will fetch a github repo from an API endpoint, cache the 80 | `Response` json, and then ensure the endpoint only gets called at-most once 81 | every **5 minutes**, mimicking the basic features of `react-query`. 82 | 83 | [Codesanbox](https://codesandbox.io/p/sandbox/starfx-simplest-dgqc9v?file=%2Fsrc%2Findex.tsx) 84 | 85 | ```tsx 86 | import { createApi, createSchema, createStore, mdw, timer } from "starfx"; 87 | import { Provider, useCache } from "starfx/react"; 88 | 89 | const [schema, initialState] = createSchema(); 90 | const store = createStore({ initialState }); 91 | 92 | const api = createApi(); 93 | // mdw = middleware 94 | api.use(mdw.api({ schema })); 95 | // where `fetchRepo` will be placed inside the middleware stack 96 | api.use(api.routes()); 97 | api.use(mdw.fetch({ baseUrl: "https://api.github.com" })); 98 | 99 | const fetchRepo = api.get( 100 | "/repos/neurosnap/starfx", 101 | { supervisor: timer() }, 102 | api.cache(), 103 | ); 104 | 105 | store.run(api.register); 106 | 107 | function App() { 108 | return ( 109 | 110 | 111 | 112 | ); 113 | } 114 | 115 | function Example() { 116 | const { isInitialLoading, isError, message, data } = useCache(fetchRepo()); 117 | if (isInitialLoading) return "Loading ..."; 118 | if (isError) return `An error has occurred: ${message}`; 119 | 120 | return ( 121 |
122 |

{data.name}

123 |

{data.description}

124 | 👀 {data.subscribers_count}{" "} 125 | ✨ {data.stargazers_count}{" "} 126 | 🍴 {data.forks_count} 127 |
128 | ); 129 | } 130 | ``` 131 | 132 | # Install 133 | 134 | ```bash 135 | npm install starfx 136 | ``` 137 | 138 | ```bash 139 | yarn add starfx 140 | ``` 141 | 142 | ```ts 143 | import * as starfx from "https://deno.land/x/starfx@0.13.2/mod.ts"; 144 | ``` 145 | 146 | # Effection 147 | 148 | This library leverages structured concurrency using 149 | [`effection`](https://frontside.com/effection). It is highly recommended that 150 | you have a brief understanding of how its API because it is used heavily within 151 | `starfx`. 152 | -------------------------------------------------------------------------------- /docs/posts/home.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: starfx 3 | description: A micro-mvc framework for react apps 4 | slug: index 5 | template: home.page.tmpl 6 | --- 7 | -------------------------------------------------------------------------------- /docs/posts/learn.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Learn 3 | description: Fundamental concepts in starfx 4 | --- 5 | 6 | # How does `starfx` work? 7 | 8 | `starfx` is a companion framework to `react` that understands how to listen to 9 | user events (e.g. clicks, form inputs, etc.), activate side-effects (e.g. fetch 10 | api data, submit form data, update state), and then intelligently update the 11 | view. If you are familiar with **MVC**: 12 | 13 | - `react` is the **View** layer 14 | - `starfx` are the **Model** and **Controller** layers 15 | 16 | The high-level picture of `starfx` is _essentially_ a glorified pubsub system: 17 | 18 | - The user goes to your app 19 | - The view is generated with `react` 20 | - When a user interacts with your web app, events gets dispatched 21 | - `starfx` listens for events and triggers side-effects (e.g. fetches API data, 22 | updates state, etc.) 23 | - An entirely new version of the state gets created 24 | - `react` surgically updates the view based on changes to the `starfx` state 25 | - Rinse and repeat 26 | 27 | It all happens as a single unidirectional loop. 28 | 29 | # How is `starfx` different? 30 | 31 | `starfx` is different in a number of ways. 32 | 33 | We combine both state and side-effect management into a single cohesive unit. 34 | This streamlines the implementation of your web app. 35 | 36 | Our business logic does not live inside of `react`, rather, it lives inside of 37 | the side-effect system. We are not shackled by `react` lifecycle hooks, in fact, 38 | `starfx` has virtually no concept of `react` at all -- except for a couple of 39 | hooks. The entire system is designed, from the ground up, to not need `react` at 40 | all in order to function. At the end of the day, `starfx` works by subscribing 41 | to and publishing events. Those events could come from `react`, but they could 42 | also come from anywhere. 43 | 44 | We have taken the best part about `express` and `koa` and applied it to fetching 45 | API data on the front-end. What this means is that we have a powerful middleware 46 | system that we can leverage on the front-end. 47 | 48 | We built a state management system leveraging the concept of a database schema. 49 | We took inspiration from [zod](https://zod.dev) to build an ergonomic and 50 | powerful state system leveraging reusable slice helpers. With our schema and 51 | custom built store, we can replace all of boilerplate with a single function 52 | call `createSchema()`. 53 | 54 | # Why does `starfx` use [generators](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator)? 55 | 56 | Generators give us -- the library authors -- more control over how side-effects 57 | are handled within a javascript runtime environment. There are things that we 58 | can do with generators that are just not possible using `async`/`await`. To 59 | provide some specific examples, we need the ability to manage async operations 60 | as a tree of tasks. We need the ability to have 61 | [structured concurrency](https://en.wikipedia.org/wiki/Structured_concurrency) 62 | in order to granularly manipulate, manage, spawn, and teardown tasks. 63 | 64 | Furthermore, `async`/`await` is implemented using generator functions. In 65 | `starfx`, not everything we want to `await` is a `Promise`! 66 | 67 | There is so much more to why generators are awesome but at the end of the day, 68 | to the end developer, you can treat generators the same as `async`/`await`. 69 | 70 | If you are struggling to understand or are getting confused using generator 71 | functions, just use the 72 | [effection async rosetta stone](https://frontside.com/effection/docs/async-rosetta-stone). 73 | 74 | We highly recommend reading the 75 | [Thinking in Effection](https://frontside.com/effection/docs/thinking-in-effection) 76 | page because it should help here. 77 | 78 | # Data strategy: preload then refresh 79 | 80 | The idea is simple: 81 | 82 | > Preload most of your API data in the background and refresh it as the user 83 | > interacts with your web app. 84 | 85 | This strategy removes the need to show loaders throughout your app. 86 | 87 | Preloading is a first-class citizen in `starfx`. It is the primary use case for 88 | using it. 89 | 90 | This is the biggest performance boost to using a single-page app. Since routing 91 | happens all client-side, it's beneficial to first download data in the 92 | background while the user navigates through your web app. While you might be 93 | fetching slow API endpoints, it feels instantaneous because the data was already 94 | loaded before a pager needed to display it. 95 | 96 | When the user lands on your web app, initialize a preload thunk that will sync 97 | the user's database locally, then when they navigate to a page that requires 98 | data, refresh that data as needed. 99 | 100 | For example, let's say the root page `/` requires a list of users while the 101 | `/mailboxes` page requires a list of mailboxes. 102 | 103 | On the root page you would fetch the list of users as well as the lists of 104 | mailboxes. When the user finally decides to click on the "Mailboxes" page, the 105 | page will act as if the data was loaded instantly because it was preloaded. So 106 | the user sees the data immediately, while at the same time you would also 107 | re-fetch the mailboxes. 108 | -------------------------------------------------------------------------------- /docs/posts/loaders.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Loaders 3 | slug: loaders 4 | description: What are loaders? 5 | --- 6 | 7 | Loaders are general purpose "status trackers." They track the status of a thunk, 8 | an endpoint, or a composite of them. One of the big benefits of decoupled 9 | loaders is you can create as many as you want, and control them however you 10 | want. 11 | 12 | [Read my blog article about it](https://bower.sh/on-decoupled-loaders) 13 | 14 | # Usage 15 | 16 | For endpoints, loaders are installed automatically and track fetch requests. 17 | Loader success is determined by `Response.ok` or if `fetch` throws an error. 18 | 19 | You can also use loaders manually: 20 | 21 | ```ts 22 | import { put } from "starfx"; 23 | // imaginary schema 24 | import { schema } from "./schema"; 25 | 26 | function* fn() { 27 | yield* put(schema.loaders.start({ id: "my-id" })); 28 | yield* put(schema.loaders.success({ id: "my-id" })); 29 | yield* put(schema.loaders.error({ id: "my-id", message: "boom!" })); 30 | } 31 | ``` 32 | 33 | For thunks you can use `mdw.loader()` which will track the status of a thunk. 34 | 35 | ```ts 36 | import { createThunks, mdw } from "starfx"; 37 | // imaginary schema 38 | import { initialState, schema } from "./schema"; 39 | 40 | const thunks = createThunks(); 41 | thunks.use(mdw.loader(schema)); 42 | thunks.use(thunks.routes()); 43 | 44 | const go = thunks.create("go", function* (ctx, next) { 45 | throw new Error("boom!"); 46 | }); 47 | 48 | const store = createStore({ initialState }); 49 | store.dispatch(go()); 50 | schema.loaders.selectById(store.getState(), { id: `${go}` }); 51 | // status = "error"; message = "boom!" 52 | ``` 53 | 54 | # Shape 55 | 56 | ```ts 57 | export type IdProp = string | number; 58 | export type LoadingStatus = "loading" | "success" | "error" | "idle"; 59 | export interface LoaderItemState< 60 | M extends Record = Record, 61 | > { 62 | id: string; 63 | status: LoadingStatus; 64 | message: string; 65 | lastRun: number; 66 | lastSuccess: number; 67 | meta: M; 68 | } 69 | 70 | export interface LoaderState< 71 | M extends AnyState = AnyState, 72 | > extends LoaderItemState { 73 | isIdle: boolean; 74 | isLoading: boolean; 75 | isError: boolean; 76 | isSuccess: boolean; 77 | isInitialLoading: boolean; 78 | } 79 | ``` 80 | 81 | # `isLoading` vs `isInitialLoading` 82 | 83 | Why does this distinction exist? Well, when building a web app with `starfx`, 84 | it's very common to have called the same endpoint multiple times. If that loader 85 | has already successfully been called previously, `isInitialLoading` will **not** 86 | flip states. 87 | 88 | The primary use case is: why show a loader if we can already show the user data? 89 | 90 | Conversely, `isLoading` will always be true when a loader is in "loading" state. 91 | 92 | This information is derived from `lastRun` and `lastSuccess`. Those are unix 93 | timestamps of the last "loading" loader and the last time it was in "success" 94 | state, respectively. 95 | 96 | # The `meta` property 97 | 98 | You can put whatever you want in there. This is a useful field when you want to 99 | pass structured data from a thunk into the view on success or failure. Maybe 100 | this is the new `id` for the entity you just created and the view needs to know 101 | it. The `meta` prop is where you would put contextual information beyond the 102 | `message` string. 103 | 104 | Here's an example for how you can update the `meta` property inside an endpoint: 105 | 106 | ```tsx 107 | const fetchUsers = api.get("/users", function* (ctx, next) { 108 | yield* next(); 109 | if (!ctx.json.ok) return; 110 | // this will merge with the default success loader state 111 | // so you don't have to set the `status` here as it is done automatically 112 | // with the api middleware 113 | ctx.loader = { meta: { total: ctx.json.value.length } }; 114 | }); 115 | 116 | function App() { 117 | const loader = useQuery(fetchUsers()); 118 | if (loader.isInitialLoading) return
loading ...
; 119 | if (loader.isError) return
error: {loader.message}
; 120 | return
Total number of users: {loader.meta.total}
; 121 | } 122 | ``` 123 | -------------------------------------------------------------------------------- /docs/posts/mdw.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Middleware 3 | slug: middleware 4 | description: The structure of a middleware function 5 | --- 6 | 7 | Here is the most basic middleware (mdw) function in `starfx`: 8 | 9 | ```ts 10 | function* (ctx, next) { 11 | yield* next(); 12 | } 13 | ``` 14 | 15 | Thunks and endpoints are just thin wrappers around a mdw stack: 16 | 17 | For example, the recommended mdw stack for `createApi()` looks like this: 18 | 19 | ```ts 20 | import { createApi, mdw } from "starfx"; 21 | import { schema } from "./schema"; 22 | 23 | // this api: 24 | const api = createApi(); 25 | api.use(mdw.api({ schema })); 26 | api.use(api.routes()); 27 | api.use(mdw.fetch({ baseUrl: "https://api.com" })); 28 | 29 | // looks like this: 30 | [ 31 | mdw.err, 32 | mdw.queryCtx, 33 | mdw.customKey, 34 | mdw.nameParser, 35 | mdw.actions, 36 | mdw.loaderApi({ schema }), 37 | mdw.cache({ schema }), 38 | api.routes(), 39 | mdw.composeUrl("https://api.com"), 40 | mdw.payload, 41 | mdw.request, 42 | mdw.json, 43 | ]; 44 | ``` 45 | 46 | When a mdw function calls `yield* next()`, all it does it call the next mdw in 47 | the stack. When that yield point resolves, it means all the mdw functions after 48 | it have been called. This doesn't necessarily mean all mdw in the stack will be 49 | called, because like `koa`, you can return early inside a mdw function, 50 | essentially cancelling all subsequent mdw. 51 | 52 | # Context 53 | 54 | The context object is just a plain javascript object that gets passed to every 55 | mdw. The type of `ctx` depends ... on the context. But for thunks, we have this 56 | basic structure: 57 | 58 | ```ts 59 | interface Payload

{ 60 | payload: P; 61 | } 62 | 63 | interface ThunkCtx

extends Payload

{ 64 | name: string; 65 | key: string; 66 | action: ActionWithPayload>; 67 | actionFn: IfAny< 68 | P, 69 | CreateAction, 70 | CreateActionWithPayload, P> 71 | >; 72 | result: Result; 73 | } 74 | ``` 75 | 76 | There are **three** very important properties that you should know about: 77 | 78 | - `name` - the name you provided when creating the thunk 79 | - `payload` - the arbitrary data you passed into the thunk 80 | - `key` - a hash of `name` and `payload` 81 | -------------------------------------------------------------------------------- /docs/posts/models.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Models 3 | description: State management in starfx 4 | --- 5 | 6 | One core component of an MVC framework is the Model. 7 | 8 | Since data normalization is a first-class citizen inside `starfx`, we built a 9 | custom, reactive database for front-end web apps. Like a backend MVC framework, 10 | we want to think of managing the FE store like managing a database. So while 11 | thinking about models as separate entities, you create all your models by 12 | creating a single schema. 13 | 14 | Managing models in `starfx` leverages two primary concepts: schema and store. 15 | 16 | The store is a single, global, and reactive object that was built to make 17 | updating views easy. It is essentially an event emitter with a javascript object 18 | that is updated in a very particular way (via `schema.update`). 19 | 20 | Because the goal of this library is to create scalable web apps, we want users 21 | to create all their models at the same time inside a single schema. 22 | -------------------------------------------------------------------------------- /docs/posts/react.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: React 3 | description: How to integrate with React 4 | toc: 2 5 | --- 6 | 7 | Even though we are **not** using `redux`, if you are familiar with 8 | [react-redux](https://react-redux.js.org) then this will be an identical 9 | experience because `starfx/react` has an identical API signature. 10 | 11 | `useDispatch`, `useSelector`, and `createSelector` are the bread and butter of 12 | `redux`'s integration with `react` all of which we use inside `starfx`. 13 | 14 | ```tsx 15 | import { 16 | TypedUseSelectorHook, 17 | useApi, 18 | useSelector as useBaseSelector, 19 | } from "starfx/react"; 20 | import { schema, WebState } from "./store.ts"; 21 | import { fetchUsers } from "./api.ts"; 22 | 23 | const useSelector: TypedUseSelectorHook = useBaseSelector; 24 | 25 | function App() { 26 | const users = useSelector(schema.users.selectTableAsList); 27 | const api = useApi(fetchUsers()); 28 | 29 | return ( 30 |

31 | {users.map((u) =>
{u.name}
)} 32 |
33 | 34 | {api.isLoading ?
Loading ...
: null} 35 |
36 |
37 | ); 38 | } 39 | ``` 40 | 41 | # Hooks 42 | 43 | ## `useSelector` 44 | 45 | Query your store with this hook. 46 | 47 | ```tsx 48 | import { useSelector } from "starfx"; 49 | 50 | function App() { 51 | const data = useSelector((state) => state.data); 52 | return
{data}
; 53 | } 54 | ``` 55 | 56 | [See `react-redux` docs](https://react-redux.js.org/api/hooks#useselector) 57 | 58 | ## `useDispatch` 59 | 60 | Call thunks and endpoints with this hook. 61 | 62 | ```tsx 63 | import { useDispatch } from "starfx"; 64 | 65 | function App() { 66 | const dispatch = useDispatch(); 67 | 68 | return ( 69 | 72 | ); 73 | } 74 | ``` 75 | 76 | [See `react-redux` docs](https://react-redux.js.org/api/hooks#usedispatch) 77 | 78 | ## `useLoader` 79 | 80 | Will accept an action creator or action and return the loader associated with 81 | it. 82 | 83 | ```tsx 84 | import { useLoader } from "starfx/react"; 85 | 86 | const log = thunks.create("log"); 87 | 88 | function App() { 89 | // this will grab loader for any `log` thunks dispatched 90 | // `action.payload.name` 91 | const loaderAny = useLoader(log); 92 | // this will grab loader a specific `log` thunk dispatched 93 | // `action.payload.key` 94 | const loader = useLoader(log("specific thunk")); 95 | } 96 | ``` 97 | 98 | ## `useApi` 99 | 100 | Will take an action creator or action itself and fetch the associated loader and 101 | create a `trigger` function that you can call later in your react component. 102 | 103 | This hook will _not_ fetch the data for you because it does not know how to 104 | fetch data from your redux state. 105 | 106 | ```ts 107 | import { useApi } from 'starfx/react'; 108 | 109 | import { api } from './api'; 110 | 111 | const fetchUsers = api.get('/users', function*() { 112 | // ... 113 | }); 114 | 115 | const View = () => { 116 | const { isLoading, trigger } = useApi(fetchUsers); 117 | useEffect(() => { 118 | trigger(); 119 | }, []); 120 | return
{isLoading ? : 'Loading' : 'Done!'}
121 | } 122 | ``` 123 | 124 | ## `useQuery` 125 | 126 | Uses [useApi](#useapi) and automatically calls `useApi().trigger()` 127 | 128 | ```ts 129 | import { useQuery } from 'starfx/react'; 130 | 131 | import { api } from './api'; 132 | 133 | const fetchUsers = api.get('/users', function*() { 134 | // ... 135 | }); 136 | 137 | const View = () => { 138 | const { isLoading } = useQuery(fetchUsers); 139 | return
{isLoading ? : 'Loading' : 'Done!'}
140 | } 141 | ``` 142 | 143 | ## `useCache` 144 | 145 | Uses [useQuery](#usequery) and automatically selects the cached data associated 146 | with the action creator or action provided. 147 | 148 | ```ts 149 | import { useCache } from 'starfx/react'; 150 | 151 | import { api } from './api'; 152 | 153 | const fetchUsers = api.get('/users', api.cache()); 154 | 155 | const View = () => { 156 | const { isLoading, data } = useCache(fetchUsers()); 157 | return
{isLoading ? : 'Loading' : data.length}
158 | } 159 | ``` 160 | 161 | ## `useLoaderSuccess` 162 | 163 | Will activate the callback provided when the loader transitions from some state 164 | to success. 165 | 166 | ```ts 167 | import { useApi, useLoaderSuccess } from "starfx/react"; 168 | 169 | import { api } from "./api"; 170 | 171 | const createUser = api.post("/users", function* (ctx, next) { 172 | // ... 173 | }); 174 | 175 | const View = () => { 176 | const { loader, trigger } = useApi(createUser); 177 | const onSubmit = () => { 178 | trigger({ name: "bob" }); 179 | }; 180 | 181 | useLoaderSuccess(loader, () => { 182 | // success! 183 | // Use this callback to navigate to another view 184 | }); 185 | 186 | return ; 187 | }; 188 | ``` 189 | -------------------------------------------------------------------------------- /docs/posts/resources.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Resources 3 | description: Some useful links to learn more 4 | --- 5 | 6 | # Quick links 7 | 8 | - [API Docs](https://deno.land/x/starfx@v0.13.2/mod.ts) 9 | - [blog posts about starfx](https://bower.sh/?tag=starfx) 10 | - [examples repo](https://github.com/neurosnap/starfx-examples) 11 | - [production example repo](https://github.com/aptible/app-ui) 12 | 13 | # Talks 14 | 15 | I recently gave a talk about delimited continuations where I also discuss this 16 | library: 17 | 18 | 19 | 20 | > [Delimited continuations are all you need](https://youtu.be/uRbqLGj_6mI?si=Mok0J8Wp0Z-ahFrN) 21 | 22 | Here is another talk I helped facilitate about `effection` with the library 23 | creator: 24 | 25 | 26 | 27 | > [effection with Charles Lowell](https://youtu.be/lJDgpxRw5WA?si=cCHZiKqNO7vIUhPc) 28 | 29 | Here is a deep-dive on how we are using `starfx` in a production App at Aptible: 30 | 31 | 32 | 33 | > [app-ui deep dive](https://youtu.be/3M5VJuIi5fk) 34 | 35 | # Other notable libraries 36 | 37 | This library is not possible without these foundational libraries: 38 | 39 | - [continuation](https://github.com/thefrontside/continuation) 40 | - [effection v3](https://github.com/thefrontside/effection/tree/v3) 41 | -------------------------------------------------------------------------------- /docs/posts/selectors.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Selectors 3 | description: Deriving data with selectors 4 | --- 5 | 6 | In a typical web app, the logic for deriving data is usually written as 7 | functions we call selectors. 8 | 9 | The basic function signature of a selector: 10 | 11 | ```ts 12 | const selectData = (state: WebState) => state.data; 13 | ``` 14 | 15 | Selectors are primarily used to encapsulate logic for looking up specific values 16 | from state, logic for actually deriving values, and improving performance by 17 | avoiding unnecessary recalculations. 18 | 19 | To learn more, redux has excellent docs 20 | [on deriving data with selectors](https://redux.js.org/usage/deriving-data-selectors). 21 | 22 | There is 100% knowledge transfer between selectors in `starfx` and `redux` 23 | because we adhere to the same function signature. 24 | 25 | The only difference is that as part of our API we re-export 26 | [reselect.createSelector](https://reselect.js.org/api/createselector/), which 27 | will memoize functions: 28 | 29 | ```ts 30 | import { createSelector } from "starfx"; 31 | 32 | const selectData = (state) => state.data; 33 | const myselector = createSelector( 34 | selectData, 35 | (data) => data.sort((a, b) => a.id - b.id); 36 | ); 37 | ``` 38 | 39 | Function memoization is just a way to cache a function call. If the dependencies 40 | (e.g. the result of `selectData`) don't change, then `myselector` will not be 41 | called: it will return its previous value. 42 | -------------------------------------------------------------------------------- /docs/posts/sitemap.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: sitemap 3 | description: starfx doc sitemap 4 | slug: sitemap 5 | template: sitemap.page.tmpl 6 | --- 7 | -------------------------------------------------------------------------------- /docs/posts/store.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Store 3 | description: An immutable store that acts like a reactive, in-memory database 4 | --- 5 | 6 | Features: 7 | 8 | - A single, global javascript object 9 | - Reactive 10 | - Normalized 11 | - Acts like a database 12 | 13 | We love `redux`. We know it gets sniped for having too much boilerplate when 14 | alternatives like `zustand` and `react-query` exist that cut through the 15 | ceremony of managing state. However, `redux` was never designed to be easy to 16 | use; it was designed to be scalable, debuggable, and maintainable. Yes, setting 17 | up a `redux` store is work, but that is in an effort to serve its 18 | maintainability. 19 | 20 | Having said that, the core abstraction in `redux` is a reducer. Reducers were 21 | originally designed to contain isolated business logic for updating sections of 22 | state (also known as state slices). They were also designed to make it easier to 23 | sustain state immutability. 24 | 25 | Fast forward to `redux-toolkit` and we have `createSlice` which leverages 26 | `immer` under-the-hood to ensure immutability. So we no longer need reducers for 27 | immutability. 28 | 29 | Further, we argue, placing the business logic for updating state inside reducers 30 | (via switch-cases) makes understanding business logic harder. Instead of having 31 | a single function that updates X state slices, we have X functions (reducers) 32 | that we need to piece together in our heads to understand what is being updated 33 | when an action is dispatched. 34 | 35 | With all of this in mind, `starfx` takes all the good parts of `redux` and 36 | removes the need for reducers entirely. We still have a single state object that 37 | contains everything from API data, UX, and a way to create memoized functions 38 | (e.g. [selectors](/selectors)). We maintain immutability (using 39 | [immer](https://github.com/immerjs/immer)) and also have a middleware system to 40 | extend it. 41 | 42 | Finally, we bring the utility of creating a schema (like [zod](https://zod.dev) 43 | or a traditional database) to make it plainly obvious what the state shape looks 44 | like as well as reusable utilities to make it easy to update and query state. 45 | 46 | This gets us closer to treating our store like a traditional database while 47 | still being flexible for our needs on the FE. 48 | 49 | ```ts 50 | import { createSchema, createStore, select, slice } from "starfx"; 51 | 52 | interface User { 53 | id: string; 54 | name: string; 55 | } 56 | 57 | // app-wide database for ui, api data, or anything that needs reactivity 58 | const [schema, initialState] = createSchema({ 59 | cache: slice.table(), 60 | loaders: slice.loaders(), 61 | users: slice.table(), 62 | }); 63 | type WebState = typeof initialState; 64 | 65 | // just a normal endpoint 66 | const fetchUsers = api.get( 67 | "/users", 68 | function* (ctx, next) { 69 | // make the http request 70 | yield* next(); 71 | 72 | // ctx.json is a Result type that either contains the http response 73 | // json data or an error 74 | if (!ctx.json.ok) { 75 | return; 76 | } 77 | 78 | const { value } = ctx.json; 79 | const users = value.reduce>((acc, user) => { 80 | acc[user.id] = user; 81 | return acc; 82 | }, {}); 83 | 84 | // update the store and trigger a re-render in react 85 | yield* schema.update(schema.users.add(users)); 86 | 87 | // User[] 88 | const users = yield* select(schema.users.selectTableAsList); 89 | // User 90 | const user = yield* select( 91 | (state) => schema.users.selectById(state, { id: "1" }), 92 | ); 93 | }, 94 | ); 95 | 96 | const store = createStore(schema); 97 | store.run(api.register); 98 | store.dispatch(fetchUsers()); 99 | ``` 100 | 101 | # How to update state 102 | 103 | There are **three** ways to update state, each with varying degrees of type 104 | safety: 105 | 106 | ```ts 107 | import { updateStore } from "starfx"; 108 | 109 | function*() { 110 | // good types 111 | yield* schema.update([/* ... */]); 112 | // no types 113 | yield* updateStore([/* ... */]); 114 | } 115 | 116 | store.run(function*() { 117 | // no types 118 | yield* store.update([/* ... */]); 119 | }); 120 | ``` 121 | 122 | `schema.update` has the highest type safety because it knows your state shape. 123 | The other methods are more generic and the user will have to provide types to 124 | them manually. 125 | 126 | # Updater function 127 | 128 | `schema.update` expects one or many state updater functions. An updater function 129 | receives the state as a function parameter. Any mutations to the `state` 130 | parameter will be applied to the app's state using 131 | [immer](https://github.com/immerjs/immer). 132 | 133 | ```ts 134 | type StoreUpdater = (s: S) => S | void; 135 | ``` 136 | 137 | > It is highly recommended you read immer's doc on 138 | > [update patterns](https://immerjs.github.io/immer/update-patterns) because 139 | > there are limitations to understand. 140 | 141 | Here's a simple updater function that increments a counter: 142 | 143 | ```ts 144 | function* inc() { 145 | yield* schema.update((state) => { 146 | state.counter += 1; 147 | }); 148 | } 149 | ``` 150 | 151 | Since the `update` function accepts an array, it's important to know that we 152 | just run those functions by iterating through that array. 153 | 154 | In fact, our store's core state management can _essentially_ be reduced to this: 155 | 156 | ```ts 157 | import { produce } from "immer"; 158 | 159 | function createStore(initialState = {}) { 160 | let state = initialState; 161 | 162 | function update(updaters) { 163 | const nextState = produce(state, (draft) => { 164 | updaters.forEach((updater) => updater(draft)); 165 | }); 166 | state = nextState; 167 | } 168 | 169 | return { 170 | getState: () => state, 171 | update, 172 | }; 173 | } 174 | ``` 175 | 176 | # Updating state from view 177 | 178 | You cannot directly update state from the view, users can only manipulate state 179 | from a thunk, endpoint, or a delimited continuation. 180 | 181 | This is a design decision that forces everything to route through our 182 | [controllers](/controllers). 183 | 184 | However, it is very easy to create a controller to do simple tasks like updating 185 | state: 186 | 187 | ```ts 188 | import type { StoreUpdater } from "starfx"; 189 | 190 | const updater = thunks.create("update", function* (ctx, next) { 191 | yield* updateStore(ctx.payload); 192 | yield* next(); 193 | }); 194 | 195 | store.dispatch( 196 | updater([ 197 | schema.users.add({ [user1.id]: user }), 198 | ]), 199 | ); 200 | ``` 201 | -------------------------------------------------------------------------------- /docs/posts/structured-concurrency.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Structured Concurrency 3 | description: What is structured concurrency? 4 | --- 5 | 6 | Resources: 7 | 8 | - [wiki](https://en.wikipedia.org/wiki/Structured_concurrency) 9 | - [await event horizon](https://frontside.com/blog/2023-12-11-await-event-horizon/) 10 | - [Why structured concurrency?](https://bower.sh/why-structured-concurrency) 11 | - [Thinking in Effection](https://frontside.com/effection/docs/thinking-in-effection) 12 | - [Delimited continuation](https://en.wikipedia.org/wiki/Delimited_continuation) 13 | - [Structured Concurrency](https://ericniebler.com/2020/11/08/structured-concurrency/) 14 | - [Structured Concurrency explained](https://www.thedevtavern.com/blog/posts/structured-concurrency-explained/) 15 | - [conc](https://github.com/sourcegraph/conc) 16 | 17 | This is a broad term so I'll make this specific to how `starfx` works. 18 | 19 | Under-the-hood, thunks and endpoints are registered under the root task. Every 20 | thunk and endpoint has their own supervisor that manages them. As a result, what 21 | we have is a single root task for your entire app that is being managed by 22 | supervisor tasks. When the root task receives a signal to shutdown itself (e.g. 23 | `task.halt()` or closing browser tab) it first must shutdown all children tasks 24 | before being resolved. 25 | 26 | When a child task throws an exception (whether intentional or otherwise) it will 27 | propagate that error up the task tree until it is caught or reaches the root 28 | task. 29 | 30 | In review: 31 | 32 | - There is a single root task for an app 33 | - The root task can spawn child tasks 34 | - If root task is halted then all child tasks are halted first 35 | - If a child task is halted or raises exception, it propagates error up the task 36 | tree 37 | - An exception can be caught (e.g. `try`/`catch`) at any point in the task tree 38 | -------------------------------------------------------------------------------- /docs/posts/supervisors.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Supervisors 3 | description: Learn how supervisor tasks work 4 | --- 5 | 6 | A supervisor task is a way to monitor children tasks and manage their health. By 7 | structuring your side-effects and business logic around supervisor tasks, we 8 | gain interesting coding paradigms that result in easier to read and manage code. 9 | 10 | [Supplemental reading from erlang](https://www.erlang.org/doc/design_principles/des_princ) 11 | 12 | The most basic version of a supervisor is simply an infinite loop that calls a 13 | child task: 14 | 15 | ```ts 16 | import { call } from "starfx"; 17 | 18 | function* supervisor() { 19 | while (true) { 20 | try { 21 | yield* call(someTask); 22 | } catch (err) { 23 | console.error(err); 24 | } 25 | } 26 | } 27 | 28 | function* someTask() { 29 | yield* sleep(10 * 1000); 30 | throw new Error("boom!"); 31 | } 32 | ``` 33 | 34 | Here we `call` some task that should always be in a running and healthy state. 35 | If it raises an exception, we log it and try to run the task again. 36 | 37 | Building on top of that simple supervisor, we can have tasks that always listen 38 | for events and if they fail, restart them. 39 | 40 | ```ts 41 | import { parallel, run, take } from "starfx"; 42 | 43 | function* watchFetch() { 44 | while (true) { 45 | const action = yield* take("FETCH_USERS"); 46 | console.log(action); 47 | } 48 | } 49 | 50 | function* send() { 51 | yield* put({ type: "FETCH_USERS" }); 52 | yield* put({ type: "FETCH_USERS" }); 53 | yield* put({ type: "FETCH_USERS" }); 54 | } 55 | 56 | await run( 57 | parallel([watchFetch, send]), 58 | ); 59 | ``` 60 | 61 | Here we create a supervisor function using a helper `take` to call a function 62 | for every `FETCH_USERS` event emitted. 63 | 64 | While inside a `while` loop, you get full access to its powerful flow control. 65 | Another example, let's say we we only want to respond to a login action when the 66 | user isn't logged in and conversely only listen to a logout action when the user 67 | is logged in: 68 | 69 | ```ts 70 | function*() { 71 | while (true) { 72 | const login = yield* take("LOGIN"); 73 | // e.g. fetch token with creds inside `login.payload` 74 | const logout = yield* take("LOGOUT"); 75 | // e.g. destroy token from `logout.payload` 76 | } 77 | } 78 | ``` 79 | 80 | Interesting, we've essentially created a finite state machine within a 81 | while-loop! 82 | 83 | We also built a helper that will abstract the while loop if you don't need it: 84 | 85 | ```ts 86 | import { takeEvery } from "starfx"; 87 | 88 | function* watchFetch() { 89 | yield* takeEvery("FETCH_USERS", function* (action) { 90 | console.log(action); 91 | }); 92 | } 93 | ``` 94 | 95 | However, this means that we are going to make the same request 3 times, we 96 | probably want a throttle or debounce so we only make a fetch request once within 97 | some interval. 98 | 99 | ```ts 100 | import { takeLeading } from "starfx"; 101 | 102 | function* watchFetch() { 103 | yield* takeLeading("FETCH_USERS", function* (action) { 104 | console.log(action); 105 | }); 106 | } 107 | ``` 108 | 109 | That's better, now only one task can be alive at one time. 110 | 111 | Both thunks and endpoints simply listen for 112 | [actions](/thunks#anatomy-of-an-action) being emitted onto a channel -- which is 113 | just an event emitter -- and then call the middleware stack with that action. 114 | 115 | Both thunks and endpoints support overriding the default `takeEvery` supervisor 116 | for either our officially supported supervisors `takeLatest` and `takeLeading`, 117 | or a user-defined supervisor. 118 | 119 | Because every thunk and endpoint have their own supervisor tasks monitoring the 120 | health of their children, we allow the end-developer to change the default 121 | supervisor -- which is `takeEvery`: 122 | 123 | ```ts 124 | const someAction = thunks.create("some-action", { supervisor: takeLatest }); 125 | dispatch(someAction()); // this task gets cancelled 126 | dispatch(someAction()); // this task gets cancelled 127 | dispatch(someAction()); // this tasks lives 128 | ``` 129 | 130 | This is the power of supervisors and is fundamental to how `starfx` works. 131 | 132 | # poll 133 | 134 | When activated, call a thunk or endpoint once every N millisecond indefinitely 135 | until cancelled. 136 | 137 | ```ts 138 | import { poll } from "starfx"; 139 | 140 | const fetchUsers = api.get("/users", { supervisor: poll() }); 141 | store.dispatch(fetchUsers()); 142 | // fetch users 143 | // sleep 5000 144 | // fetch users 145 | // sleep 5000 146 | // fetch users 147 | store.dispatch(fetchUsers()); 148 | // cancelled 149 | ``` 150 | 151 | The default value provided to `poll()` is **5 seconds**. 152 | 153 | You can optionally provide a cancel action instead of calling the thunk twice: 154 | 155 | ```ts 156 | import { poll } from "starfx"; 157 | 158 | const cancelPoll = createAction("cancel-poll"); 159 | const fetchUsers = api.get("/users", { 160 | supervisor: poll(5 * 1000, `${cancelPoll}`), 161 | }); 162 | store.dispatch(fetchUsers()); 163 | // fetch users 164 | // sleep 5000 165 | // fetch users 166 | // sleep 5000 167 | // fetch users 168 | store.dispatch(cancelPoll()); 169 | // cancelled 170 | ``` 171 | 172 | # timer 173 | 174 | Only call a thunk or endpoint at-most once every N milliseconds. 175 | 176 | ```ts 177 | import { timer } from "starfx"; 178 | 179 | const fetchUsers = api.get("/users", { supervisor: timer(1000) }); 180 | store.dispatch(fetchUsers()); 181 | store.dispatch(fetchUsers()); 182 | // sleep(100); 183 | store.dispatch(fetchUsers()); 184 | // sleep(1000); 185 | store.dispatch(fetchUsers()); 186 | // called: 2 times 187 | ``` 188 | 189 | The default value provided to `timer()` is **5 minutes**. This means you can 190 | only call `fetchUsers` at-most once every **5 minutes**. 191 | 192 | ## clearTimers 193 | 194 | Want to clear a timer and refetch? 195 | 196 | ```ts 197 | import { clearTimers, timer } from "starfx"; 198 | 199 | const fetchUsers = api.get("/users", { supervisor: timer(1000) }); 200 | store.dispatch(fetchUsers()); 201 | store.dispatch(clearTimers(fetchUsers())); 202 | store.dispatch(fetchUsers()); 203 | // called: 2 times 204 | store.dispatch(clearTimers("*")); // clear all timers 205 | ``` 206 | -------------------------------------------------------------------------------- /docs/posts/testing.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Testing 3 | description: You don't need an HTTP interceptor 4 | --- 5 | 6 | Need to write tests? Use libraries like `msw` or `nock`? Well you don't need 7 | them with `starfx`. If the `mdw.fetch()` middleware detects `ctx.response` is 8 | already filled then it skips making the request. Let's take the update user 9 | endpoint example and provide stubbed data for our tests. 10 | 11 | ```tsx 12 | import { fireEvent, render, screen } from "@testing-library/react"; 13 | import { useDispatch, useSelector } from "starfx/react"; 14 | import { db } from "./schema.ts"; 15 | import { updateUser } from "./user.ts"; 16 | 17 | function UserSettingsPage() { 18 | const id = "1"; 19 | const dispatch = useDispatch(); 20 | const user = useSelector((state) => db.users.selectById(state, { id })); 21 | 22 | return ( 23 |
24 |
Name: {user.name}
25 | 28 |
29 | ); 30 | } 31 | 32 | describe("UserSettingsPage", () => { 33 | it("should update the user", async () => { 34 | // just for this test -- inject a new middleware into the endpoint stack 35 | updateUser.use(function* (ctx, next) { 36 | ctx.response = new Response( 37 | JSON.stringify({ id: ctx.payload.id, name: ctx.payload.name }), 38 | ); 39 | yield* next(); 40 | }); 41 | 42 | render(); 43 | 44 | const btn = await screen.findByRole("button", { name: /Update User/ }); 45 | fireEvent.click(btn); 46 | 47 | await screen.findByText(/Name: bobby/); 48 | }); 49 | }); 50 | ``` 51 | 52 | That's it. No need for http interceptors and the core functionality works 53 | exactly the same, we just skip making the fetch request for our tests. 54 | 55 | What if we don't have an API endpoint yet and want to stub the data? We use the 56 | same concept but inline inside the `updateUser` endpoint: 57 | 58 | ```ts 59 | export const updateUser = api.post<{ id: string; name: string }>( 60 | "/users/:id", 61 | [ 62 | function* (ctx, next) { 63 | ctx.request = ctx.req({ 64 | body: JSON.stringify({ name: ctx.payload.name }), 65 | }); 66 | yield* next(); 67 | }, 68 | function* (ctx, next) { 69 | ctx.response = new Response( 70 | JSON.stringify({ id: ctx.payload.id, name: ctx.payload.name }), 71 | ); 72 | yield* next(); 73 | }, 74 | ], 75 | ); 76 | ``` 77 | 78 | Wow! Our stubbed data is now colocated next to our actual endpoint we are trying 79 | to mock! Once we have a real API we want to hit, we can just remove that second 80 | middleware function and everything will work exactly the same. 81 | -------------------------------------------------------------------------------- /docs/posts/thunks.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Thunks 3 | description: Thunks are tasks for business logic 4 | --- 5 | 6 | Thunks are the foundational central processing units. They have access to all 7 | the actions being dispatched from the view as well as your global state. They 8 | also wield the full power of structured concurrency. 9 | 10 | > Endpoints are specialized thunks as you will see later in the docs 11 | 12 | Think of thunks as micro-controllers. Only thunks and endpoints have the ability 13 | to update state (or a model in MVC terms). However, thunks are not tied to any 14 | particular view and in that way are more composable. Thunks can call other 15 | thunks and you have the async flow control tools from `effection` to facilitate 16 | coordination and cleanup. 17 | 18 | Every thunk that's created requires a unique id -- user provided string. This 19 | provides us with some benefits: 20 | 21 | - User hand-labels each thunk 22 | - Better traceability 23 | - Easier to debug async and side-effects 24 | - Build abstractions off naming conventions (e.g. creating routers 25 | `/users [GET]`) 26 | 27 | They also come with built-in support for a middleware stack (like `express` or 28 | `koa`). This provides a familiar and powerful abstraction for async flow control 29 | for all thunks and endpoints. 30 | 31 | Each run of a thunk gets its own `ctx` object which provides a substrate to 32 | communicate between middleware. 33 | 34 | ```ts 35 | import { call, createThunks, mdw } from "starfx"; 36 | 37 | const thunks = createThunks(); 38 | // catch errors from task and logs them with extra info 39 | thunks.use(mdw.err); 40 | // where all the thunks get called in the middleware stack 41 | thunks.use(thunks.routes()); 42 | thunks.use(function* (ctx, next) { 43 | console.log("last mdw in the stack"); 44 | yield* next(); 45 | }); 46 | 47 | // create a thunk 48 | const log = thunks.create("log", function* (ctx, next) { 49 | const resp = yield* call( 50 | fetch("https://log-drain.com", { 51 | method: "POST", 52 | body: JSON.stringify({ message: ctx.payload }), 53 | }), 54 | ); 55 | console.log("before calling next middleware"); 56 | yield* next(); 57 | console.log("after all remaining middleware have run"); 58 | }); 59 | 60 | store.dispatch(log("sending log message")); 61 | // output: 62 | // before calling next middleware 63 | // last mdw in the stack 64 | // after all remaining middleware have run 65 | ``` 66 | 67 | # Anatomy of thunk middleware 68 | 69 | Thunks are a composition of middleware functions in a stack. Therefore, every 70 | single middleware function shares the exact same type signature: 71 | 72 | ```ts 73 | // for demonstration purposes we are copy/pasting these types which can 74 | // normally be imported from: 75 | // import type { ThunkCtx, Next } from "starfx"; 76 | type Next = () => Operation; 77 | 78 | interface ThunkCtx

extends Payload

{ 79 | name: string; 80 | key: string; 81 | action: ActionWithPayload>; 82 | actionFn: IfAny< 83 | P, 84 | CreateAction, 85 | CreateActionWithPayload, P> 86 | >; 87 | result: Result; 88 | } 89 | 90 | function* myMiddleware(ctx: ThunkCtx, next: Next) { 91 | yield* next(); 92 | } 93 | ``` 94 | 95 | Similar to `express` or `koa`, if you do **not** call `next()` then the 96 | middleware stack will stop after the code execution leaves the scope of the 97 | current middleware. This provides the end-user with "exit early" functionality 98 | for even more control. 99 | 100 | # Anatomy of an Action 101 | 102 | When creating a thunk, the return value is just an action creator: 103 | 104 | ```ts 105 | console.log(log("sending log message")); 106 | { 107 | type: "log", 108 | payload: "sending log message" 109 | } 110 | ``` 111 | 112 | An action is the "event" being emitted from `startfx` and subscribes to a very 113 | particular type signature. 114 | 115 | A thunk action adheres to the 116 | [flux standard action spec](https://github.com/redux-utilities/flux-standard-action). 117 | 118 | > While not strictly necessary, it is highly recommended to keep actions JSON 119 | > serializable 120 | 121 | For thunks we have a more strict payload type signature with additional 122 | properties: 123 | 124 | ```ts 125 | interface CreateActionPayload

{ 126 | name: string; // the user-defined name 127 | options: P; // thunk payload described below 128 | key: string; // hash of entire thunk payload 129 | } 130 | 131 | interface ThunkAction

{ 132 | type: string; 133 | payload: CreateActionPayload

; 134 | } 135 | ``` 136 | 137 | This is the type signature for every action created automatically by 138 | `createThunks` or `createApi`. 139 | 140 | # Thunk payload 141 | 142 | When calling a thunk, the user can provide a payload that is strictly enforced 143 | and accessible via the `ctx.payload` property: 144 | 145 | ```ts 146 | const makeItSo = api.get<{ id: string }>("make-it-so", function* (ctx, next) { 147 | console.log(ctx.payload); 148 | yield* next(); 149 | }); 150 | 151 | makeItSo(); // type error! 152 | makeItSo("123"); // type error! 153 | makeItSo({ id: "123" }); // nice! 154 | ``` 155 | 156 | If you do not provide a type for an endpoint, then the action can be dispatched 157 | without a payload: 158 | 159 | ```ts 160 | const makeItSo = api.get("make-it-so", function* (ctx, next) { 161 | console.log(ctx.payload); 162 | yield* next(); 163 | }); 164 | 165 | makeItSo(); // nice! 166 | ``` 167 | 168 | # Custom `ctx` 169 | 170 | End-users are able to provide a custom `ctx` object to their thunks. It must 171 | extend `ThunkCtx` in order for it to pass, but otherwise you are free to add 172 | whatever properties you want: 173 | 174 | ```ts 175 | import { createThunks, type ThunkCtx } from "starfx"; 176 | 177 | interface MyCtx extends ThunkCtx { 178 | wow: bool; 179 | } 180 | 181 | const thunks = createThunks(); 182 | 183 | // we recommend a mdw that ensures the property exists since we cannot 184 | // make that guarentee 185 | thunks.use(function* (ctx, next) { 186 | if (!Object.hasOwn(ctx, "wow")) { 187 | ctx.wow = false; 188 | } 189 | yield* next(); 190 | }); 191 | 192 | const log = thunks.create("log", function* (ctx, next) { 193 | ctx.wow = true; 194 | yield* next(); 195 | }); 196 | ``` 197 | -------------------------------------------------------------------------------- /docs/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neurosnap/starfx/f18f8d64f7b18da36000fecf0707daa93bc6d77e/docs/static/logo.png -------------------------------------------------------------------------------- /docs/static/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/static/main.css: -------------------------------------------------------------------------------- 1 | @media (prefers-color-scheme: light) { 2 | .logo { 3 | stroke: #414558; 4 | fill: none; 5 | } 6 | } 7 | @media (prefers-color-scheme: dark) { 8 | .logo { 9 | stroke: #f2f2f2; 10 | fill: none; 11 | } 12 | } 13 | 14 | .logo-sm svg { 15 | width: 10px; 16 | height: 10px; 17 | } 18 | 19 | .logo-md svg { 20 | width: 25px; 21 | height: 25px; 22 | } 23 | 24 | .logo-lg svg { 25 | width: 50px; 26 | height: 50px; 27 | } 28 | 29 | .divider { 30 | width: 1px; 31 | height: auto; 32 | background-color: var(--grey-light); 33 | } 34 | 35 | .visited { 36 | color: var(--visited); 37 | } 38 | 39 | .sitemap { 40 | width: 250px; 41 | flex: 0 0 250px; 42 | margin: 0; 43 | overflow-y: scroll; 44 | padding-bottom: 2rem !important; 45 | height: calc(100vh - 3px - 3 * var(--line-height)); 46 | } 47 | 48 | .post { 49 | max-width: 650px; 50 | height: calc(100vh - 3px - 3 * var(--line-height)); 51 | overflow-y: auto; 52 | padding-right: 20px; 53 | } 54 | 55 | .post-container { 56 | max-width: 60rem; 57 | } 58 | 59 | .container-xs { 60 | max-width: 20em; 61 | width: 100%; 62 | } 63 | 64 | .hero { 65 | padding: calc(var(--line-height) * 3) 0 0 0; 66 | } 67 | 68 | .home-features pre { 69 | margin: 0; 70 | } 71 | 72 | .features { 73 | display: grid; 74 | grid-template-columns: repeat(auto-fit, minmax(225px, 1fr)); 75 | gap: calc(var(--line-height) - 4px); 76 | } 77 | 78 | .features h3 { 79 | border: none; 80 | } 81 | 82 | .mk-nav { 83 | padding: var(--line-height); 84 | } 85 | 86 | .pager { 87 | min-width: 150px; 88 | } 89 | 90 | .sitemap-grid { 91 | display: grid; 92 | grid-template-columns: repeat(2, 1fr); 93 | gap: 1rem; 94 | } 95 | 96 | .sidebar-list { 97 | padding: 0 0 0 var(--line-height); 98 | } 99 | 100 | .post-group { 101 | display: flex; 102 | gap: 1rem; 103 | flex-direction: row-reverse; 104 | } 105 | 106 | @media only screen and (max-width: 800px) { 107 | body { 108 | padding: 0 0.75rem; 109 | } 110 | 111 | header { 112 | margin: 0; 113 | } 114 | 115 | .post-group { 116 | display: block; 117 | } 118 | 119 | .post { 120 | height: 100%; 121 | overflow-y: unset; 122 | padding-right: 0; 123 | } 124 | 125 | .sitemap { 126 | column-count: 2; 127 | width: 100%; 128 | margin-bottom: calc(2 * var(--line-height)); 129 | } 130 | 131 | .sitemap-grid { 132 | grid-template-columns: repeat(1, 1fr); 133 | } 134 | 135 | .mk-nav { 136 | padding: var(--line-height) 0; 137 | } 138 | 139 | .divider { 140 | display: none; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /docs/tmpl/base.layout.tmpl: -------------------------------------------------------------------------------- 1 | {{define "base"}} 2 | 3 | 4 | 5 | {{template "title" .}} · starfx 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {{template "meta" .}} 15 | 16 | 17 | 18 | {{template "body" .}} 19 | 20 | 21 | {{end}} 22 | -------------------------------------------------------------------------------- /docs/tmpl/footer.partial.tmpl: -------------------------------------------------------------------------------- 1 | {{define "footer"}} 2 |

7 | {{end}} 8 | -------------------------------------------------------------------------------- /docs/tmpl/home.page.tmpl: -------------------------------------------------------------------------------- 1 | {{template "base" .}} 2 | 3 | {{define "title"}}{{.Data.Title}}{{end}} 4 | 5 | {{define "meta"}} 6 | 7 | {{end}} 8 | 9 | {{define "attrs"}}class="container"{{end}} 10 | 11 | {{define "body"}} 12 | {{template "nav" .}} 13 | 14 |
15 |
16 |
17 |
18 |
19 | starfx logo 20 |

starfx

21 |
22 |
A micro-mvc framework for react apps.
23 | GET STARTED 24 |
25 |
26 | 27 |
28 | 29 |
30 |
31 |

32 | Data synchronization and caching 33 |

34 |
A powerful middleware system to fetch API data
35 |
36 | 37 |
38 |

39 | An immutable and reactive data store 40 |

41 |
A normalized database for UI and API data
42 |
43 | 44 |
45 |

46 | Task tree side-effect system 47 |

48 |
A robust system for handling complex business logic using structured concurrency
49 |
50 | 51 |
52 |

53 | Tools to preload and refresh data 54 |

55 |
An awesome data loading strategy for web apps
56 |
57 | 58 |
59 |

60 | React integration 61 |

62 |
Built for react
63 |
64 |
65 |
66 | 67 |
68 | 69 | {{template "footer" .}} 70 |
71 | {{end}} 72 | -------------------------------------------------------------------------------- /docs/tmpl/nav.partial.tmpl: -------------------------------------------------------------------------------- 1 | {{define "nav"}} 2 | 18 | {{end}} 19 | -------------------------------------------------------------------------------- /docs/tmpl/pager.partial.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "pager" }} 2 |
3 |
4 | {{if .Prev}} 5 |
6 |
<< PREV
7 | {{.Prev.Text}} 8 |
9 | {{end}} 10 |
11 | 12 |
13 | {{if .Next}} 14 |
15 |
16 | NEXT >> 17 |
18 | {{.Next.Text}} 19 |
20 | {{end}} 21 |
22 |
23 | {{end}} 24 | -------------------------------------------------------------------------------- /docs/tmpl/post.page.tmpl: -------------------------------------------------------------------------------- 1 | {{template "base" .}} 2 | 3 | {{define "title"}}{{.Data.Title}}{{end}} 4 | 5 | {{define "meta"}} 6 | 7 | {{end}} 8 | 9 | {{define "attrs"}}class="post-container"{{end}} 10 | 11 | {{define "body"}} 12 | {{template "nav" .}} 13 | 14 |
15 |
16 |

17 | {{.Data.Title}} 18 |

19 |

{{.Data.Description}}

20 | 21 |
22 | 23 |
24 | {{.Data.Html}} 25 |
26 | 27 | {{template "pager" .}} 28 | {{template "footer" .}} 29 |
30 | 31 | {{template "sitemap-footer" .}} 32 |
33 | 34 | {{end}} 35 | -------------------------------------------------------------------------------- /docs/tmpl/sitemap-footer.partial.tmpl: -------------------------------------------------------------------------------- 1 | {{define "sitemap-footer"}} 2 | 32 | {{end}} 33 | -------------------------------------------------------------------------------- /docs/tmpl/sitemap.page.tmpl: -------------------------------------------------------------------------------- 1 | {{template "base" .}} 2 | 3 | {{define "title"}}{{.Data.Title}}{{end}} 4 | 5 | {{define "meta"}} 6 | 7 | {{end}} 8 | 9 | {{define "attrs"}}class="container"{{end}} 10 | 11 | {{define "body"}} 12 | {{template "nav" .}} 13 | 14 |
15 |

{{.Data.Title}}

16 |

{{.Data.Description}}

17 | 18 |
19 | 20 | {{template "toc" .}} 21 | 22 |
23 |
24 | 25 | {{template "footer" .}} 26 | {{end}} 27 | -------------------------------------------------------------------------------- /docs/tmpl/toc.partial.tmpl: -------------------------------------------------------------------------------- 1 | {{define "toc"}} 2 |
3 | {{range .Sitemap.Children -}} 4 | {{if .Children}} 5 |
6 | {{if .GenHref}} 7 |

8 | {{.Text}} 9 |

10 | {{else}} 11 |

12 | {{.Text}} 13 |

14 | {{end}} 15 | 16 |
    17 | {{range .Children -}} 18 |
  • 19 | {{.Text}} 20 | 21 |
      22 | {{range .Children}} 23 |
    • 24 | {{.Text}} 25 |
    • 26 | {{end}} 27 |
    28 |
  • 29 | {{- end}} 30 |
31 |
32 | {{end}} 33 | {{- end}} 34 |
35 | {{end}} 36 | -------------------------------------------------------------------------------- /fx/mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./parallel.ts"; 2 | export * from "./safe.ts"; 3 | export * from "./race.ts"; 4 | export * from "./request.ts"; 5 | export * from "./supervisor.ts"; 6 | -------------------------------------------------------------------------------- /fx/parallel.ts: -------------------------------------------------------------------------------- 1 | import type { Callable, Channel, Operation, Result } from "effection"; 2 | import { createChannel, resource, spawn } from "effection"; 3 | import type { Computation } from "../types.ts"; 4 | import { safe } from "./safe.ts"; 5 | 6 | export interface ParallelRet extends Computation[]> { 7 | sequence: Channel, void>; 8 | immediate: Channel, void>; 9 | } 10 | 11 | /** 12 | * The goal of `parallel` is to make it easier to cooridnate multiple async 13 | * operations in parallel, with different ways to receive completed tasks. 14 | * 15 | * All tasks are called with {@link fx.safe} which means they will never 16 | * throw an exception. Instead all tasks will return a Result object that 17 | * the end development must evaluate in order to grab the value. 18 | * 19 | * @example 20 | * ```ts 21 | * import { parallel } from "starfx"; 22 | * 23 | * function* run() { 24 | * const task = yield* parallel([job1, job2]); 25 | * // wait for all tasks to complete before moving to next yield point 26 | * const results = yield* task; 27 | * // job1 = results[0]; 28 | * // job2 = results[1]; 29 | * } 30 | * ``` 31 | * 32 | * Instead of waiting for all tasks to complete, we can instead loop over 33 | * tasks as they arrive: 34 | * 35 | * @example 36 | * ```ts 37 | * function* run() { 38 | * const task = yield* parallel([job1, job2]); 39 | * for (const job of yield* each(task.immediate)) { 40 | * // job2 completes first then it will be first in list 41 | * console.log(job); 42 | * yield* each.next(); 43 | * } 44 | * } 45 | * ``` 46 | * 47 | * Or we can instead loop over tasks in order of the array provided to 48 | * parallel: 49 | * 50 | * @example 51 | * ```ts 52 | * function* run() { 53 | * const task = yield* parallel([job1, job2]); 54 | * for (const job of yield* each(task.sequence)) { 55 | * // job1 then job2 will be returned regardless of when the jobs 56 | * // complete 57 | * console.log(job); 58 | * yield* each.next(); 59 | * } 60 | * } 61 | * ``` 62 | */ 63 | export function parallel(operations: Callable[]) { 64 | const sequence = createChannel>(); 65 | const immediate = createChannel>(); 66 | const results: Result[] = []; 67 | 68 | return resource>(function* (provide) { 69 | const task = yield* spawn(function* () { 70 | const tasks = []; 71 | for (const op of operations) { 72 | tasks.push( 73 | yield* spawn(function* () { 74 | const result = yield* safe(op); 75 | yield* immediate.send(result); 76 | return result; 77 | }), 78 | ); 79 | } 80 | 81 | for (const tsk of tasks) { 82 | const res = yield* tsk; 83 | results.push(res); 84 | yield* sequence.send(res); 85 | } 86 | 87 | yield* sequence.close(); 88 | yield* immediate.close(); 89 | }); 90 | 91 | function* wait(): Operation[]> { 92 | yield* task; 93 | return results; 94 | } 95 | 96 | yield* provide({ 97 | sequence, 98 | immediate, 99 | *[Symbol.iterator]() { 100 | return yield* wait(); 101 | }, 102 | }); 103 | }); 104 | } 105 | -------------------------------------------------------------------------------- /fx/race.ts: -------------------------------------------------------------------------------- 1 | import type { Callable, Operation, Task } from "effection"; 2 | import { action, call, resource, spawn } from "effection"; 3 | 4 | interface OpMap { 5 | [key: string]: Callable; 6 | } 7 | 8 | export function raceMap(opMap: OpMap): Operation< 9 | { 10 | [K in keyof OpMap]: OpMap[K] extends (...args: any[]) => any 11 | ? ReturnType 12 | : OpMap[K]; 13 | } 14 | > { 15 | return resource(function* Race(provide) { 16 | const keys = Object.keys(opMap); 17 | const taskMap: { [key: string]: Task } = {}; 18 | const resultMap: { [key: keyof OpMap]: OpMap[keyof OpMap] } = {}; 19 | 20 | const winner = yield* action>(function* (resolve) { 21 | for (let i = 0; i < keys.length; i += 1) { 22 | const key = keys[i]; 23 | yield* spawn(function* () { 24 | const task = yield* spawn(function* () { 25 | yield* call(opMap[key] as any); 26 | }); 27 | taskMap[key] = task; 28 | (resultMap as any)[key] = yield* task; 29 | resolve(task); 30 | }); 31 | } 32 | }); 33 | 34 | for (let i = 0; i < keys.length; i += 1) { 35 | const key = keys[i]; 36 | const task = taskMap[key]; 37 | if (task === winner) { 38 | continue; 39 | } 40 | 41 | yield* spawn(() => task.halt()); 42 | } 43 | 44 | yield* provide(resultMap); 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /fx/request.ts: -------------------------------------------------------------------------------- 1 | import { call, useAbortSignal } from "effection"; 2 | 3 | export function* request(url: string | URL | Request, opts?: RequestInit) { 4 | const signal = yield* useAbortSignal(); 5 | const response = yield* call(fetch(url, { signal, ...opts })); 6 | return response; 7 | } 8 | 9 | export function* json(response: Response) { 10 | const result = yield* call(response.json()); 11 | return result; 12 | } 13 | -------------------------------------------------------------------------------- /fx/safe.ts: -------------------------------------------------------------------------------- 1 | import type { Callable, Operation, Result } from "effection"; 2 | import { call, Err, Ok } from "effection"; 3 | 4 | /** 5 | * The goal of `safe` is to wrap Operations to prevent them from raising 6 | * and error. The result of `safe` is always a {@link Result} type. 7 | * 8 | * @example 9 | * ```ts 10 | * import { safe } from "starfx"; 11 | * 12 | * function* run() { 13 | * const results = yield* safe(fetch("api.com")); 14 | * if (result.ok) { 15 | * console.log(result.value); 16 | * } else { 17 | * console.error(result.error); 18 | * } 19 | * } 20 | * ``` 21 | */ 22 | function isError(error: unknown): error is Error { 23 | return error instanceof Error; 24 | } 25 | 26 | export function* safe(operator: Callable): Operation> { 27 | try { 28 | const value = yield* call(operator as any); 29 | return Ok(value); 30 | } catch (error) { 31 | return Err(isError(error) ? error : new Error(String(error))); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /fx/supervisor.ts: -------------------------------------------------------------------------------- 1 | import { type Callable, type Operation, sleep } from "effection"; 2 | import { safe } from "./safe.ts"; 3 | import { parallel } from "./parallel.ts"; 4 | import { API_ACTION_PREFIX, put } from "../action.ts"; 5 | import type { Result } from "effection"; // Adjust the import path as needed 6 | 7 | export function superviseBackoff(attempt: number, max = 10): number { 8 | if (attempt > max) return -1; 9 | // 20ms, 40ms, 80ms, 160ms, 320ms, 640ms, 1280ms, 2560ms, 5120ms, 10240ms 10 | return 2 ** attempt * 10; 11 | } 12 | 13 | /** 14 | * supvervise will watch whatever {@link Operation} is provided 15 | * and it will automatically try to restart it when it exists. By 16 | * default it uses a backoff pressure mechanism so if there is an 17 | * error simply calling the {@link Operation} then it will exponentially 18 | * wait longer until attempting to restart and eventually give up. 19 | */ 20 | export function supervise( 21 | op: Callable, 22 | backoff: (attemp: number) => number = superviseBackoff, 23 | ) { 24 | return function* () { 25 | let attempt = 1; 26 | let waitFor = backoff(attempt); 27 | 28 | while (waitFor >= 0) { 29 | const res = yield* safe(op); 30 | 31 | if (res.ok) { 32 | attempt = 0; 33 | } else { 34 | yield* put({ 35 | type: `${API_ACTION_PREFIX}supervise`, 36 | payload: res.error, 37 | meta: 38 | `Exception caught, waiting ${waitFor}ms before restarting operation`, 39 | }); 40 | yield* sleep(waitFor); 41 | } 42 | 43 | attempt += 1; 44 | waitFor = backoff(attempt); 45 | } 46 | }; 47 | } 48 | 49 | /** 50 | * keepAlive accepts a list of operations and calls them all with 51 | * {@link supervise} 52 | */ 53 | export function* keepAlive( 54 | ops: Callable[], 55 | backoff?: (attempt: number) => number, 56 | ): Operation[]> { 57 | const group = yield* parallel(ops.map((op) => supervise(op, backoff))); 58 | const results = yield* group; 59 | return results; 60 | } 61 | -------------------------------------------------------------------------------- /matcher.ts: -------------------------------------------------------------------------------- 1 | import type { AnyAction } from "./types.ts"; 2 | 3 | type ActionType = string; 4 | type GuardPredicate = (arg: T) => arg is G; 5 | type Predicate = ( 6 | action: Guard, 7 | ) => boolean; 8 | type StringableActionCreator = { 9 | (...args: unknown[]): A; 10 | toString(): string; 11 | }; 12 | type SubPattern = 13 | | Predicate 14 | | StringableActionCreator 15 | | ActionType; 16 | export type Pattern = SubPattern | SubPattern[]; 17 | type ActionSubPattern = 18 | | GuardPredicate 19 | | StringableActionCreator 20 | | Predicate 21 | | ActionType; 22 | export type ActionPattern = 23 | | ActionSubPattern 24 | | ActionSubPattern[]; 25 | 26 | export function matcher(pattern: ActionPattern): Predicate { 27 | if (pattern === "*") { 28 | return function (input) { 29 | return !!input; 30 | }; 31 | } 32 | 33 | if (typeof pattern === "string") { 34 | return function (input) { 35 | return pattern === input.type; 36 | }; 37 | } 38 | 39 | if (Array.isArray(pattern)) { 40 | return function (input) { 41 | return pattern.some((p) => matcher(p)(input)); 42 | }; 43 | } 44 | 45 | if (typeof pattern === "function" && Object.hasOwn(pattern, "toString")) { 46 | return function (input) { 47 | return pattern.toString() === input.type; 48 | }; 49 | } 50 | 51 | if (typeof pattern === "function") { 52 | return function (input) { 53 | return pattern(input) as boolean; 54 | }; 55 | } 56 | 57 | throw new Error("invalid pattern"); 58 | } 59 | -------------------------------------------------------------------------------- /mdw/mod.ts: -------------------------------------------------------------------------------- 1 | import * as queryMdw from "./query.ts"; 2 | import * as storeMdw from "./store.ts"; 3 | 4 | export const mdw = { ...queryMdw, ...storeMdw }; 5 | -------------------------------------------------------------------------------- /mdw/query.ts: -------------------------------------------------------------------------------- 1 | import { call, type Callable } from "effection"; 2 | import { safe } from "../fx/mod.ts"; 3 | import { compose } from "../compose.ts"; 4 | import { put } from "../action.ts"; 5 | import type { 6 | ApiCtx, 7 | ApiRequest, 8 | FetchJsonCtx, 9 | MiddlewareApi, 10 | PerfCtx, 11 | RequiredApiRequest, 12 | ThunkCtx, 13 | } from "../query/types.ts"; 14 | import type { AnyAction, Next } from "../types.ts"; 15 | import { mergeRequest } from "../query/util.ts"; 16 | import * as fetchMdw from "./fetch.ts"; 17 | export * from "./fetch.ts"; 18 | 19 | /** 20 | * This middleware will catch any errors in the pipeline 21 | * and `console.error` the context object. 22 | * 23 | * You are highly encouraged to ditch this middleware if you need something 24 | * more custom. 25 | * 26 | * It also sets `ctx.result` which informs us whether the entire 27 | * middleware pipeline succeeded or not. Think the `.catch()` case for 28 | * `window.fetch`. 29 | */ 30 | export function* err(ctx: Ctx, next: Next) { 31 | ctx.result = yield* safe(next); 32 | if (!ctx.result.ok) { 33 | const message = 34 | `Error: ${ctx.result.error.message}. Check the endpoint [${ctx.name}]`; 35 | console.error(message, ctx); 36 | yield* put({ 37 | type: "error:query", 38 | payload: { 39 | message, 40 | ctx, 41 | }, 42 | }); 43 | } 44 | } 45 | 46 | /** 47 | * This middleware allows the user to override the default key provided 48 | * to every pipeline function and instead use whatever they want. 49 | * 50 | * @example 51 | * ```ts 52 | * import { createPipe } from 'starfx'; 53 | * 54 | * const thunk = createPipe(); 55 | * thunk.use(customKey); 56 | * 57 | * const doit = thunk.create('some-action', function*(ctx, next) { 58 | * ctx.key = 'something-i-want'; 59 | * }) 60 | * ``` 61 | */ 62 | export function* customKey( 63 | ctx: Ctx, 64 | next: Next, 65 | ) { 66 | if ( 67 | ctx?.key && 68 | ctx?.action?.payload?.key && 69 | ctx.key !== ctx.action.payload.key 70 | ) { 71 | const newKey = ctx.name.split("|")[0] + "|" + ctx.key; 72 | ctx.key = newKey; 73 | ctx.action.payload.key = newKey; 74 | } 75 | yield* next(); 76 | } 77 | 78 | /** 79 | * This middleware sets up the context object with some values that are 80 | * necessary for {@link createApi} to work properly. 81 | */ 82 | export function* queryCtx(ctx: Ctx, next: Next) { 83 | if (!ctx.req) { 84 | ctx.req = (r?: ApiRequest): RequiredApiRequest => 85 | mergeRequest(ctx.request, r); 86 | } 87 | if (!ctx.request) ctx.request = ctx.req(); 88 | if (!ctx.response) ctx.response = null; 89 | if (!ctx.json) ctx.json = { ok: false, error: {} }; 90 | if (!ctx.actions) ctx.actions = []; 91 | if (!ctx.bodyType) ctx.bodyType = "json"; 92 | yield* next(); 93 | } 94 | 95 | /** 96 | * This middleware will take the result of `ctx.actions` and dispatch them 97 | * as a single batch. 98 | * 99 | * @remarks This is useful because sometimes there are a lot of actions that need dispatched 100 | * within the pipeline of the middleware and instead of dispatching them serially this 101 | * improves performance by only hitting the reducers once. 102 | */ 103 | export function* actions(ctx: { actions: AnyAction[] }, next: Next) { 104 | if (!ctx.actions) ctx.actions = []; 105 | yield* next(); 106 | if (ctx.actions.length === 0) return; 107 | yield* put(ctx.actions); 108 | } 109 | 110 | /** 111 | * This middleware will add `performance.now()` before and after your 112 | * middleware pipeline. 113 | */ 114 | export function* perf(ctx: Ctx, next: Next) { 115 | const t0 = performance.now(); 116 | yield* next(); 117 | const t1 = performance.now(); 118 | ctx.performance = t1 - t0; 119 | } 120 | 121 | /** 122 | * This middleware is a composition of other middleware required to 123 | * use `window.fetch` {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API} 124 | * with {@link createApi} 125 | */ 126 | export function fetch( 127 | { 128 | baseUrl = "", 129 | }: { 130 | baseUrl?: string; 131 | } = { baseUrl: "" }, 132 | ) { 133 | return compose([ 134 | fetchMdw.composeUrl(baseUrl), 135 | fetchMdw.payload, 136 | fetchMdw.request, 137 | fetchMdw.json, 138 | ]); 139 | } 140 | 141 | /** 142 | * This middleware will only be activated if predicate is true. 143 | */ 144 | export function predicate( 145 | predicate: ((ctx: Ctx) => boolean) | ((ctx: Ctx) => Callable), 146 | ) { 147 | return (mdw: MiddlewareApi) => { 148 | return function* (ctx: Ctx, next: Next) { 149 | const valid = yield* call(() => predicate(ctx)); 150 | if (!valid) { 151 | yield* next(); 152 | return; 153 | } 154 | 155 | yield* mdw(ctx, next); 156 | }; 157 | }; 158 | } 159 | -------------------------------------------------------------------------------- /mdw/store.ts: -------------------------------------------------------------------------------- 1 | import type { ApiCtx, ThunkCtxWLoader } from "../query/mod.ts"; 2 | import { compose } from "../compose.ts"; 3 | import type { AnyState, Next } from "../types.ts"; 4 | import { 5 | LoaderOutput, 6 | select, 7 | TableOutput, 8 | updateStore, 9 | } from "../store/mod.ts"; 10 | import { actions, customKey, err, queryCtx } from "./query.ts"; 11 | import { nameParser } from "./fetch.ts"; 12 | 13 | export interface ApiMdwProps< 14 | Ctx extends ApiCtx = ApiCtx, 15 | M extends AnyState = AnyState, 16 | > { 17 | schema: { 18 | loaders: LoaderOutput; 19 | cache: TableOutput; 20 | }; 21 | errorFn?: (ctx: Ctx) => string; 22 | } 23 | 24 | interface ErrorLike { 25 | message: string; 26 | } 27 | 28 | function isErrorLike(err: unknown): err is ErrorLike { 29 | return typeof err === "object" && err !== null && "message" in err; 30 | } 31 | 32 | /** 33 | * This middleware is a composition of many middleware used to faciliate 34 | * the {@link createApi}. 35 | * 36 | * It is not required, however, it is battle-tested and highly recommended. 37 | * 38 | * List of mdw: 39 | * - {@link mdw.err} 40 | * - {@link mdw.actions} 41 | * - {@link mdw.queryCtx} 42 | * - {@link mdw.customKey} 43 | * - {@link mdw.nameParser} 44 | * - {@link mdw.loaderApi} 45 | * - {@link mdw.cache} 46 | */ 47 | export function api( 48 | props: ApiMdwProps, 49 | ) { 50 | return compose([ 51 | err, 52 | actions, 53 | queryCtx, 54 | customKey, 55 | nameParser, 56 | loaderApi(props), 57 | cache(props.schema), 58 | ]); 59 | } 60 | 61 | /** 62 | * This middleware will automatically cache any data found inside `ctx.json` 63 | * which is where we store JSON data from the {@link mdw.fetch} middleware. 64 | */ 65 | export function cache(schema: { 66 | cache: TableOutput; 67 | }) { 68 | return function* cache(ctx: Ctx, next: Next) { 69 | ctx.cacheData = yield* select(schema.cache.selectById, { id: ctx.key }); 70 | yield* next(); 71 | if (!ctx.cache) return; 72 | let data; 73 | if (ctx.json.ok) { 74 | data = ctx.json.value; 75 | } else { 76 | data = ctx.json.error; 77 | } 78 | yield* updateStore(schema.cache.add({ [ctx.key]: data })); 79 | ctx.cacheData = data; 80 | }; 81 | } 82 | 83 | /** 84 | * This middleware will track the status of a middleware fn 85 | */ 86 | export function loader(schema: { 87 | loaders: LoaderOutput; 88 | }) { 89 | return function* ( 90 | ctx: Ctx, 91 | next: Next, 92 | ) { 93 | yield* updateStore([ 94 | schema.loaders.start({ id: ctx.name }), 95 | schema.loaders.start({ id: ctx.key }), 96 | ]); 97 | 98 | if (!ctx.loader) ctx.loader = {} as any; 99 | 100 | try { 101 | yield* next(); 102 | 103 | if (!ctx.loader) { 104 | ctx.loader = {}; 105 | } 106 | 107 | yield* updateStore([ 108 | schema.loaders.success({ id: ctx.name, ...ctx.loader }), 109 | schema.loaders.success({ id: ctx.key, ...ctx.loader }), 110 | ]); 111 | } catch (err) { 112 | if (!ctx.loader) { 113 | ctx.loader = {}; 114 | } 115 | 116 | const message = isErrorLike(err) ? err.message : "unknown exception"; 117 | yield* updateStore([ 118 | schema.loaders.error({ 119 | id: ctx.name, 120 | message, 121 | ...ctx.loader, 122 | }), 123 | schema.loaders.error({ 124 | id: ctx.key, 125 | message, 126 | ...ctx.loader, 127 | }), 128 | ]); 129 | } finally { 130 | const loaders = yield* select((s: any) => 131 | schema.loaders.selectByIds(s, { ids: [ctx.name, ctx.key] }) 132 | ); 133 | const ids = loaders 134 | .filter((loader) => loader.status === "loading") 135 | .map((loader) => loader.id); 136 | 137 | if (ids.length > 0) { 138 | yield* updateStore(schema.loaders.resetByIds(ids)); 139 | } 140 | } 141 | }; 142 | } 143 | 144 | function defaultErrorFn(ctx: Ctx) { 145 | const jso = ctx.json; 146 | if (jso.ok) return ""; 147 | return jso.error?.message || ""; 148 | } 149 | 150 | /** 151 | * This middleware will track the status of a fetch request. 152 | */ 153 | export function loaderApi< 154 | Ctx extends ApiCtx = ApiCtx, 155 | S extends AnyState = AnyState, 156 | >({ schema, errorFn = defaultErrorFn }: ApiMdwProps) { 157 | return function* trackLoading(ctx: Ctx, next: Next) { 158 | try { 159 | yield* updateStore([ 160 | schema.loaders.start({ id: ctx.name }), 161 | schema.loaders.start({ id: ctx.key }), 162 | ]); 163 | if (!ctx.loader) ctx.loader = {} as any; 164 | 165 | yield* next(); 166 | 167 | if (!ctx.response) { 168 | yield* updateStore(schema.loaders.resetByIds([ctx.name, ctx.key])); 169 | return; 170 | } 171 | 172 | if (!ctx.loader) { 173 | ctx.loader = {}; 174 | } 175 | 176 | if (!ctx.response.ok) { 177 | yield* updateStore([ 178 | schema.loaders.error({ 179 | id: ctx.name, 180 | message: errorFn(ctx), 181 | ...ctx.loader, 182 | }), 183 | schema.loaders.error({ 184 | id: ctx.key, 185 | message: errorFn(ctx), 186 | ...ctx.loader, 187 | }), 188 | ]); 189 | return; 190 | } 191 | 192 | yield* updateStore([ 193 | schema.loaders.success({ id: ctx.name, ...ctx.loader }), 194 | schema.loaders.success({ id: ctx.key, ...ctx.loader }), 195 | ]); 196 | } catch (err) { 197 | const message = isErrorLike(err) ? err.message : "unknown exception"; 198 | yield* updateStore([ 199 | schema.loaders.error({ 200 | id: ctx.name, 201 | message, 202 | ...ctx.loader, 203 | }), 204 | schema.loaders.error({ 205 | id: ctx.key, 206 | message, 207 | ...ctx.loader, 208 | }), 209 | ]); 210 | } finally { 211 | const loaders = yield* select((s: any) => 212 | schema.loaders.selectByIds(s, { ids: [ctx.name, ctx.key] }) 213 | ); 214 | const ids = loaders 215 | .filter((loader) => loader.status === "loading") 216 | .map((loader) => loader.id); 217 | yield* updateStore(schema.loaders.resetByIds(ids)); 218 | } 219 | }; 220 | } 221 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./fx/mod.ts"; 2 | export * from "./query/mod.ts"; 3 | export * from "./store/mod.ts"; 4 | export * from "./mdw/mod.ts"; 5 | 6 | export * from "./types.ts"; 7 | export * from "./compose.ts"; 8 | export * from "./action.ts"; 9 | export * from "./supervisor.ts"; 10 | export { 11 | action, 12 | call, 13 | createChannel, 14 | createContext, 15 | createQueue, 16 | createScope, 17 | createSignal, 18 | each, 19 | ensure, 20 | Err, 21 | Ok, 22 | race, 23 | resource, 24 | run, 25 | sleep, 26 | spawn, 27 | useAbortSignal, 28 | } from "effection"; 29 | export type { 30 | Callable, 31 | Channel, 32 | Instruction, 33 | Operation, 34 | Result, 35 | Scope, 36 | Stream, 37 | Subscription, 38 | Task, 39 | } from "effection"; 40 | -------------------------------------------------------------------------------- /query/api.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-explicit-any 2 | import type { ApiCtx, ApiRequest } from "./types.ts"; 3 | import type { Next } from "../types.ts"; 4 | import { createThunks } from "./thunk.ts"; 5 | import type { ThunksApi } from "./thunk.ts"; 6 | import type { ApiName, QueryApi } from "./api-types.ts"; 7 | 8 | /** 9 | * Creates a middleware thunksline for HTTP requests. 10 | * 11 | * @remarks 12 | * It uses {@link createThunks} under the hood. 13 | * 14 | * @example 15 | * ```ts 16 | * import { createApi, mdw } from 'starfx'; 17 | * 18 | * const api = createApi(); 19 | * api.use(mdw.api()); 20 | * api.use(api.routes()); 21 | * api.use(mdw.fetch({ baseUrl: 'https://api.com' })); 22 | * 23 | * const fetchUsers = api.get('/users', function*(ctx, next) { 24 | * yield next(); 25 | * }); 26 | * 27 | * store.dispatch(fetchUsers()); 28 | * ``` 29 | */ 30 | export function createApi( 31 | baseThunk?: ThunksApi, 32 | ): QueryApi { 33 | const thunks = baseThunk || createThunks(); 34 | const uri = (prename: ApiName) => { 35 | const create = thunks.create as any; 36 | 37 | let name = prename; 38 | let remainder = ""; 39 | if (Array.isArray(name)) { 40 | if (name.length === 0) { 41 | throw new Error( 42 | "createApi requires a non-empty array for the name of the endpoint", 43 | ); 44 | } 45 | name = prename[0]; 46 | if (name.length > 1) { 47 | const [_, ...other] = prename; 48 | remainder = ` ${other.join("|")}`; 49 | } 50 | } 51 | const tmpl = (method: string) => `${name} [${method}]${remainder}`; 52 | 53 | return { 54 | get: (...args: any[]) => create(tmpl("GET"), ...args), 55 | post: (...args: any[]) => create(tmpl("POST"), ...args), 56 | put: (...args: any[]) => create(tmpl("PUT"), ...args), 57 | patch: (...args: any[]) => create(tmpl("PATCH"), ...args), 58 | delete: (...args: any[]) => create(tmpl("DELETE"), ...args), 59 | options: (...args: any[]) => create(tmpl("OPTIONS"), ...args), 60 | head: (...args: any[]) => create(tmpl("HEAD"), ...args), 61 | connect: (...args: any[]) => create(tmpl("CONNECT"), ...args), 62 | trace: (...args: any[]) => create(tmpl("TRACE"), ...args), 63 | }; 64 | }; 65 | 66 | return { 67 | use: thunks.use, 68 | /** 69 | * @deprecated use `register()` instead 70 | */ 71 | bootup: thunks.register, 72 | register: thunks.register, 73 | create: thunks.create, 74 | routes: thunks.routes, 75 | reset: thunks.reset, 76 | cache: () => { 77 | return function* onCache(ctx: Ctx, next: Next) { 78 | ctx.cache = true; 79 | yield* next(); 80 | }; 81 | }, 82 | request: (req: ApiRequest) => { 83 | return function* onRequest(ctx: Ctx, next: Next) { 84 | ctx.request = ctx.req(req); 85 | yield* next(); 86 | }; 87 | }, 88 | uri, 89 | get: (name: ApiName, ...args: any[]) => uri(name).get(...args), 90 | post: (name: ApiName, ...args: any[]) => uri(name).post(...args), 91 | put: (name: ApiName, ...args: any[]) => uri(name).put(...args), 92 | patch: (name: ApiName, ...args: any[]) => uri(name).patch(...args), 93 | delete: (name: ApiName, ...args: any[]) => uri(name).delete(...args), 94 | options: (name: ApiName, ...args: any[]) => uri(name).options(...args), 95 | head: (name: ApiName, ...args: any[]) => uri(name).head(...args), 96 | connect: (name: ApiName, ...args: any[]) => uri(name).connect(...args), 97 | trace: (name: ApiName, ...args: any[]) => uri(name).trace(...args), 98 | }; 99 | } 100 | -------------------------------------------------------------------------------- /query/create-key.ts: -------------------------------------------------------------------------------- 1 | import { isObject } from "./util.ts"; 2 | 3 | // deno-lint-ignore no-explicit-any 4 | const deepSortObject = (opts?: any) => { 5 | if (!isObject(opts)) return opts; 6 | return Object.keys(opts) 7 | .sort() 8 | .reduce>((res, key) => { 9 | res[`${key}`] = opts[key]; 10 | if (opts[key] && isObject(opts[key])) { 11 | res[`${key}`] = deepSortObject(opts[key]); 12 | } 13 | return res; 14 | }, {}); 15 | }; 16 | 17 | function padStart(hash: string, len: number) { 18 | while (hash.length < len) { 19 | hash = "0" + hash; 20 | } 21 | return hash; 22 | } 23 | 24 | // https://gist.github.com/iperelivskiy/4110988 25 | const tinySimpleHash = (s: string) => { 26 | let h = 9; 27 | for (let i = 0; i < s.length;) { 28 | h = Math.imul(h ^ s.charCodeAt(i++), 9 ** 9); 29 | } 30 | return h ^ (h >>> 9); 31 | }; 32 | 33 | /** 34 | * This function used to set `ctx.key` 35 | */ 36 | // deno-lint-ignore no-explicit-any 37 | export const createKey = (name: string, payload?: any) => { 38 | const normJsonString = typeof payload !== "undefined" 39 | ? JSON.stringify(deepSortObject(payload)) 40 | : ""; 41 | const hash = normJsonString 42 | ? padStart(tinySimpleHash(normJsonString).toString(16), 8) 43 | : ""; 44 | return hash ? `${name}|${hash}` : name; 45 | }; 46 | -------------------------------------------------------------------------------- /query/mod.ts: -------------------------------------------------------------------------------- 1 | import { createThunks, type ThunksApi } from "./thunk.ts"; 2 | 3 | export * from "./api.ts"; 4 | export * from "./types.ts"; 5 | export * from "./create-key.ts"; 6 | 7 | export { createThunks, ThunksApi }; 8 | 9 | /** 10 | * @deprecated Use {@link createThunks} instead; 11 | */ 12 | export const createPipe = createThunks; 13 | -------------------------------------------------------------------------------- /query/types.ts: -------------------------------------------------------------------------------- 1 | import type { Operation, Result } from "effection"; 2 | import type { 3 | Action, 4 | ActionWithPayload, 5 | LoaderItemState, 6 | LoaderPayload, 7 | Next, 8 | Payload, 9 | } from "../types.ts"; 10 | 11 | type IfAny = 0 extends 1 & T ? Y : N; 12 | 13 | export interface ThunkCtx

extends Payload

{ 14 | name: string; 15 | key: string; 16 | action: ActionWithPayload>; 17 | actionFn: IfAny< 18 | P, 19 | CreateAction, 20 | CreateActionWithPayload, P> 21 | >; 22 | result: Result; 23 | } 24 | 25 | export interface ThunkCtxWLoader extends ThunkCtx { 26 | loader: Omit, "id"> | null; 27 | } 28 | 29 | export interface LoaderCtx

extends ThunkCtx

{ 30 | loader: Partial | null; 31 | } 32 | 33 | export type ApiFetchResult = 34 | | { 35 | ok: true; 36 | value: ApiSuccess; 37 | } 38 | | { 39 | ok: false; 40 | error: ApiError; 41 | }; 42 | 43 | export type ApiRequest = Partial<{ url: string } & RequestInit>; 44 | export type RequiredApiRequest = { 45 | url: string; 46 | headers: HeadersInit; 47 | } & Partial; 48 | 49 | export interface FetchCtx

extends ThunkCtx

{ 50 | request: ApiRequest | null; 51 | req: (r?: ApiRequest) => RequiredApiRequest; 52 | response: Response | null; 53 | bodyType: "arrayBuffer" | "blob" | "formData" | "json" | "text"; 54 | } 55 | 56 | export interface FetchJson { 57 | json: ApiFetchResult; 58 | } 59 | 60 | export interface FetchJsonCtx

61 | extends FetchCtx

, FetchJson {} 62 | 63 | export interface ApiCtx 64 | extends FetchJsonCtx { 65 | actions: Action[]; 66 | loader: Omit, "id"> | null; 67 | // should we cache ctx.json? 68 | cache: boolean; 69 | // should we use mdw.stub? 70 | stub: boolean; 71 | // previously cached data 72 | cacheData: any; 73 | _success: ApiSuccess; 74 | _error: ApiError; 75 | } 76 | 77 | export interface PerfCtx

extends ThunkCtx

{ 78 | performance: number; 79 | } 80 | 81 | export type Middleware = ( 82 | ctx: Ctx, 83 | next: Next, 84 | ) => Operation; 85 | export type MiddlewareCo = 86 | | Middleware 87 | | Middleware[]; 88 | 89 | export type MiddlewareApi = ( 90 | ctx: Ctx, 91 | next: Next, 92 | ) => Operation; 93 | export type MiddlewareApiCo = 94 | | Middleware 95 | | Middleware[]; 96 | 97 | export interface CreateActionPayload

{ 98 | name: string; 99 | key: string; 100 | options: P; 101 | _result: ApiSuccess; 102 | } 103 | 104 | export type CreateActionFn = () => ActionWithPayload< 105 | CreateActionPayload, ApiSuccess> 106 | >; 107 | 108 | export interface CreateAction 109 | extends CreateActionFn { 110 | run: ( 111 | p?: ActionWithPayload< 112 | CreateActionPayload, ApiSuccess> 113 | >, 114 | ) => Operation; 115 | use: (mdw: Middleware) => void; 116 | } 117 | 118 | export type CreateActionFnWithPayload

= ( 119 | p: P, 120 | ) => ActionWithPayload>; 121 | 122 | export interface CreateActionWithPayload< 123 | Ctx extends ThunkCtx, 124 | P, 125 | ApiSuccess = any, 126 | > extends CreateActionFnWithPayload { 127 | run: ( 128 | a: ActionWithPayload> | P, 129 | ) => Operation; 130 | use: (mdw: Middleware) => void; 131 | } 132 | 133 | export type ThunkAction

= ActionWithPayload< 134 | CreateActionPayload 135 | >; 136 | 137 | export type Supervisor = ( 138 | pattern: string, 139 | op: (action: Action) => Operation, 140 | ) => Operation; 141 | -------------------------------------------------------------------------------- /query/util.ts: -------------------------------------------------------------------------------- 1 | import type { ApiRequest, RequiredApiRequest } from "./types.ts"; 2 | 3 | export function* noop() {} 4 | // deno-lint-ignore no-explicit-any 5 | export const isFn = (fn?: any) => fn && typeof fn === "function"; 6 | // deno-lint-ignore no-explicit-any 7 | export const isObject = (obj?: any) => typeof obj === "object" && obj !== null; 8 | 9 | export const mergeHeaders = ( 10 | cur?: HeadersInit, 11 | next?: HeadersInit, 12 | ): HeadersInit => { 13 | if (!cur && !next) return {}; 14 | if (!cur && next) return next; 15 | if (cur && !next) return cur; 16 | return { ...cur, ...next }; 17 | }; 18 | 19 | export const mergeRequest = ( 20 | cur?: ApiRequest | null, 21 | next?: ApiRequest | null, 22 | ): RequiredApiRequest => { 23 | const defaultReq = { url: "", method: "GET", headers: mergeHeaders() }; 24 | if (!cur && !next) return { ...defaultReq, headers: mergeHeaders() }; 25 | if (!cur && next) return { ...defaultReq, ...next }; 26 | if (cur && !next) return { ...defaultReq, ...cur }; 27 | return { 28 | ...defaultReq, 29 | ...cur, 30 | ...next, 31 | headers: mergeHeaders(cur?.headers, next?.headers), 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /queue.ts: -------------------------------------------------------------------------------- 1 | import { createQueue } from "effection"; 2 | 3 | export function createFilterQueue(predicate: (v: T) => boolean) { 4 | const queue = createQueue(); 5 | 6 | return { 7 | ...queue, 8 | add(value: T) { 9 | if (predicate(value)) { 10 | queue.add(value); 11 | } 12 | }, 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /scripts/branch-exists.ts: -------------------------------------------------------------------------------- 1 | import { call, main, type Operation } from "effection"; 2 | 3 | await main(function* (): Operation { 4 | // based on env created from ${{ secrets.GITHUB_TOKEN }} in CI 5 | const token = Deno.env.get("GITHUB_TOKEN"); 6 | const [branch, ownerRepo] = Deno.args; 7 | console.dir({ branch, ownerRepo }); 8 | 9 | const response = yield* call( 10 | fetch(`https://api.github.com/repos/${ownerRepo}/branches`, { 11 | headers: { 12 | Accept: "application/vnd.github+json", 13 | "X-GitHub-Api-Version": "2022-11-28", 14 | // the token isn't required but helps with rate limiting 15 | ...(token ? { Authorization: `Bearer ${token}` } : {}), 16 | }, 17 | }), 18 | ); 19 | 20 | if (response.ok) { 21 | const branches = yield* call(response.json()); 22 | const branchList = branches.map((branch: { name: string }) => branch.name); 23 | // for CI debug purposes 24 | console.dir({ branchList }); 25 | // GitHub Actions maintains the step output through a file which you append keys into 26 | // the path that file is available as an env var 27 | if (Deno.env.get("CI")) { 28 | const output = Deno.env.get("GITHUB_OUTPUT"); 29 | if (!output) throw new Error("$GITHUB_OUTPUT is not set"); 30 | const encoder = new TextEncoder(); 31 | if (branchList.includes(branch)) { 32 | const data = encoder.encode(`branch=${branch}`); 33 | yield* call(Deno.writeFile(output, data, { append: true })); 34 | } else { 35 | const data = encoder.encode("branch=main"); 36 | yield* call(Deno.writeFile(output, data, { append: true })); 37 | } 38 | } 39 | // always log out the branch for both CI and local running 40 | if (branchList.includes(branch)) { 41 | console.log(`branch=${branch}`); 42 | } else { 43 | console.log(`branch=main`); 44 | } 45 | } else { 46 | console.error( 47 | `Error trying to fetch https://api.github.com/repos/${ownerRepo}/branches and check for ${branch}`, 48 | ); 49 | const text = yield* call(response.text()); 50 | throw new Error(text); 51 | } 52 | }); 53 | -------------------------------------------------------------------------------- /scripts/npm.ts: -------------------------------------------------------------------------------- 1 | import { build, emptyDir } from "jsr:@deno/dnt@0.41.3"; 2 | 3 | const [version] = Deno.args; 4 | if (!version) { 5 | throw new Error("a version argument is required to build the npm package"); 6 | } 7 | 8 | init().then(console.log).catch(console.error); 9 | 10 | async function init() { 11 | await emptyDir("./npm"); 12 | await build({ 13 | declaration: "inline", 14 | scriptModule: "cjs", 15 | entryPoints: [ 16 | { 17 | name: ".", 18 | path: "mod.ts", 19 | }, 20 | { 21 | name: "./react", 22 | path: "react.ts", 23 | }, 24 | ], 25 | mappings: { 26 | // use the deno module in this repo, but use the npm module when publishing 27 | "https://deno.land/x/effection@3.0.0-beta.3/mod.ts": { 28 | name: "effection", 29 | version: "3.0.0-beta.3", 30 | }, 31 | }, 32 | importMap: "deno.json", 33 | outDir: "./npm", 34 | shims: { 35 | deno: false, 36 | }, 37 | test: false, 38 | 39 | typeCheck: "both", 40 | compilerOptions: { 41 | target: "ES2020", 42 | sourceMap: true, 43 | lib: ["DOM", "DOM.Iterable", "ESNext"], 44 | }, 45 | package: { 46 | name: "starfx", 47 | version, 48 | description: 49 | "Async flow control and state management system for deno, node, and browser", 50 | license: "MIT", 51 | author: { 52 | name: "Eric Bower", 53 | email: "me@erock.io", 54 | }, 55 | repository: { 56 | type: "git", 57 | url: "git+https://github.com/neurosnap/starfx.git", 58 | }, 59 | bugs: { 60 | url: "https://github.com/neurosnap/starfx/issues", 61 | }, 62 | engines: { 63 | node: ">= 18", 64 | }, 65 | sideEffects: false, 66 | }, 67 | postBuild() { 68 | Deno.copyFileSync("LICENSE.md", "npm/LICENSE.md"); 69 | Deno.copyFileSync("README.md", "npm/README.md"); 70 | }, 71 | }); 72 | } 73 | -------------------------------------------------------------------------------- /scripts/sync.ts: -------------------------------------------------------------------------------- 1 | import { call, main, type Operation } from "effection"; 2 | 3 | await main(function* (): Operation { 4 | const [syncMethod, folderFromArgs] = Deno.args; 5 | const folder = folderFromArgs ?? "starfx-examples/vite-react"; 6 | const dir = `../${folder}/node_modules/starfx`; 7 | const npmAssets = yield* call(Deno.realPath("./npm")); 8 | 9 | if (syncMethod === "install") { 10 | // parcel doesn't handle symlinks well, do a `file:` install instead 11 | const command = new Deno.Command("npm", { 12 | args: ["add", "starfx@file:../../starfx/npm", "--install-links"], 13 | cwd: `../${folder}`, 14 | stderr: "piped", 15 | stdout: "piped", 16 | }); 17 | yield* call(command.output()); 18 | } else if (syncMethod === "symlink") { 19 | // this option is primarily for local usage 20 | try { 21 | yield* call(Deno.remove(dir, { recursive: true })); 22 | } catch (_error) { 23 | // assume that the folder does not exist 24 | } 25 | 26 | // create a symlink to the `dir` which should allow 27 | // this example to run with the build assets 28 | yield* call(Deno.symlink(npmAssets, dir)); 29 | } 30 | }); 31 | -------------------------------------------------------------------------------- /store/batch.ts: -------------------------------------------------------------------------------- 1 | import { action } from "effection"; 2 | import { UpdaterCtx } from "./types.ts"; 3 | import { AnyState, Next } from "../types.ts"; 4 | 5 | export function createBatchMdw( 6 | queue: (send: () => void) => void, 7 | ) { 8 | let notifying = false; 9 | return function* batchMdw(_: UpdaterCtx, next: Next) { 10 | if (!notifying) { 11 | notifying = true; 12 | yield* action(function* (resolve) { 13 | queue(() => { 14 | notifying = false; 15 | resolve(); 16 | }); 17 | }); 18 | yield* next(); 19 | } 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /store/context.ts: -------------------------------------------------------------------------------- 1 | import { type Channel, createChannel, createContext } from "effection"; 2 | import type { AnyState } from "../types.ts"; 3 | import type { FxStore } from "./types.ts"; 4 | 5 | export const StoreUpdateContext = createContext>( 6 | "starfx:store:update", 7 | createChannel(), 8 | ); 9 | export const StoreContext = createContext>("starfx:store"); 10 | -------------------------------------------------------------------------------- /store/fx.ts: -------------------------------------------------------------------------------- 1 | import { Operation, Result } from "effection"; 2 | import type { ActionFnWithPayload, AnyState } from "../types.ts"; 3 | import type { FxStore, StoreUpdater, UpdaterCtx } from "./types.ts"; 4 | import { StoreContext } from "./context.ts"; 5 | import { LoaderOutput } from "./slice/loaders.ts"; 6 | import { parallel, safe } from "../fx/mod.ts"; 7 | import { ThunkAction } from "../query/mod.ts"; 8 | import { getIdFromAction, take } from "../action.ts"; 9 | 10 | export function* updateStore( 11 | updater: StoreUpdater | StoreUpdater[], 12 | ): Operation> { 13 | const store = yield* StoreContext; 14 | // had to cast the store since StoreContext has a generic store type 15 | const st = store as FxStore; 16 | const ctx = yield* st.update(updater); 17 | return ctx; 18 | } 19 | 20 | export function select(selectorFn: (s: S) => R): Operation; 21 | export function select( 22 | selectorFn: (s: S, p: P) => R, 23 | p: P, 24 | ): Operation; 25 | export function* select( 26 | selectorFn: (s: S, p?: P) => R, 27 | p?: P, 28 | ): Operation { 29 | const store = yield* StoreContext; 30 | return selectorFn(store.getState() as S, p); 31 | } 32 | 33 | export function* waitForLoader( 34 | loaders: LoaderOutput, 35 | action: ThunkAction | ActionFnWithPayload, 36 | ) { 37 | const id = getIdFromAction(action); 38 | const selector = (s: AnyState) => loaders.selectById(s, { id }); 39 | 40 | // check for done state on init 41 | let loader = yield* select(selector); 42 | if (loader.isSuccess || loader.isError) { 43 | return loader; 44 | } 45 | 46 | while (true) { 47 | yield* take("*"); 48 | loader = yield* select(selector); 49 | if (loader.isSuccess || loader.isError) { 50 | return loader; 51 | } 52 | } 53 | } 54 | 55 | export function* waitForLoaders( 56 | loaders: LoaderOutput, 57 | actions: (ThunkAction | ActionFnWithPayload)[], 58 | ) { 59 | const group = yield* parallel( 60 | actions.map((action) => waitForLoader(loaders, action)), 61 | ); 62 | return yield* group; 63 | } 64 | 65 | export function createTracker>( 66 | loader: LoaderOutput, 67 | ) { 68 | return (id: string) => { 69 | return function* (op: () => Operation>) { 70 | yield* updateStore(loader.start({ id })); 71 | const result = yield* safe(op); 72 | if (result.ok) { 73 | yield* updateStore(loader.success({ id })); 74 | } else { 75 | yield* updateStore( 76 | loader.error({ 77 | id, 78 | message: result.error.message, 79 | }), 80 | ); 81 | } 82 | return result; 83 | }; 84 | }; 85 | } 86 | -------------------------------------------------------------------------------- /store/mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./context.ts"; 2 | export * from "./fx.ts"; 3 | export * from "./store.ts"; 4 | export * from "./types.ts"; 5 | export { createSelector } from "reselect"; 6 | export * from "./slice/mod.ts"; 7 | export * from "./schema.ts"; 8 | export * from "./batch.ts"; 9 | export * from "./persist.ts"; 10 | -------------------------------------------------------------------------------- /store/persist.ts: -------------------------------------------------------------------------------- 1 | import { Err, Ok, Operation, Result } from "effection"; 2 | import { select, updateStore } from "./fx.ts"; 3 | import type { AnyState, Next } from "../types.ts"; 4 | import type { UpdaterCtx } from "./types.ts"; 5 | 6 | export const PERSIST_LOADER_ID = "@@starfx/persist"; 7 | 8 | export interface PersistAdapter { 9 | getItem(key: string): Operation>>; 10 | setItem(key: string, item: Partial): Operation>; 11 | removeItem(key: string): Operation>; 12 | } 13 | 14 | export interface PersistProps { 15 | adapter: PersistAdapter; 16 | allowlist: (keyof S)[]; 17 | key: string; 18 | reconciler: (original: S, rehydrated: Partial) => S; 19 | rehydrate: () => Operation>; 20 | transform?: TransformFunctions; 21 | } 22 | interface TransformFunctions { 23 | in(s: Partial): Partial; 24 | out(s: Partial): Partial; 25 | } 26 | 27 | export function createTransform() { 28 | const transformers: TransformFunctions = { 29 | in: function (currentState: Partial): Partial { 30 | return currentState; 31 | }, 32 | out: function (currentState: Partial): Partial { 33 | return currentState; 34 | }, 35 | }; 36 | 37 | const inTransformer = function (state: Partial): Partial { 38 | return transformers.in(state); 39 | }; 40 | 41 | const outTransformer = function (state: Partial): Partial { 42 | return transformers.out(state); 43 | }; 44 | 45 | return { 46 | in: inTransformer, 47 | out: outTransformer, 48 | }; 49 | } 50 | 51 | export function createLocalStorageAdapter< 52 | S extends AnyState, 53 | >(): PersistAdapter { 54 | return { 55 | getItem: function* (key: string) { 56 | const storage = localStorage.getItem(key) || "{}"; 57 | return Ok(JSON.parse(storage)); 58 | }, 59 | setItem: function* (key: string, s: Partial) { 60 | const state = JSON.stringify(s); 61 | try { 62 | localStorage.setItem(key, state); 63 | } catch (err: any) { 64 | return Err(err); 65 | } 66 | return Ok(undefined); 67 | }, 68 | removeItem: function* (key: string) { 69 | localStorage.removeItem(key); 70 | return Ok(undefined); 71 | }, 72 | }; 73 | } 74 | 75 | export function shallowReconciler( 76 | original: S, 77 | persisted: Partial, 78 | ): S { 79 | return { ...original, ...persisted }; 80 | } 81 | 82 | export function createPersistor({ 83 | adapter, 84 | key = "starfx", 85 | reconciler = shallowReconciler, 86 | allowlist = [], 87 | transform, 88 | }: 89 | & Pick, "adapter"> 90 | & Partial>): PersistProps { 91 | function* rehydrate(): Operation> { 92 | const persistedState = yield* adapter.getItem(key); 93 | if (!persistedState.ok) { 94 | return Err(persistedState.error); 95 | } 96 | let stateFromStorage = persistedState.value as Partial; 97 | 98 | if (transform) { 99 | try { 100 | stateFromStorage = transform.out(persistedState.value); 101 | } catch (err: any) { 102 | console.error("Persistor outbound transformer error:", err); 103 | } 104 | } 105 | 106 | const state = yield* select((s) => s); 107 | const nextState = reconciler(state as S, stateFromStorage); 108 | yield* updateStore(function (state) { 109 | Object.keys(nextState).forEach((key: keyof S) => { 110 | state[key] = nextState[key]; 111 | }); 112 | }); 113 | 114 | return Ok(undefined); 115 | } 116 | 117 | return { 118 | key, 119 | adapter, 120 | allowlist, 121 | reconciler, 122 | rehydrate, 123 | transform, 124 | }; 125 | } 126 | 127 | export function persistStoreMdw({ 128 | allowlist, 129 | adapter, 130 | key, 131 | transform, 132 | }: PersistProps) { 133 | return function* (_: UpdaterCtx, next: Next) { 134 | yield* next(); 135 | const state = yield* select((s: S) => s); 136 | 137 | let transformedState: Partial = state; 138 | if (transform) { 139 | try { 140 | transformedState = transform.in(state); 141 | } catch (err: any) { 142 | console.error("Persistor inbound transformer error:", err); 143 | } 144 | } 145 | 146 | // empty allowlist list means save entire state 147 | if (allowlist.length === 0) { 148 | yield* adapter.setItem(key, transformedState); 149 | return; 150 | } 151 | 152 | const allowedState = allowlist.reduce>((acc, key) => { 153 | if (key in transformedState) { 154 | acc[key] = transformedState[key] as S[keyof S]; 155 | } 156 | return acc; 157 | }, {}); 158 | 159 | yield* adapter.setItem(key, allowedState); 160 | }; 161 | } 162 | -------------------------------------------------------------------------------- /store/run.ts: -------------------------------------------------------------------------------- 1 | import { Callable, Operation, Result, Scope, Task } from "effection"; 2 | import { parallel, safe } from "../fx/mod.ts"; 3 | 4 | export function createRun(scope: Scope) { 5 | function run(op: Callable[]): Task[]>; 6 | function run(op: Callable): Task>; 7 | function run( 8 | op: Callable | Callable[], 9 | ): Task | Result[]> { 10 | if (Array.isArray(op)) { 11 | return scope.run(function* (): Operation[]> { 12 | const group = yield* parallel(op); 13 | const result = yield* group; 14 | return result; 15 | }); 16 | } 17 | return scope.run(() => safe(op)); 18 | } 19 | 20 | return run; 21 | } 22 | -------------------------------------------------------------------------------- /store/schema.ts: -------------------------------------------------------------------------------- 1 | import { updateStore } from "./fx.ts"; 2 | import { slice } from "./slice/mod.ts"; 3 | import { FxMap, FxSchema, StoreUpdater } from "./types.ts"; 4 | 5 | const defaultSchema = function (): O { 6 | return { cache: slice.table(), loaders: slice.loaders() } as O; 7 | }; 8 | 9 | export function createSchema< 10 | O extends FxMap, 11 | S extends { [key in keyof O]: ReturnType["initialState"] }, 12 | >( 13 | slices: O = defaultSchema(), 14 | ): [FxSchema, S] { 15 | const db = Object.keys(slices).reduce>((acc, key) => { 16 | // deno-lint-ignore no-explicit-any 17 | (acc as any)[key] = slices[key](key); 18 | return acc; 19 | }, {} as FxSchema); 20 | 21 | const initialState = Object.keys(db).reduce((acc, key) => { 22 | // deno-lint-ignore no-explicit-any 23 | (acc as any)[key] = db[key].initialState; 24 | return acc; 25 | }, {}) as S; 26 | 27 | function* update( 28 | ups: 29 | | StoreUpdater 30 | | StoreUpdater[], 31 | ) { 32 | return yield* updateStore(ups); 33 | } 34 | 35 | db.update = update; 36 | 37 | return [db, initialState]; 38 | } 39 | -------------------------------------------------------------------------------- /store/slice/any.ts: -------------------------------------------------------------------------------- 1 | import type { AnyState } from "../../types.ts"; 2 | import type { BaseSchema } from "../types.ts"; 3 | 4 | export interface AnyOutput extends BaseSchema { 5 | schema: "any"; 6 | initialState: V; 7 | set: (v: V) => (s: S) => void; 8 | reset: () => (s: S) => void; 9 | select: (s: S) => V; 10 | } 11 | 12 | export function createAny({ 13 | name, 14 | initialState, 15 | }: { 16 | name: keyof S; 17 | initialState: V; 18 | }): AnyOutput { 19 | return { 20 | schema: "any", 21 | name: name as string, 22 | initialState, 23 | set: (value) => (state) => { 24 | // deno-lint-ignore no-explicit-any 25 | (state as any)[name] = value; 26 | }, 27 | reset: () => (state) => { 28 | // deno-lint-ignore no-explicit-any 29 | (state as any)[name] = initialState; 30 | }, 31 | select: (state) => { 32 | // deno-lint-ignore no-explicit-any 33 | return (state as any)[name]; 34 | }, 35 | }; 36 | } 37 | 38 | export function any(initialState: V) { 39 | return (name: string) => createAny({ name, initialState }); 40 | } 41 | -------------------------------------------------------------------------------- /store/slice/loaders.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from "reselect"; 2 | import type { 3 | AnyState, 4 | LoaderItemState, 5 | LoaderPayload, 6 | LoaderState, 7 | } from "../../types.ts"; 8 | import { BaseSchema } from "../types.ts"; 9 | 10 | interface PropId { 11 | id: string; 12 | } 13 | 14 | interface PropIds { 15 | ids: string[]; 16 | } 17 | 18 | const excludesFalse = (n?: T): n is T => Boolean(n); 19 | 20 | export function defaultLoaderItem( 21 | li: Partial> = {}, 22 | ): LoaderItemState { 23 | return { 24 | id: "", 25 | status: "idle", 26 | message: "", 27 | lastRun: 0, 28 | lastSuccess: 0, 29 | meta: {} as M, 30 | ...li, 31 | }; 32 | } 33 | 34 | export function defaultLoader( 35 | l: Partial> = {}, 36 | ): LoaderState { 37 | const loading = defaultLoaderItem(l); 38 | return { 39 | ...loading, 40 | isIdle: loading.status === "idle", 41 | isError: loading.status === "error", 42 | isSuccess: loading.status === "success", 43 | isLoading: loading.status === "loading", 44 | isInitialLoading: 45 | (loading.status === "idle" || loading.status === "loading") && 46 | loading.lastSuccess === 0, 47 | }; 48 | } 49 | 50 | interface LoaderSelectors< 51 | M extends AnyState = AnyState, 52 | S extends AnyState = AnyState, 53 | > { 54 | findById: ( 55 | d: Record>, 56 | { id }: PropId, 57 | ) => LoaderState; 58 | findByIds: ( 59 | d: Record>, 60 | { ids }: PropIds, 61 | ) => LoaderState[]; 62 | selectTable: (s: S) => Record>; 63 | selectTableAsList: (state: S) => LoaderItemState[]; 64 | selectById: (s: S, p: PropId) => LoaderState; 65 | selectByIds: (s: S, p: PropIds) => LoaderState[]; 66 | } 67 | 68 | function loaderSelectors< 69 | M extends AnyState = AnyState, 70 | S extends AnyState = AnyState, 71 | >( 72 | selectTable: (s: S) => Record>, 73 | ): LoaderSelectors { 74 | const empty = defaultLoader(); 75 | const tableAsList = ( 76 | data: Record>, 77 | ): LoaderItemState[] => Object.values(data).filter(excludesFalse); 78 | 79 | const findById = (data: Record>, { id }: PropId) => 80 | defaultLoader(data[id]) || empty; 81 | const findByIds = ( 82 | data: Record>, 83 | { ids }: PropIds, 84 | ): LoaderState[] => 85 | ids.map((id) => defaultLoader(data[id])).filter(excludesFalse); 86 | const selectById = createSelector( 87 | selectTable, 88 | (_: S, p: PropId) => p.id, 89 | (loaders, id): LoaderState => findById(loaders, { id }), 90 | ); 91 | 92 | return { 93 | findById, 94 | findByIds, 95 | selectTable, 96 | selectTableAsList: createSelector( 97 | selectTable, 98 | (data): LoaderItemState[] => tableAsList(data), 99 | ), 100 | selectById, 101 | selectByIds: createSelector( 102 | selectTable, 103 | (_: S, p: PropIds) => p.ids, 104 | (loaders, ids) => findByIds(loaders, { ids }), 105 | ), 106 | }; 107 | } 108 | 109 | export interface LoaderOutput< 110 | M extends Record, 111 | S extends AnyState, 112 | > extends 113 | LoaderSelectors, 114 | BaseSchema>> { 115 | schema: "loader"; 116 | initialState: Record>; 117 | start: (e: LoaderPayload) => (s: S) => void; 118 | success: (e: LoaderPayload) => (s: S) => void; 119 | error: (e: LoaderPayload) => (s: S) => void; 120 | reset: () => (s: S) => void; 121 | resetByIds: (ids: string[]) => (s: S) => void; 122 | } 123 | 124 | const ts = () => new Date().getTime(); 125 | 126 | export const createLoaders = < 127 | M extends AnyState = AnyState, 128 | S extends AnyState = AnyState, 129 | >({ 130 | name, 131 | initialState = {}, 132 | }: { 133 | name: keyof S; 134 | initialState?: Record>; 135 | }): LoaderOutput => { 136 | const selectors = loaderSelectors((s: S) => s[name]); 137 | 138 | return { 139 | schema: "loader", 140 | name: name as string, 141 | initialState, 142 | start: (e) => (s) => { 143 | const table = selectors.selectTable(s); 144 | const loader = table[e.id]; 145 | table[e.id] = defaultLoaderItem({ 146 | ...loader, 147 | ...e, 148 | status: "loading", 149 | lastRun: ts(), 150 | }); 151 | }, 152 | success: (e) => (s) => { 153 | const table = selectors.selectTable(s); 154 | const loader = table[e.id]; 155 | table[e.id] = defaultLoaderItem({ 156 | ...loader, 157 | ...e, 158 | status: "success", 159 | lastSuccess: ts(), 160 | }); 161 | }, 162 | error: (e) => (s) => { 163 | const table = selectors.selectTable(s); 164 | const loader = table[e.id]; 165 | table[e.id] = defaultLoaderItem({ 166 | ...loader, 167 | ...e, 168 | status: "error", 169 | }); 170 | }, 171 | reset: () => (s) => { 172 | // deno-lint-ignore no-explicit-any 173 | (s as any)[name] = initialState; 174 | }, 175 | resetByIds: (ids: string[]) => (s) => { 176 | const table = selectors.selectTable(s); 177 | ids.forEach((id) => { 178 | delete table[id]; 179 | }); 180 | }, 181 | ...selectors, 182 | }; 183 | }; 184 | 185 | export function loaders( 186 | initialState?: Record>, 187 | ) { 188 | return (name: string) => createLoaders({ name, initialState }); 189 | } 190 | -------------------------------------------------------------------------------- /store/slice/mod.ts: -------------------------------------------------------------------------------- 1 | import { str, StrOutput } from "./str.ts"; 2 | import { num, NumOutput } from "./num.ts"; 3 | import { table, TableOutput } from "./table.ts"; 4 | import { any, AnyOutput } from "./any.ts"; 5 | import { obj, ObjOutput } from "./obj.ts"; 6 | import { 7 | defaultLoader, 8 | defaultLoaderItem, 9 | LoaderOutput, 10 | loaders, 11 | } from "./loaders.ts"; 12 | 13 | export const slice = { 14 | str, 15 | num, 16 | table, 17 | any, 18 | obj, 19 | loaders, 20 | /** 21 | * @deprecated Use `slice.loaders` instead 22 | */ 23 | loader: loaders, 24 | }; 25 | export { defaultLoader, defaultLoaderItem }; 26 | export type { 27 | AnyOutput, 28 | LoaderOutput, 29 | NumOutput, 30 | ObjOutput, 31 | StrOutput, 32 | TableOutput, 33 | }; 34 | -------------------------------------------------------------------------------- /store/slice/num.ts: -------------------------------------------------------------------------------- 1 | import type { AnyState } from "../../types.ts"; 2 | import type { BaseSchema } from "../types.ts"; 3 | 4 | export interface NumOutput extends BaseSchema { 5 | schema: "num"; 6 | initialState: number; 7 | set: (v: number) => (s: S) => void; 8 | increment: (by?: number) => (s: S) => void; 9 | decrement: (by?: number) => (s: S) => void; 10 | reset: () => (s: S) => void; 11 | select: (s: S) => number; 12 | } 13 | 14 | export function createNum({ 15 | name, 16 | initialState = 0, 17 | }: { 18 | name: keyof S; 19 | initialState?: number; 20 | }): NumOutput { 21 | return { 22 | name: name as string, 23 | schema: "num", 24 | initialState, 25 | set: (value) => (state) => { 26 | // deno-lint-ignore no-explicit-any 27 | (state as any)[name] = value; 28 | }, 29 | increment: (by = 1) => (state) => { 30 | // deno-lint-ignore no-explicit-any 31 | (state as any)[name] += by; 32 | }, 33 | decrement: (by = 1) => (state) => { 34 | // deno-lint-ignore no-explicit-any 35 | (state as any)[name] -= by; 36 | }, 37 | reset: () => (state) => { 38 | // deno-lint-ignore no-explicit-any 39 | (state as any)[name] = initialState; 40 | }, 41 | select: (state) => { 42 | // deno-lint-ignore no-explicit-any 43 | return (state as any)[name]; 44 | }, 45 | }; 46 | } 47 | 48 | export function num(initialState?: number) { 49 | return (name: string) => createNum({ name, initialState }); 50 | } 51 | -------------------------------------------------------------------------------- /store/slice/obj.test.ts: -------------------------------------------------------------------------------- 1 | import { asserts, describe, it } from "../../test.ts"; 2 | import { configureStore, updateStore } from "../mod.ts"; 3 | 4 | import { createObj } from "./obj.ts"; 5 | const tests = describe("createObj()"); 6 | 7 | export interface ICurrentUser { 8 | username: string; 9 | userId: number; 10 | isadmin: boolean; 11 | roles: string[]; 12 | } 13 | 14 | const NAME = "currentUser"; 15 | const crtInitialState = { 16 | username: "", 17 | userId: 0, 18 | isadmin: false, 19 | roles: [], 20 | }; 21 | 22 | const slice = createObj({ 23 | name: NAME, 24 | initialState: crtInitialState, 25 | }); 26 | 27 | it(tests, "sets up an obj", async () => { 28 | const store = configureStore({ 29 | initialState: { 30 | [NAME]: crtInitialState, 31 | }, 32 | }); 33 | 34 | await store.run(function* () { 35 | yield* updateStore( 36 | slice.set({ 37 | username: "bob", 38 | userId: 1, 39 | isadmin: true, 40 | roles: ["admin", "user"], 41 | }), 42 | ); 43 | }); 44 | 45 | asserts.assertEquals(store.getState()["currentUser"], { 46 | username: "bob", 47 | userId: 1, 48 | isadmin: true, 49 | roles: ["admin", "user"], 50 | }); 51 | 52 | await store.run(function* () { 53 | yield* updateStore(slice.update({ key: "username", value: "alice" })); 54 | }); 55 | 56 | asserts.assertEquals(store.getState()["currentUser"], { 57 | username: "alice", 58 | userId: 1, 59 | isadmin: true, 60 | roles: ["admin", "user"], 61 | }); 62 | 63 | await store.run(function* () { 64 | yield* updateStore( 65 | slice.update({ key: "roles", value: ["admin", "superuser"] }), 66 | ); 67 | }); 68 | 69 | asserts.assertEquals(store.getState()["currentUser"], { 70 | username: "alice", 71 | userId: 1, 72 | isadmin: true, 73 | roles: ["admin", "superuser"], 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /store/slice/obj.ts: -------------------------------------------------------------------------------- 1 | import type { AnyState } from "../../types.ts"; 2 | import type { BaseSchema } from "../types.ts"; 3 | 4 | export interface ObjOutput 5 | extends BaseSchema { 6 | schema: "obj"; 7 | initialState: V; 8 | set: (v: V) => (s: S) => void; 9 | reset: () => (s: S) => void; 10 | update:

(prop: { key: P; value: V[P] }) => (s: S) => void; 11 | select: (s: S) => V; 12 | } 13 | 14 | export function createObj({ 15 | name, 16 | initialState, 17 | }: { 18 | name: keyof S; 19 | initialState: V; 20 | }): ObjOutput { 21 | return { 22 | schema: "obj", 23 | name: name as string, 24 | initialState, 25 | set: (value) => (state) => { 26 | // deno-lint-ignore no-explicit-any 27 | (state as any)[name] = value; 28 | }, 29 | reset: () => (state) => { 30 | // deno-lint-ignore no-explicit-any 31 | (state as any)[name] = initialState; 32 | }, 33 | update:

(prop: { key: P; value: V[P] }) => (state) => { 34 | // deno-lint-ignore no-explicit-any 35 | (state as any)[name][prop.key] = prop.value; 36 | }, 37 | select: (state) => { 38 | // deno-lint-ignore no-explicit-any 39 | return (state as any)[name]; 40 | }, 41 | }; 42 | } 43 | 44 | export function obj(initialState: V) { 45 | return (name: string) => createObj({ name, initialState }); 46 | } 47 | -------------------------------------------------------------------------------- /store/slice/str.ts: -------------------------------------------------------------------------------- 1 | import type { AnyState } from "../../types.ts"; 2 | import type { BaseSchema } from "../types.ts"; 3 | 4 | export interface StrOutput 5 | extends BaseSchema { 6 | schema: "str"; 7 | initialState: string; 8 | set: (v: string) => (s: S) => void; 9 | reset: () => (s: S) => void; 10 | select: (s: S) => string; 11 | } 12 | 13 | export function createStr({ 14 | name, 15 | initialState = "", 16 | }: { 17 | name: keyof S; 18 | initialState?: string; 19 | }): StrOutput { 20 | return { 21 | schema: "str", 22 | name: name as string, 23 | initialState, 24 | set: (value) => (state) => { 25 | // deno-lint-ignore no-explicit-any 26 | (state as any)[name] = value; 27 | }, 28 | reset: () => (state) => { 29 | // deno-lint-ignore no-explicit-any 30 | (state as any)[name] = initialState; 31 | }, 32 | select: (state) => { 33 | // deno-lint-ignore no-explicit-any 34 | return (state as any)[name]; 35 | }, 36 | }; 37 | } 38 | 39 | export function str(initialState?: string) { 40 | return (name: string) => createStr({ name, initialState }); 41 | } 42 | -------------------------------------------------------------------------------- /store/slice/table.test.ts: -------------------------------------------------------------------------------- 1 | import { asserts, describe, it } from "../../test.ts"; 2 | import { configureStore } from "../store.ts"; 3 | import { updateStore } from "../fx.ts"; 4 | import { createTable, table } from "./table.ts"; 5 | 6 | const tests = describe("createTable()"); 7 | 8 | type TUser = { 9 | id: number; 10 | user: string; 11 | }; 12 | 13 | const NAME = "table"; 14 | const empty = { id: 0, user: "" }; 15 | const slice = createTable({ 16 | name: NAME, 17 | empty, 18 | }); 19 | 20 | const initialState = { 21 | [NAME]: slice.initialState, 22 | }; 23 | 24 | const first = { id: 1, user: "A" }; 25 | const second = { id: 2, user: "B" }; 26 | const third = { id: 3, user: "C" }; 27 | 28 | it(tests, "sets up a table", async () => { 29 | const store = configureStore({ 30 | initialState, 31 | }); 32 | 33 | await store.run(function* () { 34 | yield* updateStore(slice.set({ [first.id]: first })); 35 | }); 36 | asserts.assertEquals(store.getState()[NAME], { [first.id]: first }); 37 | }); 38 | 39 | it(tests, "adds a row", async () => { 40 | const store = configureStore({ 41 | initialState, 42 | }); 43 | 44 | await store.run(function* () { 45 | yield* updateStore(slice.set({ [second.id]: second })); 46 | }); 47 | asserts.assertEquals(store.getState()[NAME], { 2: second }); 48 | }); 49 | 50 | it(tests, "removes a row", async () => { 51 | const store = configureStore({ 52 | initialState: { 53 | ...initialState, 54 | [NAME]: { [first.id]: first, [second.id]: second } as Record< 55 | string, 56 | TUser 57 | >, 58 | }, 59 | }); 60 | 61 | await store.run(function* () { 62 | yield* updateStore(slice.remove(["1"])); 63 | }); 64 | asserts.assertEquals(store.getState()[NAME], { [second.id]: second }); 65 | }); 66 | 67 | it(tests, "updates a row", async () => { 68 | const store = configureStore({ 69 | initialState, 70 | }); 71 | await store.run(function* () { 72 | const updated = { id: second.id, user: "BB" }; 73 | yield* updateStore(slice.patch({ [updated.id]: updated })); 74 | }); 75 | asserts.assertEquals(store.getState()[NAME], { 76 | [second.id]: { ...second, user: "BB" }, 77 | }); 78 | }); 79 | 80 | it(tests, "gets a row", async () => { 81 | const store = configureStore({ 82 | initialState, 83 | }); 84 | await store.run(function* () { 85 | yield* updateStore( 86 | slice.add({ [first.id]: first, [second.id]: second, [third.id]: third }), 87 | ); 88 | }); 89 | 90 | const row = slice.selectById(store.getState(), { id: "2" }); 91 | asserts.assertEquals(row, second); 92 | }); 93 | 94 | it(tests, "when the record doesnt exist, it returns empty record", () => { 95 | const store = configureStore({ 96 | initialState, 97 | }); 98 | 99 | const row = slice.selectById(store.getState(), { id: "2" }); 100 | asserts.assertEquals(row, empty); 101 | }); 102 | 103 | it(tests, "gets all rows", async () => { 104 | const store = configureStore({ 105 | initialState, 106 | }); 107 | const data = { [first.id]: first, [second.id]: second, [third.id]: third }; 108 | await store.run(function* () { 109 | yield* updateStore(slice.add(data)); 110 | }); 111 | asserts.assertEquals(store.getState()[NAME], data); 112 | }); 113 | 114 | // checking types of `result` here 115 | it(tests, "with empty", async () => { 116 | const tbl = table({ empty: first })("users"); 117 | const store = configureStore({ 118 | initialState, 119 | }); 120 | 121 | asserts.assertEquals(tbl.empty, first); 122 | await store.run(function* () { 123 | yield* updateStore(tbl.set({ [first.id]: first })); 124 | }); 125 | asserts.assertEquals(tbl.selectTable(store.getState()), { 126 | [first.id]: first, 127 | }); 128 | const result = tbl.selectById(store.getState(), { id: 1 }); 129 | asserts.assertEquals(result, first); 130 | }); 131 | 132 | // checking types of `result` here 133 | it(tests, "with no empty", async () => { 134 | const tbl = table()("users"); 135 | const store = configureStore({ 136 | initialState, 137 | }); 138 | 139 | asserts.assertEquals(tbl.empty, undefined); 140 | await store.run(function* () { 141 | yield* updateStore(tbl.set({ [first.id]: first })); 142 | }); 143 | asserts.assertEquals(tbl.selectTable(store.getState()), { 144 | [first.id]: first, 145 | }); 146 | const result = tbl.selectById(store.getState(), { id: 1 }); 147 | asserts.assertEquals(result, first); 148 | }); 149 | -------------------------------------------------------------------------------- /store/store.ts: -------------------------------------------------------------------------------- 1 | import { createContext, createScope, createSignal, Ok, Scope } from "effection"; 2 | import { enablePatches, produceWithPatches } from "immer"; 3 | import { ActionContext, API_ACTION_PREFIX, emit } from "../action.ts"; 4 | import { BaseMiddleware, compose } from "../compose.ts"; 5 | import { StoreContext, StoreUpdateContext } from "./context.ts"; 6 | import { createRun } from "./run.ts"; 7 | import type { AnyAction, AnyState, Next } from "../types.ts"; 8 | import type { FxStore, Listener, StoreUpdater, UpdaterCtx } from "./types.ts"; 9 | const stubMsg = "This is merely a stub, not implemented"; 10 | 11 | let id = 0; 12 | 13 | // https://github.com/reduxjs/redux/blob/4a6d2fb227ba119d3498a43fab8f53fe008be64c/src/createStore.ts#L344 14 | function observable() { 15 | return { 16 | subscribe: (_observer: unknown) => { 17 | throw new Error(stubMsg); 18 | }, 19 | [Symbol.observable]() { 20 | return this; 21 | }, 22 | }; 23 | } 24 | 25 | export interface CreateStore { 26 | scope?: Scope; 27 | initialState: S; 28 | middleware?: BaseMiddleware>[]; 29 | } 30 | 31 | export const IdContext = createContext("starfx:id", 0); 32 | 33 | export function createStore({ 34 | initialState, 35 | scope: initScope, 36 | middleware = [], 37 | }: CreateStore): FxStore { 38 | let scope: Scope; 39 | if (initScope) { 40 | scope = initScope; 41 | } else { 42 | const tuple = createScope(); 43 | scope = tuple[0]; 44 | } 45 | 46 | let state = initialState; 47 | const listeners = new Set(); 48 | enablePatches(); 49 | 50 | const signal = createSignal(); 51 | scope.set(ActionContext, signal); 52 | scope.set(IdContext, id++); 53 | 54 | function getScope() { 55 | return scope; 56 | } 57 | 58 | function getState() { 59 | return state; 60 | } 61 | 62 | function subscribe(fn: Listener) { 63 | listeners.add(fn); 64 | return () => listeners.delete(fn); 65 | } 66 | 67 | function* updateMdw(ctx: UpdaterCtx, next: Next) { 68 | const upds: StoreUpdater[] = []; 69 | 70 | if (Array.isArray(ctx.updater)) { 71 | upds.push(...ctx.updater); 72 | } else { 73 | upds.push(ctx.updater); 74 | } 75 | 76 | const [nextState, patches, _] = produceWithPatches(getState(), (draft) => { 77 | // TODO: check for return value inside updater 78 | // deno-lint-ignore no-explicit-any 79 | upds.forEach((updater) => updater(draft as any)); 80 | }); 81 | ctx.patches = patches; 82 | 83 | // set the state! 84 | state = nextState; 85 | 86 | yield* next(); 87 | } 88 | 89 | function* logMdw(ctx: UpdaterCtx, next: Next) { 90 | dispatch({ 91 | type: `${API_ACTION_PREFIX}store`, 92 | payload: ctx, 93 | }); 94 | yield* next(); 95 | } 96 | 97 | function* notifyChannelMdw(_: UpdaterCtx, next: Next) { 98 | const chan = yield* StoreUpdateContext; 99 | yield* chan.send(); 100 | yield* next(); 101 | } 102 | 103 | function* notifyListenersMdw(_: UpdaterCtx, next: Next) { 104 | listeners.forEach((f) => f()); 105 | yield* next(); 106 | } 107 | 108 | function createUpdater() { 109 | const fn = compose>([ 110 | updateMdw, 111 | ...middleware, 112 | logMdw, 113 | notifyChannelMdw, 114 | notifyListenersMdw, 115 | ]); 116 | 117 | return fn; 118 | } 119 | 120 | const mdw = createUpdater(); 121 | function* update(updater: StoreUpdater | StoreUpdater[]) { 122 | const ctx = { 123 | updater, 124 | patches: [], 125 | result: Ok(undefined), 126 | }; 127 | 128 | yield* mdw(ctx); 129 | 130 | if (!ctx.result.ok) { 131 | dispatch({ 132 | type: `${API_ACTION_PREFIX}store`, 133 | payload: ctx.result.error, 134 | }); 135 | } 136 | 137 | return ctx; 138 | } 139 | 140 | function dispatch(action: AnyAction | AnyAction[]) { 141 | emit({ signal, action }); 142 | } 143 | 144 | function getInitialState() { 145 | return initialState; 146 | } 147 | 148 | function* reset(ignoreList: (keyof S)[] = []) { 149 | return yield* update((s) => { 150 | const keep = ignoreList.reduce( 151 | (acc, key) => { 152 | acc[key] = s[key]; 153 | return acc; 154 | }, 155 | { ...initialState }, 156 | ); 157 | 158 | Object.keys(s).forEach((key: keyof S) => { 159 | s[key] = keep[key]; 160 | }); 161 | }); 162 | } 163 | 164 | const store = { 165 | getScope, 166 | getState, 167 | subscribe, 168 | update, 169 | reset, 170 | run: createRun(scope), 171 | // instead of actions relating to store mutation, they 172 | // refer to pieces of business logic -- that can also mutate state 173 | dispatch, 174 | // stubs so `react-redux` is happy 175 | // deno-lint-ignore no-explicit-any 176 | replaceReducer( 177 | _nextReducer: (_s: S, _a: AnyAction) => void, 178 | ): void { 179 | throw new Error(stubMsg); 180 | }, 181 | getInitialState, 182 | [Symbol.observable]: observable, 183 | }; 184 | 185 | // deno-lint-ignore no-explicit-any 186 | store.getScope().set(StoreContext, store as any); 187 | return store; 188 | } 189 | 190 | /** 191 | * @deprecated use {@link createStore} 192 | */ 193 | export const configureStore = createStore; 194 | -------------------------------------------------------------------------------- /store/types.ts: -------------------------------------------------------------------------------- 1 | import type { Patch } from "immer"; 2 | import type { Operation, Scope } from "effection"; 3 | import type { AnyAction, AnyState } from "../types.ts"; 4 | import type { LoaderOutput } from "./slice/loaders.ts"; 5 | import type { TableOutput } from "./slice/table.ts"; 6 | import { BaseCtx } from "../mod.ts"; 7 | import { createRun } from "./run.ts"; 8 | 9 | export type StoreUpdater = (s: S) => S | void; 10 | 11 | export type Listener = () => void; 12 | 13 | export interface UpdaterCtx extends BaseCtx { 14 | updater: StoreUpdater | StoreUpdater[]; 15 | patches: Patch[]; 16 | } 17 | 18 | declare global { 19 | interface SymbolConstructor { 20 | readonly observable: symbol; 21 | } 22 | } 23 | 24 | export interface BaseSchema { 25 | initialState: TOutput; 26 | schema: string; 27 | name: string; 28 | } 29 | 30 | export type Output }> = { 31 | [key in keyof O]: O[key]["initialState"]; 32 | }; 33 | 34 | export interface FxMap { 35 | loaders: (s: string) => LoaderOutput; 36 | cache: (s: string) => TableOutput; 37 | [key: string]: (name: string) => BaseSchema; 38 | } 39 | 40 | export type FxSchema = 41 | & { 42 | [key in keyof O]: ReturnType; 43 | } 44 | & { update: FxStore["update"] }; 45 | 46 | export interface FxStore { 47 | getScope: () => Scope; 48 | getState: () => S; 49 | subscribe: (fn: Listener) => () => void; 50 | update: (u: StoreUpdater | StoreUpdater[]) => Operation>; 51 | reset: (ignoreList?: (keyof S)[]) => Operation>; 52 | run: ReturnType; 53 | // deno-lint-ignore no-explicit-any 54 | dispatch: (a: AnyAction | AnyAction[]) => any; 55 | replaceReducer: (r: (s: S, a: AnyAction) => S) => void; 56 | getInitialState: () => S; 57 | // deno-lint-ignore no-explicit-any 58 | [Symbol.observable]: () => any; 59 | } 60 | 61 | export interface QueryState { 62 | cache: TableOutput["initialState"]; 63 | loaders: LoaderOutput["initialState"]; 64 | } 65 | -------------------------------------------------------------------------------- /supervisor.ts: -------------------------------------------------------------------------------- 1 | import { createAction, take } from "./action.ts"; 2 | import { call, Callable, Operation, race, sleep, spawn, Task } from "effection"; 3 | import type { ActionWithPayload, AnyAction } from "./types.ts"; 4 | import type { CreateActionPayload } from "./query/mod.ts"; 5 | import { getIdFromAction } from "./action.ts"; 6 | 7 | const MS = 1000; 8 | const SECONDS = 1 * MS; 9 | const MINUTES = 60 * SECONDS; 10 | 11 | export function poll(parentTimer: number = 5 * SECONDS, cancelType?: string) { 12 | return function* poller( 13 | actionType: string, 14 | op: (action: AnyAction) => Operation, 15 | ): Operation { 16 | const cancel = cancelType || actionType; 17 | function* fire(action: { type: string }, timer: number) { 18 | while (true) { 19 | yield* op(action); 20 | yield* sleep(timer); 21 | } 22 | } 23 | 24 | while (true) { 25 | const action = yield* take<{ timer?: number }>(actionType); 26 | const timer = action.payload?.timer || parentTimer; 27 | yield* race([fire(action, timer), take(`${cancel}`) as Operation]); 28 | } 29 | }; 30 | } 31 | 32 | type ClearTimerPayload = string | { type: string; payload: { key: string } }; 33 | 34 | export const clearTimers = createAction< 35 | ClearTimerPayload | ClearTimerPayload[] 36 | >("clear-timers"); 37 | 38 | /** 39 | * timer() will create a cache timer for each `key` inside 40 | * of a starfx api endpoint. `key` is a hash of the action type and payload. 41 | * 42 | * Why do we want this? If we have an api endpoint to fetch a single app: `fetchApp({ id: 1 })` 43 | * if we don't set a timer per key then all calls to `fetchApp` will be on a timer. 44 | * So if we call `fetchApp({ id: 1 })` and then `fetchApp({ id: 2 })` if we use a normal 45 | * cache timer then the second call will not send an http request. 46 | */ 47 | export function timer(timer: number = 5 * MINUTES) { 48 | return function* onTimer( 49 | actionType: string, 50 | op: (action: AnyAction) => Callable, 51 | ) { 52 | const map: { [key: string]: Task } = {}; 53 | 54 | function* activate(action: ActionWithPayload) { 55 | yield* call(() => op(action)); 56 | const idA = getIdFromAction(action); 57 | 58 | const matchFn = ( 59 | act: ActionWithPayload, 60 | ) => { 61 | if (act.type !== `${clearTimers}`) return false; 62 | if (!act.payload) return false; 63 | const ids = Array.isArray(act.payload) ? act.payload : [act.payload]; 64 | return ids.some((id) => { 65 | if (id === "*") { 66 | return true; 67 | } 68 | if (typeof id === "string") { 69 | return idA === id; 70 | } else { 71 | return idA === getIdFromAction(id); 72 | } 73 | }); 74 | }; 75 | yield* race([sleep(timer), take(matchFn as any) as Operation]); 76 | 77 | delete map[action.payload.key]; 78 | } 79 | 80 | while (true) { 81 | const action = yield* take(`${actionType}`); 82 | const key = action.payload.key; 83 | if (!map[key]) { 84 | const task = yield* spawn(function* () { 85 | yield* activate(action); 86 | }); 87 | map[key] = task; 88 | } 89 | } 90 | }; 91 | } 92 | -------------------------------------------------------------------------------- /test.ts: -------------------------------------------------------------------------------- 1 | export { 2 | afterAll, 3 | beforeAll, 4 | beforeEach, 5 | describe, 6 | it, 7 | } from "jsr:@std/testing/bdd"; 8 | export * as assertType from "jsr:@std/testing/types"; 9 | export { assert } from "jsr:@std/assert"; 10 | export * as asserts from "jsr:@std/assert"; 11 | export { expect } from "jsr:@std/expect"; 12 | export { install, mock } from "https://deno.land/x/mock_fetch@0.3.0/mod.ts"; 13 | 14 | export function isLikeSelector(selector: unknown) { 15 | return ( 16 | selector !== null && 17 | typeof selector === "object" && 18 | Reflect.getPrototypeOf(selector) === Object.prototype && 19 | Reflect.ownKeys(selector).length > 0 20 | ); 21 | } 22 | 23 | export const CIRCULAR_SELECTOR = new Error("Encountered a circular selector"); 24 | 25 | export function assertLike( 26 | lhs: Record, 27 | selector: Record, 28 | circular = new Set(), 29 | ) { 30 | if (circular.has(selector)) { 31 | throw CIRCULAR_SELECTOR; 32 | } 33 | 34 | circular.add(selector); 35 | 36 | if (lhs === null || typeof lhs !== "object") { 37 | return lhs; 38 | } 39 | 40 | const comparable: Record = {}; 41 | for (const [key, rhs] of Object.entries(selector)) { 42 | if (isLikeSelector(rhs)) { 43 | comparable[key] = assertLike(Reflect.get(lhs, key), rhs, circular); 44 | } else { 45 | comparable[key] = Reflect.get(lhs, key); 46 | } 47 | } 48 | 49 | return comparable; 50 | } 51 | -------------------------------------------------------------------------------- /test/action.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "../test.ts"; 2 | import { createAction } from "../mod.ts"; 3 | 4 | const tests = describe("createAction()"); 5 | 6 | it(tests, "should return action type when stringified", () => { 7 | const undo = createAction("UNDO"); 8 | expect(`UNDO`).toEqual(`${undo}`); 9 | }); 10 | 11 | it(tests, "return object with type", () => { 12 | const undo = createAction("UNDO"); 13 | expect(undo()).toEqual({ type: `UNDO`, payload: undefined }); 14 | }); 15 | -------------------------------------------------------------------------------- /test/batch.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "../test.ts"; 2 | import { 3 | createBatchMdw, 4 | createSchema, 5 | createStore, 6 | slice, 7 | } from "../store/mod.ts"; 8 | import { parallel } from "../mod.ts"; 9 | 10 | const batch = describe("batch mdw"); 11 | 12 | it(batch, "should batch notify subscribers based on mdw", async () => { 13 | const [schema, initialState] = createSchema({ 14 | cache: slice.table({ empty: {} }), 15 | loaders: slice.loaders(), 16 | }); 17 | const store = createStore({ 18 | initialState, 19 | middleware: [createBatchMdw(queueMicrotask)], 20 | }); 21 | let counter = 0; 22 | store.subscribe(() => { 23 | counter += 1; 24 | }); 25 | await store.run(function* () { 26 | const group: any = yield* parallel([ 27 | schema.update(schema.cache.add({ "1": "one" })), 28 | schema.update(schema.cache.add({ "2": "two" })), 29 | schema.update(schema.cache.add({ "3": "three" })), 30 | schema.update(schema.cache.add({ "4": "four" })), 31 | schema.update(schema.cache.add({ "5": "five" })), 32 | schema.update(schema.cache.add({ "6": "six" })), 33 | ]); 34 | yield* group; 35 | // make sure it will still notify subscribers after batched round 36 | yield* schema.update(schema.cache.add({ "7": "seven" })); 37 | }); 38 | expect(counter).toBe(2); 39 | }); 40 | -------------------------------------------------------------------------------- /test/compose.test.ts: -------------------------------------------------------------------------------- 1 | import { asserts, describe, expect, it } from "../test.ts"; 2 | import { compose, Err, Ok, Result, run, safe, sleep } from "../mod.ts"; 3 | 4 | const tests = describe("compose()"); 5 | 6 | it(tests, "should compose middleware", async () => { 7 | const mdw = compose<{ one: string; three: string; result: Result }>([ 8 | function* (ctx, next) { 9 | ctx.one = "two"; 10 | yield* next(); 11 | }, 12 | function* (ctx, next) { 13 | ctx.three = "four"; 14 | yield* next(); 15 | }, 16 | ]); 17 | const actual = await run(function* () { 18 | const ctx = { one: "", three: "", result: Ok(void 0) }; 19 | yield* mdw(ctx); 20 | return ctx; 21 | }); 22 | 23 | const expected = { 24 | // we should see the mutation 25 | one: "two", 26 | three: "four", 27 | result: Ok(void 0), 28 | }; 29 | expect(actual).toEqual(expected); 30 | }); 31 | 32 | it(tests, "order of execution", async () => { 33 | const mdw = compose<{ actual: string; result: Result }>([ 34 | function* (ctx, next) { 35 | ctx.actual += "a"; 36 | yield* next(); 37 | ctx.actual += "g"; 38 | }, 39 | function* (ctx, next) { 40 | yield* sleep(10); 41 | ctx.actual += "b"; 42 | yield* next(); 43 | yield* sleep(10); 44 | ctx.actual += "f"; 45 | }, 46 | function* (ctx, next) { 47 | ctx.actual += "c"; 48 | yield* next(); 49 | ctx.actual += "d"; 50 | yield* sleep(30); 51 | ctx.actual += "e"; 52 | }, 53 | ]); 54 | 55 | const actual = await run(function* () { 56 | const ctx = { actual: "", result: Ok(void 0) }; 57 | yield* mdw(ctx); 58 | return ctx; 59 | }); 60 | const expected = { 61 | actual: "abcdefg", 62 | result: Ok(void 0), 63 | }; 64 | expect(actual).toEqual(expected); 65 | }); 66 | 67 | it(tests, "when error is discovered, it should throw", async () => { 68 | const err = new Error("boom"); 69 | const mdw = compose([ 70 | function* (_, next) { 71 | yield* next(); 72 | asserts.fail(); 73 | }, 74 | function* (_, next) { 75 | yield* next(); 76 | throw err; 77 | }, 78 | ]); 79 | const actual = await run(function* () { 80 | const ctx = {}; 81 | const result = yield* safe(() => mdw(ctx)); 82 | return result; 83 | }); 84 | 85 | const expected = Err(err); 86 | expect(actual).toEqual(expected); 87 | }); 88 | -------------------------------------------------------------------------------- /test/create-store.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "../test.ts"; 2 | import { createStore, select } from "../store/mod.ts"; 3 | import { call } from "../mod.ts"; 4 | 5 | const tests = describe("createStore()"); 6 | 7 | interface TestState { 8 | user: { id: string }; 9 | } 10 | 11 | it(tests, "should be able to grab values from store", async () => { 12 | let actual; 13 | const store = createStore({ initialState: { user: { id: "1" } } }); 14 | await store.run(function* () { 15 | actual = yield* select((s: TestState) => s.user); 16 | }); 17 | expect(actual).toEqual({ id: "1" }); 18 | }); 19 | 20 | it(tests, "should be able to grab store from a nested call", async () => { 21 | let actual; 22 | const store = createStore({ initialState: { user: { id: "2" } } }); 23 | await store.run(function* () { 24 | actual = yield* call(function* () { 25 | return yield* select((s: TestState) => s.user); 26 | }); 27 | }); 28 | expect(actual).toEqual({ id: "2" }); 29 | }); 30 | -------------------------------------------------------------------------------- /test/parallel.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "../test.ts"; 2 | import type { Operation, Result } from "../mod.ts"; 3 | import { each, Err, Ok, parallel, run, sleep, spawn } from "../mod.ts"; 4 | 5 | const test = describe("parallel()"); 6 | 7 | interface Defer { 8 | promise: Promise; 9 | resolve: (t: T) => void; 10 | reject: (t: Error) => void; 11 | } 12 | 13 | function defer(): Defer { 14 | let resolve: (t: T) => void = () => {}; 15 | let reject: (t: Error) => void = () => {}; 16 | const promise = new Promise((res, rej) => { 17 | resolve = res; 18 | reject = rej; 19 | }); 20 | 21 | return { resolve, reject, promise }; 22 | } 23 | 24 | it( 25 | test, 26 | "should return an immediate channel with results as they are completed", 27 | async () => { 28 | const result = await run(function* () { 29 | const results = yield* parallel([ 30 | function* () { 31 | yield* sleep(20); 32 | return "second"; 33 | }, 34 | function* () { 35 | yield* sleep(10); 36 | return "first"; 37 | }, 38 | ]); 39 | 40 | const res: Result[] = []; 41 | for (const val of yield* each(results.immediate)) { 42 | res.push(val); 43 | yield* each.next(); 44 | } 45 | 46 | yield* results; 47 | return res; 48 | }); 49 | 50 | expect(result).toEqual([Ok("first"), Ok("second")]); 51 | }, 52 | ); 53 | 54 | it( 55 | test, 56 | "should return a sequence channel with results preserving array order as results", 57 | async () => { 58 | const result = await run(function* () { 59 | const results = yield* parallel([ 60 | function* () { 61 | yield* sleep(20); 62 | return "second"; 63 | }, 64 | function* () { 65 | yield* sleep(10); 66 | return "first"; 67 | }, 68 | ]); 69 | 70 | const res: Result[] = []; 71 | for (const val of yield* each(results.sequence)) { 72 | res.push(val); 73 | yield* each.next(); 74 | } 75 | 76 | yield* results; 77 | return res; 78 | }); 79 | 80 | expect(result).toEqual([Ok("second"), Ok("first")]); 81 | }, 82 | ); 83 | 84 | it( 85 | test, 86 | "should return all the result in an array, preserving order", 87 | async () => { 88 | const result = await run(function* () { 89 | const para = yield* parallel([ 90 | function* () { 91 | yield* sleep(20); 92 | return "second"; 93 | }, 94 | function* () { 95 | yield* sleep(10); 96 | return "first"; 97 | }, 98 | ]); 99 | 100 | return yield* para; 101 | }); 102 | 103 | expect(result).toEqual([Ok("second"), Ok("first")]); 104 | }, 105 | ); 106 | 107 | it(test, "should return empty array", async () => { 108 | let actual; 109 | await run(function* (): Operation { 110 | const results = yield* parallel([]); 111 | actual = yield* results; 112 | }); 113 | expect(actual).toEqual([]); 114 | }); 115 | 116 | it(test, "should resolve all async items", async () => { 117 | const two = defer(); 118 | 119 | function* one() { 120 | yield* sleep(5); 121 | return 1; 122 | } 123 | 124 | const result = await run(function* () { 125 | yield* spawn(function* () { 126 | yield* sleep(15); 127 | two.resolve(2); 128 | }); 129 | const results = yield* parallel([one, () => two.promise]); 130 | return yield* results; 131 | }); 132 | 133 | expect(result).toEqual([Ok(1), Ok(2)]); 134 | }); 135 | 136 | it(test, "should stop all operations when there is an error", async () => { 137 | let actual: Result[] = []; 138 | const one = defer(); 139 | const two = defer(); 140 | 141 | function* genFn() { 142 | try { 143 | const results = yield* parallel([() => one.promise, () => two.promise]); 144 | actual = yield* results; 145 | } catch (_) { 146 | actual = [Err(new Error("should not get hit"))]; 147 | } 148 | } 149 | 150 | const err = new Error("error"); 151 | one.reject(err); 152 | two.resolve(1); 153 | 154 | await run(genFn); 155 | 156 | const expected = [Err(err), Ok(1)]; 157 | expect(actual).toEqual(expected); 158 | }); 159 | -------------------------------------------------------------------------------- /test/put.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "../test.ts"; 2 | import { ActionContext, each, put, sleep, spawn, take } from "../mod.ts"; 3 | import { createStore } from "../store/mod.ts"; 4 | 5 | const putTests = describe("put()"); 6 | 7 | it(putTests, "should send actions through channel", async () => { 8 | const actual: string[] = []; 9 | 10 | function* genFn(arg: string) { 11 | const actions = yield* ActionContext; 12 | const task = yield* spawn(function* () { 13 | for (const action of yield* each(actions)) { 14 | actual.push(action.type); 15 | yield* each.next(); 16 | } 17 | }); 18 | 19 | yield* put({ 20 | type: arg, 21 | }); 22 | yield* put({ 23 | type: "2", 24 | }); 25 | actions.close(); 26 | yield* task; 27 | } 28 | 29 | const store = createStore({ initialState: {} }); 30 | await store.run(() => genFn("arg")); 31 | 32 | const expected = ["arg", "2"]; 33 | expect(actual).toEqual(expected); 34 | }); 35 | 36 | it(putTests, "should handle nested puts", async () => { 37 | const actual: string[] = []; 38 | 39 | function* genA() { 40 | yield* put({ 41 | type: "a", 42 | }); 43 | actual.push("put a"); 44 | } 45 | 46 | function* genB() { 47 | yield* take(["a"]); 48 | yield* put({ 49 | type: "b", 50 | }); 51 | actual.push("put b"); 52 | } 53 | 54 | function* root() { 55 | yield* spawn(genB); 56 | yield* spawn(genA); 57 | } 58 | 59 | const store = createStore({ initialState: {} }); 60 | await store.run(() => root()); 61 | 62 | const expected = ["put b", "put a"]; 63 | expect(actual).toEqual(expected); 64 | }); 65 | 66 | it( 67 | putTests, 68 | "should not cause stack overflow when puts are emitted while dispatching saga", 69 | async () => { 70 | function* root() { 71 | for (let i = 0; i < 10_000; i += 1) { 72 | yield* put({ type: "test" }); 73 | } 74 | yield* sleep(0); 75 | } 76 | 77 | const store = createStore({ initialState: {} }); 78 | await store.run(() => root()); 79 | expect(true).toBe(true); 80 | }, 81 | ); 82 | 83 | it( 84 | putTests, 85 | "should not miss `put` that was emitted directly after creating a task (caused by another `put`)", 86 | async () => { 87 | const actual: string[] = []; 88 | 89 | function* root() { 90 | yield* spawn(function* firstspawn() { 91 | yield* sleep(10); 92 | yield* put({ type: "c" }); 93 | yield* put({ type: "do not miss" }); 94 | }); 95 | 96 | yield* take("c"); 97 | 98 | const tsk = yield* spawn(function* () { 99 | yield* take("do not miss"); 100 | actual.push("didn't get missed"); 101 | }); 102 | yield* tsk; 103 | } 104 | 105 | const store = createStore({ initialState: {} }); 106 | await store.run(root); 107 | const expected = ["didn't get missed"]; 108 | expect(actual).toEqual(expected); 109 | }, 110 | ); 111 | -------------------------------------------------------------------------------- /test/react.test.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { asserts, describe, it } from "../test.ts"; 3 | import { Provider } from "../react.ts"; 4 | import { createSchema, createStore, slice } from "../store/mod.ts"; 5 | 6 | const tests = describe("react"); 7 | 8 | // typing test 9 | it(tests, () => { 10 | const [schema, initialState] = createSchema({ 11 | cache: slice.table(), 12 | loaders: slice.loaders(), 13 | }); 14 | const store = createStore({ initialState }); 15 | React.createElement(Provider, { 16 | schema, 17 | store, 18 | children: React.createElement("div"), 19 | }); 20 | asserts.equal(true, true); 21 | }); 22 | -------------------------------------------------------------------------------- /test/safe.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "../test.ts"; 2 | import { call, run } from "../mod.ts"; 3 | 4 | const tests = describe("call()"); 5 | 6 | it(tests, "should call the generator function", async () => { 7 | expect.assertions(1); 8 | function* me() { 9 | return "valid"; 10 | } 11 | 12 | await run(function* () { 13 | const result = yield* call(me); 14 | expect(result).toBe("valid"); 15 | }); 16 | }); 17 | 18 | it(tests, "should return an Err()", async () => { 19 | expect.assertions(1); 20 | const err = new Error("bang!"); 21 | function* me() { 22 | throw err; 23 | } 24 | 25 | await run(function* () { 26 | try { 27 | yield* call(me); 28 | } catch (err) { 29 | expect(err).toEqual(err); 30 | } 31 | }); 32 | }); 33 | 34 | it(tests, "should call a promise", async () => { 35 | expect.assertions(1); 36 | const me = () => 37 | new Promise((resolve) => { 38 | setTimeout(() => { 39 | resolve("valid"); 40 | }, 10); 41 | }); 42 | 43 | await run(function* () { 44 | const result = yield* call(me); 45 | expect(result).toEqual("valid"); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /test/schema.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "../test.ts"; 2 | import { createSchema, createStore, select, slice } from "../store/mod.ts"; 3 | 4 | const tests = describe("createSchema()"); 5 | 6 | interface User { 7 | id: string; 8 | name: string; 9 | } 10 | interface UserWithRoles extends User { 11 | roles: string[]; 12 | } 13 | 14 | const emptyUser = { id: "", name: "" }; 15 | 16 | it(tests, "default schema", async () => { 17 | const [schema, initialState] = createSchema(); 18 | const store = createStore({ initialState }); 19 | expect(store.getState()).toEqual({ 20 | cache: {}, 21 | loaders: {}, 22 | }); 23 | 24 | await store.run(function* () { 25 | yield* schema.update(schema.loaders.start({ id: "1" })); 26 | yield* schema.update(schema.cache.add({ "1": true })); 27 | }); 28 | 29 | expect(schema.cache.selectTable(store.getState())).toEqual({ 30 | "1": true, 31 | }); 32 | expect( 33 | schema.loaders.selectById(store.getState(), { id: "1" }).status, 34 | "loading", 35 | ); 36 | }); 37 | 38 | it(tests, "general types and functionality", async () => { 39 | expect.assertions(8); 40 | const [db, initialState] = createSchema({ 41 | users: slice.table({ 42 | initialState: { "1": { id: "1", name: "wow" } }, 43 | empty: emptyUser, 44 | }), 45 | token: slice.str(), 46 | counter: slice.num(), 47 | dev: slice.any(false), 48 | currentUser: slice.obj(emptyUser), 49 | cache: slice.table({ empty: {} }), 50 | loaders: slice.loaders(), 51 | }); 52 | const store = createStore({ initialState }); 53 | 54 | expect(store.getState()).toEqual({ 55 | users: { "1": { id: "1", name: "wow" } }, 56 | token: "", 57 | counter: 0, 58 | dev: false, 59 | currentUser: { id: "", name: "" }, 60 | cache: {}, 61 | loaders: {}, 62 | }); 63 | const userMap = db.users.selectTable(store.getState()); 64 | expect(userMap).toEqual({ "1": { id: "1", name: "wow" } }); 65 | 66 | await store.run(function* () { 67 | yield* db.update([ 68 | db.users.add({ "2": { id: "2", name: "bob" } }), 69 | db.users.patch({ "1": { name: "zzz" } }), 70 | ]); 71 | 72 | const users = yield* select(db.users.selectTable); 73 | expect(users).toEqual({ 74 | "1": { id: "1", name: "zzz" }, 75 | "2": { id: "2", name: "bob" }, 76 | }); 77 | 78 | yield* db.update(db.counter.increment()); 79 | const counter = yield* select(db.counter.select); 80 | expect(counter).toBe(1); 81 | 82 | yield* db.update(db.currentUser.update({ key: "name", value: "vvv" })); 83 | const curUser = yield* select(db.currentUser.select); 84 | expect(curUser).toEqual({ id: "", name: "vvv" }); 85 | 86 | yield* db.update(db.loaders.start({ id: "fetch-users" })); 87 | const fetchLoader = yield* select(db.loaders.selectById, { 88 | id: "fetch-users", 89 | }); 90 | expect(fetchLoader.id).toBe("fetch-users"); 91 | expect(fetchLoader.status).toBe("loading"); 92 | expect(fetchLoader.lastRun).not.toBe(0); 93 | }); 94 | }); 95 | 96 | it(tests, "can work with a nested object", async () => { 97 | expect.assertions(3); 98 | const [db, initialState] = createSchema({ 99 | currentUser: slice.obj({ id: "", name: "", roles: [] }), 100 | cache: slice.table({ empty: {} }), 101 | loaders: slice.loaders(), 102 | }); 103 | const store = createStore({ initialState }); 104 | await store.run(function* () { 105 | yield* db.update(db.currentUser.update({ key: "name", value: "vvv" })); 106 | const curUser = yield* select(db.currentUser.select); 107 | expect(curUser).toEqual({ id: "", name: "vvv", roles: [] }); 108 | 109 | yield* db.update(db.currentUser.update({ key: "roles", value: ["admin"] })); 110 | const curUser2 = yield* select(db.currentUser.select); 111 | expect(curUser2).toEqual({ id: "", name: "vvv", roles: ["admin"] }); 112 | 113 | yield* db.update( 114 | db.currentUser.update({ key: "roles", value: ["admin", "users"] }), 115 | ); 116 | const curUser3 = yield* select(db.currentUser.select); 117 | expect(curUser3).toEqual({ 118 | id: "", 119 | name: "vvv", 120 | roles: ["admin", "users"], 121 | }); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /test/store.test.ts: -------------------------------------------------------------------------------- 1 | import { createScope, Operation, parallel, put, Result, take } from "../mod.ts"; 2 | import { 3 | createStore, 4 | StoreContext, 5 | StoreUpdateContext, 6 | updateStore, 7 | } from "../store/mod.ts"; 8 | import { describe, expect, it } from "../test.ts"; 9 | 10 | const tests = describe("store"); 11 | 12 | interface User { 13 | id: string; 14 | name: string; 15 | } 16 | 17 | interface State { 18 | users: { [key: string]: User }; 19 | theme: string; 20 | token: string; 21 | dev: boolean; 22 | } 23 | 24 | function findUserById(state: State, { id }: { id: string }) { 25 | return state.users[id]; 26 | } 27 | 28 | function findUsers(state: State) { 29 | return state.users; 30 | } 31 | 32 | interface UpdateUserProps { 33 | id: string; 34 | name: string; 35 | } 36 | 37 | const updateUser = ({ id, name }: UpdateUserProps) => (state: State) => { 38 | // use selectors to find the data you want to mutate 39 | const user = findUserById(state, { id }); 40 | user.name = name; 41 | 42 | // different ways to update a `zod` record 43 | const users = findUsers(state); 44 | users[id].name = name; 45 | 46 | delete users[2]; 47 | users[3] = { id: "", name: "" }; 48 | 49 | // or mutate state directly without selectors 50 | state.dev = true; 51 | }; 52 | 53 | it( 54 | tests, 55 | "update store and receives update from channel `StoreUpdateContext`", 56 | async () => { 57 | expect.assertions(1); 58 | const [scope] = createScope(); 59 | const initialState: Partial = { 60 | users: { 1: { id: "1", name: "testing" }, 2: { id: "2", name: "wow" } }, 61 | dev: false, 62 | }; 63 | createStore({ scope, initialState }); 64 | let store; 65 | await scope.run(function* (): Operation[]> { 66 | const result = yield* parallel([ 67 | function* () { 68 | store = yield* StoreContext; 69 | const chan = yield* StoreUpdateContext; 70 | const msgList = yield* chan.subscribe(); 71 | yield* msgList.next(); 72 | }, 73 | function* () { 74 | yield* updateStore(updateUser({ id: "1", name: "eric" })); 75 | }, 76 | ]); 77 | return yield* result; 78 | }); 79 | expect(store!.getState()).toEqual({ 80 | users: { 1: { id: "1", name: "eric" }, 3: { id: "", name: "" } }, 81 | dev: true, 82 | }); 83 | }, 84 | ); 85 | 86 | it(tests, "update store and receives update from `subscribe()`", async () => { 87 | expect.assertions(1); 88 | const initialState: Partial = { 89 | users: { 1: { id: "1", name: "testing" }, 2: { id: "2", name: "wow" } }, 90 | dev: false, 91 | theme: "", 92 | token: "", 93 | }; 94 | const store = createStore({ initialState }); 95 | 96 | store.subscribe(() => { 97 | expect(store.getState()).toEqual({ 98 | users: { 1: { id: "1", name: "eric" }, 3: { id: "", name: "" } }, 99 | dev: true, 100 | theme: "", 101 | token: "", 102 | }); 103 | }); 104 | 105 | await store.run(function* () { 106 | yield* updateStore(updateUser({ id: "1", name: "eric" })); 107 | }); 108 | }); 109 | 110 | it(tests, "emit Action and update store", async () => { 111 | expect.assertions(1); 112 | const initialState: Partial = { 113 | users: { 1: { id: "1", name: "testing" }, 2: { id: "2", name: "wow" } }, 114 | dev: false, 115 | theme: "", 116 | token: "", 117 | }; 118 | const store = createStore({ initialState }); 119 | 120 | await store.run(function* (): Operation { 121 | const result = yield* parallel([ 122 | function* (): Operation { 123 | const action = yield* take("UPDATE_USER"); 124 | yield* updateStore(updateUser(action.payload)); 125 | }, 126 | function* () { 127 | yield* put({ type: "UPDATE_USER", payload: { id: "1", name: "eric" } }); 128 | }, 129 | ]); 130 | yield* result; 131 | }); 132 | 133 | expect(store.getState()).toEqual({ 134 | users: { 1: { id: "1", name: "eric" }, 3: { id: "", name: "" } }, 135 | theme: "", 136 | token: "", 137 | dev: true, 138 | }); 139 | }); 140 | 141 | it(tests, "resets store", async () => { 142 | expect.assertions(2); 143 | const initialState: Partial = { 144 | users: { 1: { id: "1", name: "testing" }, 2: { id: "2", name: "wow" } }, 145 | dev: false, 146 | theme: "", 147 | token: "", 148 | }; 149 | const store = createStore({ initialState }); 150 | 151 | await store.run(function* () { 152 | yield* store.update((s) => { 153 | s.users = { 3: { id: "3", name: "hehe" } }; 154 | s.dev = true; 155 | s.theme = "darkness"; 156 | }); 157 | }); 158 | 159 | expect(store.getState()).toEqual({ 160 | users: { 3: { id: "3", name: "hehe" } }, 161 | theme: "darkness", 162 | token: "", 163 | dev: true, 164 | }); 165 | 166 | await store.run(store.reset(["users"])); 167 | 168 | expect(store.getState()).toEqual({ 169 | users: { 3: { id: "3", name: "hehe" } }, 170 | dev: false, 171 | theme: "", 172 | token: "", 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /test/supervisor.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "../test.ts"; 2 | import { 3 | call, 4 | Operation, 5 | run, 6 | spawn, 7 | supervise, 8 | superviseBackoff, 9 | } from "../mod.ts"; 10 | import { ActionWithPayload } from "../types.ts"; 11 | import { take } from "../action.ts"; 12 | import { API_ACTION_PREFIX } from "../action.ts"; 13 | 14 | const test = describe("supervise()"); 15 | 16 | describe("superviseBackoff", () => { 17 | it("should increase number exponentially", () => { 18 | const actual: number[] = []; 19 | for (let i = 1; i < 15; i += 1) { 20 | actual.push(superviseBackoff(i)); 21 | } 22 | expect(actual).toEqual([ 23 | 20, 24 | 40, 25 | 80, 26 | 160, 27 | 320, 28 | 640, 29 | 1280, 30 | 2560, 31 | 5120, 32 | 10240, 33 | -1, 34 | -1, 35 | -1, 36 | -1, 37 | ]); 38 | }); 39 | }); 40 | 41 | type LogAction = ActionWithPayload<{ message: string }>; 42 | 43 | it(test, "should recover with backoff pressure", async () => { 44 | const err = console.error; 45 | console.error = () => {}; 46 | 47 | const actions: LogAction[] = []; 48 | const backoff = (attempt: number) => { 49 | if (attempt === 4) return -1; 50 | return attempt; 51 | }; 52 | 53 | await run(function* () { 54 | function* op(): Operation { 55 | throw new Error("boom!"); 56 | } 57 | yield* spawn(function* () { 58 | while (true) { 59 | const action = yield* take("*"); 60 | actions.push(action); 61 | } 62 | }); 63 | yield* call(supervise(op, backoff)); 64 | }); 65 | 66 | expect(actions.length).toEqual(3); 67 | expect(actions[0].type).toEqual(`${API_ACTION_PREFIX}supervise`); 68 | expect(actions[0].meta).toEqual( 69 | "Exception caught, waiting 1ms before restarting operation", 70 | ); 71 | expect(actions[1].type).toEqual(`${API_ACTION_PREFIX}supervise`); 72 | expect(actions[1].meta).toEqual( 73 | "Exception caught, waiting 2ms before restarting operation", 74 | ); 75 | expect(actions[2].type).toEqual(`${API_ACTION_PREFIX}supervise`); 76 | expect(actions[2].meta).toEqual( 77 | "Exception caught, waiting 3ms before restarting operation", 78 | ); 79 | 80 | console.error = err; 81 | }); 82 | -------------------------------------------------------------------------------- /test/take-helper.test.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from "effection"; 2 | import { describe, expect, it } from "../test.ts"; 3 | import { createStore } from "../store/mod.ts"; 4 | import type { AnyAction } from "../mod.ts"; 5 | import { sleep, take, takeEvery, takeLatest, takeLeading } from "../mod.ts"; 6 | 7 | const testEvery = describe("takeEvery()"); 8 | const testLatest = describe("takeLatest()"); 9 | const testLeading = describe("takeLeading()"); 10 | 11 | it(testLatest, "should cancel previous tasks and only use latest", async () => { 12 | const actual: string[] = []; 13 | function* worker(action: AnyAction) { 14 | if (action.payload !== "3") { 15 | yield* sleep(3000); 16 | } 17 | actual.push(action.payload); 18 | } 19 | 20 | function* root() { 21 | const task = yield* spawn(() => takeLatest("ACTION", worker)); 22 | yield* take("CANCEL_WATCHER"); 23 | yield* task.halt(); 24 | } 25 | const store = createStore({ initialState: {} }); 26 | const task = store.run(root); 27 | 28 | store.dispatch({ type: "ACTION", payload: "1" }); 29 | store.dispatch({ type: "ACTION", payload: "2" }); 30 | store.dispatch({ type: "ACTION", payload: "3" }); 31 | store.dispatch({ type: "CANCEL_WATCHER" }); 32 | 33 | await task; 34 | 35 | expect(actual).toEqual(["3"]); 36 | }); 37 | 38 | it(testLeading, "should keep first action and discard the rest", async () => { 39 | let called = 0; 40 | const actual: string[] = []; 41 | function* worker(action: AnyAction) { 42 | called += 1; 43 | yield* sleep(100); 44 | actual.push(action.payload); 45 | } 46 | 47 | function* root() { 48 | const task = yield* spawn(() => takeLeading("ACTION", worker)); 49 | yield* sleep(150); 50 | yield* task.halt(); 51 | } 52 | const store = createStore({ initialState: {} }); 53 | const task = store.run(root); 54 | 55 | store.dispatch({ type: "ACTION", payload: "1" }); 56 | store.dispatch({ type: "ACTION", payload: "2" }); 57 | store.dispatch({ type: "ACTION", payload: "3" }); 58 | 59 | await task; 60 | 61 | expect(actual).toEqual(["1"]); 62 | expect(called).toEqual(1); 63 | }); 64 | 65 | it(testEvery, "should receive all actions", async () => { 66 | const loop = 10; 67 | const actual: string[][] = []; 68 | 69 | function* root() { 70 | const task = yield* spawn(() => 71 | takeEvery("ACTION", (action) => worker("a1", "a2", action)) 72 | ); 73 | yield* take("CANCEL_WATCHER"); 74 | yield* task.halt(); 75 | } 76 | 77 | // deno-lint-ignore require-yield 78 | function* worker(arg1: string, arg2: string, action: AnyAction) { 79 | actual.push([arg1, arg2, action.payload]); 80 | } 81 | 82 | const store = createStore({ initialState: {} }); 83 | const task = store.run(root); 84 | 85 | for (let i = 1; i <= loop / 2; i += 1) { 86 | store.dispatch({ 87 | type: "ACTION", 88 | payload: i, 89 | }); 90 | } 91 | 92 | // no further task should be forked after this 93 | store.dispatch({ 94 | type: "CANCEL_WATCHER", 95 | }); 96 | 97 | for (let i = loop / 2 + 1; i <= loop; i += 1) { 98 | store.dispatch({ 99 | type: "ACTION", 100 | payload: i, 101 | }); 102 | } 103 | await task; 104 | 105 | expect(actual).toEqual([ 106 | ["a1", "a2", 1], 107 | ["a1", "a2", 2], 108 | ["a1", "a2", 3], 109 | ["a1", "a2", 4], 110 | ["a1", "a2", 5], 111 | ]); 112 | }); 113 | -------------------------------------------------------------------------------- /test/take.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "../test.ts"; 2 | import type { AnyAction } from "../mod.ts"; 3 | import { put, sleep, spawn, take } from "../mod.ts"; 4 | import { createStore } from "../store/mod.ts"; 5 | 6 | const takeTests = describe("take()"); 7 | 8 | it( 9 | takeTests, 10 | "a put should complete before more `take` are added and then consumed automatically", 11 | async () => { 12 | const actual: AnyAction[] = []; 13 | 14 | function* channelFn() { 15 | yield* sleep(10); 16 | yield* put({ type: "action-1", payload: 1 }); 17 | yield* put({ type: "action-1", payload: 2 }); 18 | } 19 | 20 | function* root() { 21 | yield* spawn(channelFn); 22 | 23 | actual.push(yield* take("action-1")); 24 | actual.push(yield* take("action-1")); 25 | } 26 | 27 | const store = createStore({ initialState: {} }); 28 | await store.run(root); 29 | 30 | expect(actual).toEqual([ 31 | { type: "action-1", payload: 1 }, 32 | { type: "action-1", payload: 2 }, 33 | ]); 34 | }, 35 | ); 36 | 37 | it(takeTests, "take from default channel", async () => { 38 | function* channelFn() { 39 | yield* sleep(10); 40 | yield* put({ type: "action-*" }); 41 | yield* put({ type: "action-1" }); 42 | yield* put({ type: "action-2" }); 43 | yield* put({ type: "unnoticeable-action" }); 44 | yield* put({ 45 | type: "", 46 | payload: { 47 | isAction: true, 48 | }, 49 | }); 50 | yield* put({ 51 | type: "", 52 | payload: { 53 | isMixedWithPredicate: true, 54 | }, 55 | }); 56 | yield* put({ 57 | type: "action-3", 58 | }); 59 | } 60 | 61 | const actual: AnyAction[] = []; 62 | function* genFn() { 63 | yield* spawn(channelFn); 64 | 65 | try { 66 | actual.push(yield* take("*")); // take all actions 67 | actual.push(yield* take("action-1")); // take only actions of type 'action-1' 68 | actual.push(yield* take(["action-2", "action-2222"])); // take either type 69 | actual.push(yield* take((a: AnyAction) => a.payload?.isAction)); // take if match predicate 70 | actual.push( 71 | yield* take([ 72 | "action-3", 73 | (a: AnyAction) => a.payload?.isMixedWithPredicate, 74 | ]), 75 | ); // take if match any from the mixed array 76 | actual.push( 77 | yield* take([ 78 | "action-3", 79 | (a: AnyAction) => a.payload?.isMixedWithPredicate, 80 | ]), 81 | ); // take if match any from the mixed array 82 | } finally { 83 | actual.push({ type: "auto ended" }); 84 | } 85 | } 86 | 87 | const store = createStore({ initialState: {} }); 88 | await store.run(genFn); 89 | 90 | const expected = [ 91 | { 92 | type: "action-*", 93 | }, 94 | { 95 | type: "action-1", 96 | }, 97 | { 98 | type: "action-2", 99 | }, 100 | { 101 | type: "", 102 | payload: { 103 | isAction: true, 104 | }, 105 | }, 106 | { 107 | type: "", 108 | payload: { 109 | isMixedWithPredicate: true, 110 | }, 111 | }, 112 | { 113 | type: "action-3", 114 | }, 115 | { type: "auto ended" }, 116 | ]; 117 | expect(actual).toEqual(expected); 118 | }); 119 | -------------------------------------------------------------------------------- /test/timer.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "../test.ts"; 2 | import { clearTimers, put, run, sleep, spawn, timer } from "../mod.ts"; 3 | 4 | const tests = describe("timer()"); 5 | 6 | it(tests, "should call thunk at most once every timer", async () => { 7 | let called = 0; 8 | await run(function* () { 9 | yield* spawn(function* () { 10 | yield* timer(10)("ACTION", function* () { 11 | called += 1; 12 | }); 13 | }); 14 | yield* put({ type: "ACTION", payload: { key: "my-key" } }); 15 | yield* sleep(1); 16 | yield* put({ type: "ACTION", payload: { key: "my-key" } }); 17 | yield* sleep(20); 18 | yield* put({ type: "ACTION", payload: { key: "my-key" } }); 19 | yield* sleep(50); 20 | }); 21 | expect(called).toBe(2); 22 | }); 23 | 24 | it(tests, "should let user cancel timer", async () => { 25 | let called = 0; 26 | await run(function* () { 27 | yield* spawn(function* () { 28 | yield* timer(10_000)("ACTION", function* () { 29 | called += 1; 30 | }); 31 | }); 32 | yield* put({ type: "ACTION", payload: { key: "my-key" } }); 33 | yield* sleep(1); 34 | yield* put(clearTimers(["my-key"])); 35 | yield* put({ type: "ACTION", payload: { key: "my-key" } }); 36 | }); 37 | expect(called).toBe(2); 38 | }); 39 | 40 | it(tests, "should let user cancel timer with action obj", async () => { 41 | let called = 0; 42 | await run(function* () { 43 | yield* spawn(function* () { 44 | yield* timer(10_000)("ACTION", function* () { 45 | called += 1; 46 | }); 47 | }); 48 | const action = { type: "ACTION", payload: { key: "my-key" } }; 49 | yield* put(action); 50 | yield* sleep(1); 51 | yield* put(clearTimers(action)); 52 | yield* put(action); 53 | }); 54 | expect(called).toBe(2); 55 | }); 56 | 57 | it(tests, "should let user cancel timer with wildcard", async () => { 58 | let called = 0; 59 | await run(function* () { 60 | yield* spawn(function* () { 61 | yield* timer(10_000)("ACTION", function* () { 62 | called += 1; 63 | }); 64 | }); 65 | yield* spawn(function* () { 66 | yield* timer(10_000)("WOW", function* () { 67 | called += 1; 68 | }); 69 | }); 70 | yield* put({ type: "ACTION", payload: { key: "my-key" } }); 71 | yield* put({ type: "WOW", payload: { key: "my-key" } }); 72 | yield* sleep(1); 73 | yield* put(clearTimers(["*"])); 74 | yield* put({ type: "ACTION", payload: { key: "my-key" } }); 75 | yield* put({ type: "WOW", payload: { key: "my-key" } }); 76 | }); 77 | expect(called).toBe(4); 78 | }); 79 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | import type { Instruction, Operation } from "effection"; 2 | 3 | export interface Computation { 4 | // deno-lint-ignore no-explicit-any 5 | [Symbol.iterator](): Iterator; 6 | } 7 | 8 | export type Next = () => Operation; 9 | 10 | export type IdProp = string | number; 11 | export type LoadingStatus = "loading" | "success" | "error" | "idle"; 12 | export interface LoaderItemState< 13 | M extends Record = Record, 14 | > { 15 | id: string; 16 | status: LoadingStatus; 17 | message: string; 18 | lastRun: number; 19 | lastSuccess: number; 20 | meta: M; 21 | } 22 | 23 | export interface LoaderState 24 | extends LoaderItemState { 25 | isIdle: boolean; 26 | isLoading: boolean; 27 | isError: boolean; 28 | isSuccess: boolean; 29 | isInitialLoading: boolean; 30 | } 31 | 32 | export type LoaderPayload = 33 | & Pick, "id"> 34 | & Partial, "message" | "meta">>; 35 | 36 | // deno-lint-ignore no-explicit-any 37 | export type AnyState = Record; 38 | 39 | // deno-lint-ignore no-explicit-any 40 | export interface Payload

{ 41 | payload: P; 42 | } 43 | 44 | export interface Action { 45 | type: string; 46 | } 47 | 48 | export type ActionFn = () => { toString: () => string }; 49 | export type ActionFnWithPayload

= (p: P) => { toString: () => string }; 50 | 51 | // https://github.com/redux-utilities/flux-standard-action 52 | export interface AnyAction extends Action { 53 | payload?: any; 54 | meta?: any; 55 | error?: boolean; 56 | } 57 | 58 | export interface ActionWithPayload

extends AnyAction { 59 | payload: P; 60 | } 61 | --------------------------------------------------------------------------------