From 213fb3045120573046f5136127f8254d17880b68 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Wed, 24 Sep 2025 23:46:04 -0500 Subject: [PATCH 01/35] store updater api --- examples/yjs/src/store/updater.ts | 32 +++++++++ package-lock.json | 10 ++- package.json | 10 +-- src/store/store.ts | 109 ++++++++++++++++++++---------- src/store/types.ts | 3 +- 5 files changed, 122 insertions(+), 42 deletions(-) create mode 100644 examples/yjs/src/store/updater.ts diff --git a/examples/yjs/src/store/updater.ts b/examples/yjs/src/store/updater.ts new file mode 100644 index 00000000..c6a98fd2 --- /dev/null +++ b/examples/yjs/src/store/updater.ts @@ -0,0 +1,32 @@ +import * as Y from "yjs"; +import type { AnyState, UpdaterCtx, Next, Operation } from "starfx"; + +export const defaultStoreUpdater = ( + setState: (state: S) => void, + getState: () => S +) => { + console.log("Creating Y.Doc"); + const ydoc = new Y.Doc({ autoLoad: true }); + const root = ydoc.getMap(); + + const data = new Y.Map(); + root.set("data", data); + data.set("items", new Y.Array()); + + root.observeDeep((events, transaction) => { + console.log("Y.Doc changed", { events, transaction }); + setState(root.toJSON() as S); + }); + + function* updateMdw(ctx: UpdaterCtx, next: Next) { + ydoc.transact(() => ctx.updater(root)); + console.log({ updater: ctx.updater }); + setState(root.toJSON() as S); + yield* next(); + } + + const initializeStore: () => Operation = function* () { + setState(root.toJSON() as S); + }; + return { updateMdw, initializeStore }; +}; diff --git a/package-lock.json b/package-lock.json index e8e66b37..cc1ca55e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "starfx", - "version": "0.14.4", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "starfx", - "version": "0.14.4", + "version": "0.15.0", "license": "MIT", "dependencies": { "effection": "^3.5.0", @@ -32,6 +32,12 @@ "react-redux": "9.x" }, "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, "react-redux": { "optional": true } diff --git a/package.json b/package.json index bea809b3..a1e0405d 100644 --- a/package.json +++ b/package.json @@ -3,16 +3,18 @@ "version": "0.15.0", "description": "A micro-mvc framework for react apps", "type": "module", - "files": ["/dist"], + "files": [ + "/dist" + ], "sideEffects": false, "main": "./dist/cjs/index.js", "types": "./dist/types/index.d.ts", "module": "./dist/esm/index.js", "scripts": { "test": "vitest --exclude examples", - "lint": "npx @biomejs/biome check --write", - "fmt": "npx @biomejs/biome check --write --linter-enabled=false", - "ci": "npx @biomejs/biome ci .", + "lint": "@biomejs/biome check --write", + "fmt": "@biomejs/biome check --write --linter-enabled=false", + "ci": "@biomejs/biome ci .", "template": "tsx ./api-type-template.mts", "build": "tsx ./build.mts" }, diff --git a/src/store/store.ts b/src/store/store.ts index a2f887ea..f9d1c659 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,9 +1,13 @@ import { - Ok, + type Operation, type Scope, + type Callable, + Ok, createContext, createScope, createSignal, + type Result, + type Task, } from "effection"; import { enablePatches, produceWithPatches } from "immer"; import { API_ACTION_PREFIX, ActionContext, emit } from "../action.js"; @@ -32,14 +36,54 @@ export interface CreateStore { scope?: Scope; initialState: S; middleware?: BaseMiddleware>[]; + setStoreUpdater?: ( + setState: (state: S) => void, + getState: () => S, + getInitialState?: () => S + ) => { + updateMdw: BaseMiddleware>; + initializeStore: Callable; + }; } export const IdContext = createContext("starfx:id", 0); -export function createStore({ +const defaultStoreUpdater = ( + setState: (state: S) => void, + getState: () => S +) => { + enablePatches(); + + function* updateMdw(ctx: UpdaterCtx, next: Next) { + const upds: StoreUpdater[] = []; + + if (Array.isArray(ctx.updater)) { + upds.push(...ctx.updater); + } else { + upds.push(ctx.updater); + } + + const [nextState, patches, _] = produceWithPatches(getState(), (draft) => { + // TODO: check for return value inside updater + upds.forEach((updater) => updater(draft as any)); + }); + ctx.patches = patches; + + // set the state! + setState(nextState); + + yield* next(); + } + + const initializeStore = function* () {}; + return { updateMdw, initializeStore }; +}; + +export function createStore({ initialState, scope: initScope, middleware = [], + setStoreUpdater = defaultStoreUpdater, }: CreateStore): FxStore { let scope: Scope; if (initScope) { @@ -51,7 +95,6 @@ export function createStore({ let state = initialState; const listeners = new Set(); - enablePatches(); const signal = createSignal(); scope.set(ActionContext, signal); @@ -65,31 +108,21 @@ export function createStore({ return state; } + function setState(newState: S) { + state = newState; + } + + function getInitialState() { + return initialState; + } + function subscribe(fn: Listener) { listeners.add(fn); return () => listeners.delete(fn); } - function* updateMdw(ctx: UpdaterCtx, next: Next) { - const upds: StoreUpdater[] = []; - - if (Array.isArray(ctx.updater)) { - upds.push(...ctx.updater); - } else { - upds.push(ctx.updater); - } - - const [nextState, patches, _] = produceWithPatches(getState(), (draft) => { - // TODO: check for return value inside updater - // deno-lint-ignore no-explicit-any - upds.forEach((updater) => updater(draft as any)); - }); - ctx.patches = patches; - - // set the state! - state = nextState; - - yield* next(); + function dispatch(action: AnyAction | AnyAction[]) { + emit({ signal, action }); } function* logMdw(ctx: UpdaterCtx, next: Next) { @@ -111,6 +144,11 @@ export function createStore({ yield* next(); } + const { updateMdw, initializeStore } = setStoreUpdater( + setState, + getState, + getInitialState + ); function createUpdater() { const fn = compose>([ updateMdw, @@ -143,14 +181,6 @@ export function createStore({ return ctx; } - function dispatch(action: AnyAction | AnyAction[]) { - emit({ signal, action }); - } - - function getInitialState() { - return initialState; - } - function* reset(ignoreList: (keyof S)[] = []) { return yield* update((s) => { const keep = ignoreList.reduce( @@ -158,7 +188,7 @@ export function createStore({ acc[key] = s[key]; return acc; }, - { ...initialState }, + { ...initialState } ); Object.keys(s).forEach((key: keyof S) => { @@ -167,20 +197,31 @@ export function createStore({ }); } + const run = createRun(scope); + + function initialize(op: Callable | Callable[]): Task[]> { + const ops = Array.isArray(op) + ? ([initializeStore].concat(op) as Callable[]) + : [initializeStore, op]; + return run(ops); + } + const store: FxStore = { getScope, getState, subscribe, + //@ts-expect-error + initialize, update, reset, - run: createRun(scope), + run, // instead of actions relating to store mutation, they // refer to pieces of business logic -- that can also mutate state dispatch, // stubs so `react-redux` is happy // deno-lint-ignore no-explicit-any replaceReducer( - _nextReducer: (_s: S, _a: AnyAction) => void, + _nextReducer: (_s: S, _a: AnyAction) => void ): void { throw new Error(stubMsg); }, diff --git a/src/store/types.ts b/src/store/types.ts index 5342cc81..b3ae10ab 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -48,11 +48,10 @@ export interface FxStore { update: (u: StoreUpdater | StoreUpdater[]) => Operation>; reset: (ignoreList?: (keyof S)[]) => Operation>; run: ReturnType; - // deno-lint-ignore no-explicit-any + initialize: ReturnType; //(op: Callable[]) => Operation[]>; dispatch: (a: AnyAction | AnyAction[]) => any; replaceReducer: (r: (s: S, a: AnyAction) => S) => void; getInitialState: () => S; - // deno-lint-ignore no-explicit-any [Symbol.observable]: () => any; } From fbfce31acaac59c359964991508eb933091913cc Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Wed, 24 Sep 2025 23:46:25 -0500 Subject: [PATCH 02/35] beginning of yjs example --- examples/yjs/.eslintrc.js | 23 +++++++++++ examples/yjs/.gitignore | 25 ++++++++++++ examples/yjs/index.html | 12 ++++++ examples/yjs/package.json | 30 ++++++++++++++ examples/yjs/src/App.css | 42 +++++++++++++++++++ examples/yjs/src/App.tsx | 32 +++++++++++++++ examples/yjs/src/index.css | 69 ++++++++++++++++++++++++++++++++ examples/yjs/src/main.tsx | 37 +++++++++++++++++ examples/yjs/src/store/schema.ts | 21 ++++++++++ examples/yjs/src/thunks.ts | 31 ++++++++++++++ examples/yjs/src/vite-env.d.ts | 1 + examples/yjs/tsconfig.json | 24 +++++++++++ examples/yjs/tsconfig.node.json | 10 +++++ examples/yjs/vite.config.ts | 7 ++++ 14 files changed, 364 insertions(+) create mode 100644 examples/yjs/.eslintrc.js create mode 100644 examples/yjs/.gitignore create mode 100644 examples/yjs/index.html create mode 100644 examples/yjs/package.json create mode 100644 examples/yjs/src/App.css create mode 100644 examples/yjs/src/App.tsx create mode 100644 examples/yjs/src/index.css create mode 100644 examples/yjs/src/main.tsx create mode 100644 examples/yjs/src/store/schema.ts create mode 100644 examples/yjs/src/thunks.ts create mode 100644 examples/yjs/src/vite-env.d.ts create mode 100644 examples/yjs/tsconfig.json create mode 100644 examples/yjs/tsconfig.node.json create mode 100644 examples/yjs/vite.config.ts diff --git a/examples/yjs/.eslintrc.js b/examples/yjs/.eslintrc.js new file mode 100644 index 00000000..a6d1076c --- /dev/null +++ b/examples/yjs/.eslintrc.js @@ -0,0 +1,23 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; +import { defineConfig, globalIgnores } from "eslint/config"; + +export default defineConfig([ + globalIgnores(["dist"]), + { + files: ["**/*.{ts,tsx}"], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs["recommended-latest"], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]); diff --git a/examples/yjs/.gitignore b/examples/yjs/.gitignore new file mode 100644 index 00000000..6a2885a0 --- /dev/null +++ b/examples/yjs/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +.yarn/** diff --git a/examples/yjs/index.html b/examples/yjs/index.html new file mode 100644 index 00000000..7530a970 --- /dev/null +++ b/examples/yjs/index.html @@ -0,0 +1,12 @@ + + + + + + vite + react + starfx + + +
+ + + diff --git a/examples/yjs/package.json b/examples/yjs/package.json new file mode 100644 index 00000000..ada88bd3 --- /dev/null +++ b/examples/yjs/package.json @@ -0,0 +1,30 @@ +{ + "name": "yjs-example", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "start": "vite --host 0.0.0.0", + "build": "tsc && vite build", + "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0", + "starfx": "file:../..", + "yjs": "^13.6.27" + }, + "devDependencies": { + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@typescript-eslint/eslint-plugin": "^5.57.1", + "@typescript-eslint/parser": "^5.57.1", + "@vitejs/plugin-react": "^4.6.0", + "eslint": "^8.38.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.3.4", + "typescript": "^5.3.2", + "vite": "^4.3.2" + } +} diff --git a/examples/yjs/src/App.css b/examples/yjs/src/App.css new file mode 100644 index 00000000..b9d355df --- /dev/null +++ b/examples/yjs/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/examples/yjs/src/App.tsx b/examples/yjs/src/App.tsx new file mode 100644 index 00000000..9bb484c3 --- /dev/null +++ b/examples/yjs/src/App.tsx @@ -0,0 +1,32 @@ +import { + TypedUseSelectorHook, + useDispatch, + useSelector as useSel, +} from "starfx/react"; +import "./App.css"; +import { AppState, createFolder, schema } from "./thunks.js"; + +const useSelector: TypedUseSelectorHook = useSel; + +function App({ id }: { id: string }) { + const dispatch = useDispatch(); + const state = useSelector((s) => s); + console.log("state", state); + // const user = useSelector((s) => schema.users.selectById(s, { id })); + // const userList = useSelector(schema.users.selectTableAsList); + return ( +
+
hi there, user.name
+ + {/* {userList.map((u) => { + return ( +
+ ({u.id}) {u.name}; age {u.age} +
+ ); + })} */} +
+ ); +} + +export default App; diff --git a/examples/yjs/src/index.css b/examples/yjs/src/index.css new file mode 100644 index 00000000..2c3fac68 --- /dev/null +++ b/examples/yjs/src/index.css @@ -0,0 +1,69 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/examples/yjs/src/main.tsx b/examples/yjs/src/main.tsx new file mode 100644 index 00000000..2e357601 --- /dev/null +++ b/examples/yjs/src/main.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { Provider } from "starfx/react"; +import { createStore, take } from "starfx"; +import { defaultStoreUpdater } from "./store/updater.js"; +import { thunks, initialState, schema } from "./thunks.js"; +import App from "./App.js"; +import "./index.css"; + +init(); + +function init() { + const store = createStore({ + initialState, + setStoreUpdater: defaultStoreUpdater, + }); + // makes `fx` available in devtools + (window as any).fx = store; + + store.initialize([ + function* logger() { + while (true) { + const action = yield* take("*"); + console.log("action", action); + } + }, + thunks.register, + ]); + + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + + + + ); +} diff --git a/examples/yjs/src/store/schema.ts b/examples/yjs/src/store/schema.ts new file mode 100644 index 00000000..6b809fdf --- /dev/null +++ b/examples/yjs/src/store/schema.ts @@ -0,0 +1,21 @@ +import { + type FxMap, + type FxSchema, + type StoreUpdater, + updateStore, +} from "starfx"; + +export function createSchema< + O extends FxMap, + S extends { [key in keyof O]: ReturnType["initialState"] } +>(): [FxSchema, S] { + const initialState = {} as S; + function* update(ups: StoreUpdater | StoreUpdater[]) { + return yield* updateStore(ups); + } + + const db = {} as FxSchema; + db.update = update; + + return [db, initialState]; +} diff --git a/examples/yjs/src/thunks.ts b/examples/yjs/src/thunks.ts new file mode 100644 index 00000000..865e37ef --- /dev/null +++ b/examples/yjs/src/thunks.ts @@ -0,0 +1,31 @@ +import { createThunks, mdw, StoreContext } from "starfx"; +import { createSchema } from "./store/schema.js"; + +export const [schema, initialState] = createSchema(); +export type AppState = typeof initialState; + +export const thunks = createThunks(); +// catch errors from task and logs them with extra info +thunks.use(mdw.err); +// where all the thunks get called in the middleware stack +thunks.use(thunks.routes()); +thunks.use(function* (ctx, next) { + console.log("last mdw in the stack"); + yield* next(); +}); + +export const createFolder = thunks.create("/users", function* (ctx, next) { + console.log("Creating folder", ctx); + yield* schema.update((root) => { + const yarray = root.get("data").get("items"); + yarray.push([ + { + id: Date.now().toString(), + name: "New folder", + children: [], + }, + ]); + }); + + yield* next(); +}); diff --git a/examples/yjs/src/vite-env.d.ts b/examples/yjs/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/examples/yjs/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/yjs/tsconfig.json b/examples/yjs/tsconfig.json new file mode 100644 index 00000000..c81ef9f3 --- /dev/null +++ b/examples/yjs/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/examples/yjs/tsconfig.node.json b/examples/yjs/tsconfig.node.json new file mode 100644 index 00000000..42872c59 --- /dev/null +++ b/examples/yjs/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/examples/yjs/vite.config.ts b/examples/yjs/vite.config.ts new file mode 100644 index 00000000..9cc50ead --- /dev/null +++ b/examples/yjs/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], +}); From 7883a04fab9d936071a2e789e6e512e355c47cec Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Thu, 25 Sep 2025 00:09:26 -0500 Subject: [PATCH 03/35] lint and fix command --- package.json | 10 ++++------ src/store/store.ts | 7 +++---- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index a1e0405d..29b47518 100644 --- a/package.json +++ b/package.json @@ -3,18 +3,16 @@ "version": "0.15.0", "description": "A micro-mvc framework for react apps", "type": "module", - "files": [ - "/dist" - ], + "files": ["/dist"], "sideEffects": false, "main": "./dist/cjs/index.js", "types": "./dist/types/index.d.ts", "module": "./dist/esm/index.js", "scripts": { "test": "vitest --exclude examples", - "lint": "@biomejs/biome check --write", - "fmt": "@biomejs/biome check --write --linter-enabled=false", - "ci": "@biomejs/biome ci .", + "lint": "biome check --write", + "fmt": "biome check --write --linter-enabled=false", + "ci": "biome ci .", "template": "tsx ./api-type-template.mts", "build": "tsx ./build.mts" }, diff --git a/src/store/store.ts b/src/store/store.ts index f9d1c659..861baf38 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,13 +1,12 @@ import { - type Operation, - type Scope, type Callable, Ok, + type Result, + type Scope, + type Task, createContext, createScope, createSignal, - type Result, - type Task, } from "effection"; import { enablePatches, produceWithPatches } from "immer"; import { API_ACTION_PREFIX, ActionContext, emit } from "../action.js"; From f523c988687b0b20c752a15b5eaec24555e34017 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Thu, 25 Sep 2025 00:14:35 -0500 Subject: [PATCH 04/35] fmt again? --- src/store/store.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/store/store.ts b/src/store/store.ts index 861baf38..50912da1 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -38,7 +38,7 @@ export interface CreateStore { setStoreUpdater?: ( setState: (state: S) => void, getState: () => S, - getInitialState?: () => S + getInitialState?: () => S, ) => { updateMdw: BaseMiddleware>; initializeStore: Callable; @@ -49,7 +49,7 @@ export const IdContext = createContext("starfx:id", 0); const defaultStoreUpdater = ( setState: (state: S) => void, - getState: () => S + getState: () => S, ) => { enablePatches(); @@ -146,7 +146,7 @@ export function createStore({ const { updateMdw, initializeStore } = setStoreUpdater( setState, getState, - getInitialState + getInitialState, ); function createUpdater() { const fn = compose>([ @@ -187,7 +187,7 @@ export function createStore({ acc[key] = s[key]; return acc; }, - { ...initialState } + { ...initialState }, ); Object.keys(s).forEach((key: keyof S) => { @@ -220,7 +220,7 @@ export function createStore({ // stubs so `react-redux` is happy // deno-lint-ignore no-explicit-any replaceReducer( - _nextReducer: (_s: S, _a: AnyAction) => void + _nextReducer: (_s: S, _a: AnyAction) => void, ): void { throw new Error(stubMsg); }, From 2fecfddd7040d97dc43ca4de446a40e980e37e6f Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Sat, 1 Nov 2025 16:51:21 -0500 Subject: [PATCH 05/35] use node 20 for publish --- .github/workflows/preview.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 03b57d9f..98d3e843 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -17,7 +17,7 @@ jobs: - name: setup node uses: actions/setup-node@v2 with: - node-version: 18.x + node-version: 20.x registry-url: https://registry.npmjs.com - name: install run: npm install diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e4ed42b..73124a7d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: - name: setup node uses: actions/setup-node@v2 with: - node-version: 18.x + node-version: 20.x registry-url: https://registry.npmjs.com - name: install run: npm install From f415a2a4e914249168474753496aa2aa55f29ffc Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Fri, 14 Nov 2025 22:48:15 -0600 Subject: [PATCH 06/35] try re-exporting full effection --- package.json | 14 +++++++++++++- src/effection.ts | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 src/effection.ts diff --git a/package.json b/package.json index 2b2c42b0..ecb2cb64 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,9 @@ "version": "0.16.0-beta.0", "description": "A micro-mvc framework for react apps", "type": "module", - "files": ["/dist"], + "files": [ + "/dist" + ], "sideEffects": false, "main": "./dist/cjs/index.js", "types": "./dist/types/index.d.ts", @@ -81,6 +83,16 @@ "default": "./dist/cjs/react.js" } }, + "./effection": { + "import": { + "types": "./dist/types/effection.d.ts", + "default": "./dist/esm/effection.js" + }, + "require": { + "types": "./dist/types/effection.d.ts", + "default": "./dist/cjs/effection.js" + } + }, "./package.json": "./package.json" } } diff --git a/src/effection.ts b/src/effection.ts new file mode 100644 index 00000000..4c1652ef --- /dev/null +++ b/src/effection.ts @@ -0,0 +1 @@ +export * from "effection"; From 8471bf12e1ee4076ab8bc9f91f61c9a1bd689d09 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Fri, 14 Nov 2025 23:50:31 -0600 Subject: [PATCH 07/35] lint --- package.json | 4 +--- src/store/store.ts | 10 +++++----- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index ecb2cb64..70b3b9ce 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,7 @@ "version": "0.16.0-beta.0", "description": "A micro-mvc framework for react apps", "type": "module", - "files": [ - "/dist" - ], + "files": ["/dist"], "sideEffects": false, "main": "./dist/cjs/index.js", "types": "./dist/types/index.d.ts", diff --git a/src/store/store.ts b/src/store/store.ts index 4ae4acc0..e2cc7a99 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -38,7 +38,7 @@ export interface CreateStore { setStoreUpdater?: ( setState: (state: S) => void, getState: () => S, - getInitialState?: () => S + getInitialState?: () => S, ) => { updateMdw: BaseMiddleware>; initializeStore: Callable; @@ -49,7 +49,7 @@ export const IdContext = createContext("starfx:id", 0); const defaultStoreUpdater = ( setState: (state: S) => void, - getState: () => S + getState: () => S, ) => { enablePatches(); @@ -146,7 +146,7 @@ export function createStore({ const { updateMdw, initializeStore } = setStoreUpdater( setState, getState, - getInitialState + getInitialState, ); function createUpdater() { const fn = compose>([ @@ -187,7 +187,7 @@ export function createStore({ acc[key] = s[key]; return acc; }, - { ...initialState } + { ...initialState }, ); Object.keys(s).forEach((key: keyof S) => { @@ -219,7 +219,7 @@ export function createStore({ dispatch, // stubs so `react-redux` is happy replaceReducer( - _nextReducer: (_s: S, _a: AnyAction) => void + _nextReducer: (_s: S, _a: AnyAction) => void, ): void { throw new Error(stubMsg); }, From 674fad0825d141b277c4c953fd84613a4ea98524 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Mon, 17 Nov 2025 22:29:00 -0600 Subject: [PATCH 08/35] lift signals --- src/store/store.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/store/store.ts b/src/store/store.ts index e2cc7a99..4808899c 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -7,6 +7,7 @@ import { createContext, createScope, createSignal, + lift, } from "effection"; import { enablePatches, produceWithPatches } from "immer"; import { API_ACTION_PREFIX, ActionContext, emit } from "../action.js"; @@ -125,7 +126,7 @@ export function createStore({ } function* logMdw(ctx: UpdaterCtx, next: Next) { - dispatch({ + yield* lift(dispatch)({ type: `${API_ACTION_PREFIX}store`, payload: ctx, }); @@ -148,8 +149,7 @@ export function createStore({ getState, getInitialState, ); - function createUpdater() { - const fn = compose>([ + const mdw = compose>([ updateMdw, ...middleware, logMdw, @@ -157,10 +157,6 @@ export function createStore({ notifyListenersMdw, ]); - return fn; - } - - const mdw = createUpdater(); function* update(updater: StoreUpdater | StoreUpdater[]) { const ctx = { updater, @@ -171,7 +167,7 @@ export function createStore({ yield* mdw(ctx); if (!ctx.result.ok) { - dispatch({ + yield* lift(dispatch)({ type: `${API_ACTION_PREFIX}store`, payload: ctx.result.error, }); From 806a08d6c821411301956d09128ecad81c48eecc Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Mon, 17 Nov 2025 22:29:58 -0600 Subject: [PATCH 09/35] initialize is sequential --- src/store/store.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/store/store.ts b/src/store/store.ts index 4808899c..c7907a86 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,7 +1,7 @@ import { type Callable, Ok, - type Result, + type Operation, type Scope, type Task, createContext, @@ -194,18 +194,17 @@ export function createStore({ const run = createRun(scope); - function initialize(op: Callable | Callable[]): Task[]> { - const ops = Array.isArray(op) - ? ([initializeStore].concat(op) as Callable[]) - : [initializeStore, op]; - return run(ops); + function initialize(op: () => Operation): Task { + return scope.run(function* (): Operation { + yield* initializeStore(); + yield* op(); + }); } const store: FxStore = { getScope, getState, subscribe, - //@ts-expect-error initialize, update, reset, From ef94711bbcaee8166b6ba94283f7470c54ed7931 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Mon, 17 Nov 2025 22:30:20 -0600 Subject: [PATCH 10/35] pass scope to initialize --- src/store/store.ts | 14 ++++++++------ src/store/types.ts | 4 ++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/store/store.ts b/src/store/store.ts index c7907a86..93abcf72 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -39,6 +39,7 @@ export interface CreateStore { setStoreUpdater?: ( setState: (state: S) => void, getState: () => S, + getScope: () => Scope, getInitialState?: () => S, ) => { updateMdw: BaseMiddleware>; @@ -147,15 +148,16 @@ export function createStore({ const { updateMdw, initializeStore } = setStoreUpdater( setState, getState, + getScope, getInitialState, ); const mdw = compose>([ - updateMdw, - ...middleware, - logMdw, - notifyChannelMdw, - notifyListenersMdw, - ]); + updateMdw, + ...middleware, + logMdw, + notifyChannelMdw, + notifyListenersMdw, + ]); function* update(updater: StoreUpdater | StoreUpdater[]) { const ctx = { diff --git a/src/store/types.ts b/src/store/types.ts index b3ae10ab..a3277a72 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -1,4 +1,4 @@ -import type { Operation, Scope } from "effection"; +import type { Operation, Scope, Task } from "effection"; import type { Patch } from "immer"; import type { BaseCtx } from "../index.js"; import type { AnyAction, AnyState } from "../types.js"; @@ -48,7 +48,7 @@ export interface FxStore { update: (u: StoreUpdater | StoreUpdater[]) => Operation>; reset: (ignoreList?: (keyof S)[]) => Operation>; run: ReturnType; - initialize: ReturnType; //(op: Callable[]) => Operation[]>; + initialize: (op: () => Operation) => Task; dispatch: (a: AnyAction | AnyAction[]) => any; replaceReducer: (r: (s: S, a: AnyAction) => S) => void; getInitialState: () => S; From a9b0abd475979a78ff3174b415744a5366cf7f6a Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Sat, 3 Jan 2026 00:44:20 -0600 Subject: [PATCH 11/35] store.manage() --- src/store/store.ts | 25 ++++++++++++++++ src/store/types.ts | 6 +++- src/test/store.test.ts | 67 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src/store/store.ts b/src/store/store.ts index 93abcf72..bb3dc284 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -7,11 +7,14 @@ import { createContext, createScope, createSignal, + each, lift, + suspend, } from "effection"; import { enablePatches, produceWithPatches } from "immer"; import { API_ACTION_PREFIX, ActionContext, emit } from "../action.js"; import { type BaseMiddleware, compose } from "../compose.js"; +import { createReplaySignal } from "../fx/replay-signal.js"; import type { AnyAction, AnyState, Next } from "../types.js"; import { StoreContext, StoreUpdateContext } from "./context.js"; import { createRun } from "./run.js"; @@ -98,6 +101,7 @@ export function createStore({ const listeners = new Set(); const signal = createSignal(); + const watch = createReplaySignal(); scope.set(ActionContext, signal); scope.set(IdContext, id++); @@ -194,11 +198,31 @@ export function createStore({ }); } + function manage(name: string, inputResource: Operation) { + const CustomContext = createContext(name); + function* manager() { + const providedResource = yield* inputResource; + scope.set(CustomContext, providedResource); + yield* suspend(); + } + watch.send(manager); + + // returns to the user so they can use this resource from + // anywhere this context is available + return CustomContext; + } + const run = createRun(scope); function initialize(op: () => Operation): Task { return scope.run(function* (): Operation { yield* initializeStore(); + yield* scope.spawn(function* () { + for (const watched of yield* each(watch)) { + yield* scope.spawn(watched); + yield* each.next(); + } + }); yield* op(); }); } @@ -208,6 +232,7 @@ export function createStore({ getState, subscribe, initialize, + manage, update, reset, run, diff --git a/src/store/types.ts b/src/store/types.ts index a3277a72..5cfd8e09 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -1,4 +1,4 @@ -import type { Operation, Scope, Task } from "effection"; +import type { Context, Operation, Scope, Task } from "effection"; import type { Patch } from "immer"; import type { BaseCtx } from "../index.js"; import type { AnyAction, AnyState } from "../types.js"; @@ -47,6 +47,10 @@ export interface FxStore { subscribe: (fn: Listener) => () => void; update: (u: StoreUpdater | StoreUpdater[]) => Operation>; reset: (ignoreList?: (keyof S)[]) => Operation>; + manage: ( + name: string, + resource: Operation, + ) => Context; run: ReturnType; initialize: (op: () => Operation) => Task; dispatch: (a: AnyAction | AnyAction[]) => any; diff --git a/src/test/store.test.ts b/src/test/store.test.ts index 6c75897b..d428f453 100644 --- a/src/test/store.test.ts +++ b/src/test/store.test.ts @@ -2,8 +2,10 @@ import { type Operation, type Result, createScope, + createThunks, parallel, put, + resource, sleep, take, } from "../index.js"; @@ -13,7 +15,7 @@ import { createStore, updateStore, } from "../store/index.js"; -import { expect, test } from "../test.js"; +import { describe, expect, test } from "../test.js"; interface User { id: string; @@ -180,3 +182,66 @@ test("resets store", async () => { token: "", }); }); + +describe(".manage", () => { + function guessAge(): Operation<{ guess: number; cumulative: null | number }> { + return resource(function* (provide) { + let cumulative = 0 as null | number; + try { + yield* provide({ + get guess() { + const random = Math.floor(Math.random() * 100); + if (cumulative !== null) cumulative += random; + return random; + }, + get cumulative() { + return cumulative; + }, + }); + } finally { + cumulative = null; + } + }); + } + + test("expects resource", async () => { + expect.assertions(1); + + const thunk = createThunks(); + thunk.use(thunk.routes()); + const store = createStore({ initialState: {} }); + const TestContext = store.manage("test:context", guessAge()); + store.initialize(thunk.register); + let acc = "bla"; + const action = thunk.create("/users", function* (payload, next) { + const c = yield* TestContext.get(); + if (c) acc += "b"; + next(); + }); + store.dispatch(action()); + + expect(acc).toBe("blab"); + }); + + test("uses resource", async () => { + expect.assertions(2); + + const thunk = createThunks(); + thunk.use(thunk.routes()); + const store = createStore({ initialState: {} }); + const TestContext = store.manage("test:context", guessAge()); + store.initialize(thunk.register); + let guess = 0; + let acc = 0; + const action = thunk.create("/users", function* (payload, next) { + const c = yield* TestContext.expect(); + guess += c.guess; + acc += c.cumulative ?? 0; + next(); + }); + store.dispatch(action()); + + expect(guess).toBeGreaterThan(0); + expect(acc).toEqual(guess); + }); +}); From 86f4e94d8e4b894dd080f6089b1b57747c21ddc6 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Fri, 23 Jan 2026 01:22:15 -0600 Subject: [PATCH 12/35] spike out createSchema 'holding' the storage --- examples/basic/src/main.tsx | 12 +- examples/tests-rtl/src/api.ts | 27 +-- examples/tests-rtl/src/store.ts | 9 +- examples/tests-rtl/tests/utils.tsx | 7 +- examples/vite-react/src/api.ts | 4 +- examples/vite-react/src/main.tsx | 8 +- examples/yjs/src/main.tsx | 8 +- examples/yjs/src/store/schema.ts | 61 ++++- examples/yjs/src/store/updater.ts | 32 --- examples/yjs/src/thunks.ts | 6 +- src/react.ts | 105 +++++++-- src/store/fx.ts | 7 +- src/store/persist.ts | 12 +- src/store/schema.ts | 215 +++++++++++++++-- src/store/store.ts | 131 +++-------- src/store/types.ts | 37 ++- src/test/api.test.ts | 25 +- src/test/batch.test.ts | 16 +- src/test/create-store.test.ts | 10 +- src/test/fetch.test.ts | 4 +- src/test/matcher.test.ts | 16 +- src/test/mdw.test.ts | 11 +- src/test/persist.test.ts | 365 +++++++++++++++++------------ src/test/put.test.ts | 10 +- src/test/react.test.ts | 4 +- src/test/schema.test.ts | 12 +- src/test/store.test.ts | 56 +++-- src/test/store/slice/obj.test.ts | 12 +- src/test/store/slice/table.test.ts | 105 ++++++--- src/test/take-helper.test.ts | 10 +- src/test/take.test.ts | 6 +- src/test/thunk.test.ts | 69 +++--- 32 files changed, 857 insertions(+), 555 deletions(-) delete mode 100644 examples/yjs/src/store/updater.ts diff --git a/examples/basic/src/main.tsx b/examples/basic/src/main.tsx index a76c9041..23dad0e4 100644 --- a/examples/basic/src/main.tsx +++ b/examples/basic/src/main.tsx @@ -2,8 +2,8 @@ import ReactDOM from "react-dom/client"; import { createApi, createSchema, createStore, mdw, timer } from "starfx"; import { Provider, useCache } from "starfx/react"; -const [schema, initialState] = createSchema(); -const store = createStore({ initialState }); +const schema = createSchema(); +const store = createStore({ schemas: [schema] }); const api = createApi(); // mdw = middleware @@ -14,14 +14,14 @@ api.use(mdw.fetch({ baseUrl: "https://api.github.com" })); const fetchRepo = api.get( "/repos/neurosnap/starfx", { supervisor: timer() }, - api.cache(), + api.cache() ); store.run(api.register); function App() { return ( - + ); @@ -47,7 +47,7 @@ function Example() { const root = document.getElementById("root") as HTMLElement; ReactDOM.createRoot(root).render( - + - , + ); diff --git a/examples/tests-rtl/src/api.ts b/examples/tests-rtl/src/api.ts index 23e39d19..c9abe627 100644 --- a/examples/tests-rtl/src/api.ts +++ b/examples/tests-rtl/src/api.ts @@ -1,7 +1,7 @@ import { createApi, createSchema, mdw, slice } from "starfx"; const emptyUser = { id: "", name: "" }; -export const [schema, initialState] = createSchema({ +export const schema = createSchema({ users: slice.table({ empty: emptyUser }), cache: slice.table(), loaders: slice.loaders(), @@ -12,20 +12,17 @@ api.use(mdw.api({ schema })); api.use(api.routes()); api.use(mdw.fetch({ baseUrl: "https://jsonplaceholder.typicode.com" })); -export const fetchUsers = api.get( - "/users", - function* (ctx, next) { - yield* next(); +export const fetchUsers = api.get("/users", function* (ctx, next) { + yield* next(); - if (!ctx.json.ok) { - return; - } + if (!ctx.json.ok) { + return; + } - const users = ctx.json.value.reduce((acc, user) => { - acc[user.id] = user; - return acc; - }, {}); + const users = ctx.json.value.reduce((acc, user) => { + acc[user.id] = user; + return acc; + }, {}); - yield* schema.update(schema.users.add(users)); - }, -); + yield* schema.update(schema.users.add(users)); +}); diff --git a/examples/tests-rtl/src/store.ts b/examples/tests-rtl/src/store.ts index 4c30ef6c..3f3cebf5 100644 --- a/examples/tests-rtl/src/store.ts +++ b/examples/tests-rtl/src/store.ts @@ -1,12 +1,9 @@ -import { api, initialState as schemaInitialState } from "./api"; +import { api, schema } from "./api"; import { createStore } from "starfx"; -export function setupStore({ initialState = {} }) { +export function setupStore({ initialState = {} } = {}) { const store = createStore({ - initialState: { - ...schemaInitialState, - ...initialState, - }, + schemas: [schema], }); store.run(api.register); diff --git a/examples/tests-rtl/tests/utils.tsx b/examples/tests-rtl/tests/utils.tsx index bdd90452..c5b2de3a 100644 --- a/examples/tests-rtl/tests/utils.tsx +++ b/examples/tests-rtl/tests/utils.tsx @@ -1,16 +1,11 @@ import React, { type ReactElement } from "react"; import { render, type RenderOptions } from "@testing-library/react"; import { Provider } from "starfx/react"; -import { schema } from "../src/api"; import { setupStore } from "../src/store"; const AllTheProviders = ({ children }: { children: React.ReactNode }) => { const store = setupStore({}); - return ( - - {children} - - ); + return {children}; }; const customRender = ( diff --git a/examples/vite-react/src/api.ts b/examples/vite-react/src/api.ts index c2aed6cf..72827643 100644 --- a/examples/vite-react/src/api.ts +++ b/examples/vite-react/src/api.ts @@ -8,12 +8,12 @@ interface User { } const emptyUser: User = { id: "", name: "", age: 0 }; -export const [schema, initialState] = createSchema({ +export const schema = createSchema({ users: slice.table({ empty: emptyUser }), cache: slice.table(), loaders: slice.loaders(), }); -export type AppState = typeof initialState; +export type AppState = typeof schema.initialState; export const api = createApi(); api.use(mdw.api({ schema })); diff --git a/examples/vite-react/src/main.tsx b/examples/vite-react/src/main.tsx index 96f60a66..f03d277d 100644 --- a/examples/vite-react/src/main.tsx +++ b/examples/vite-react/src/main.tsx @@ -2,14 +2,14 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { createStore, take } from "starfx"; import { Provider } from "starfx/react"; -import { api, initialState, schema } from "./api.ts"; +import { api, schema } from "./api.ts"; import App from "./App.tsx"; import "./index.css"; init(); function init() { - const store = createStore({ initialState }); + const store = createStore({ schemas: [schema] }); // makes `fx` available in devtools (window as any).fx = store; @@ -25,9 +25,9 @@ function init() { ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + - , + ); } diff --git a/examples/yjs/src/main.tsx b/examples/yjs/src/main.tsx index 2e357601..dadff68f 100644 --- a/examples/yjs/src/main.tsx +++ b/examples/yjs/src/main.tsx @@ -2,8 +2,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { Provider } from "starfx/react"; import { createStore, take } from "starfx"; -import { defaultStoreUpdater } from "./store/updater.js"; -import { thunks, initialState, schema } from "./thunks.js"; +import { thunks, schema } from "./thunks.js"; import App from "./App.js"; import "./index.css"; @@ -11,8 +10,7 @@ init(); function init() { const store = createStore({ - initialState, - setStoreUpdater: defaultStoreUpdater, + schemas: [schema], }); // makes `fx` available in devtools (window as any).fx = store; @@ -29,7 +27,7 @@ function init() { ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + diff --git a/examples/yjs/src/store/schema.ts b/examples/yjs/src/store/schema.ts index 6b809fdf..09ef7873 100644 --- a/examples/yjs/src/store/schema.ts +++ b/examples/yjs/src/store/schema.ts @@ -1,21 +1,58 @@ import { type FxMap, type FxSchema, - type StoreUpdater, - updateStore, + type Next, + type UpdaterCtx, + type FxStore, + createSchemaWithUpdater, } from "starfx"; +import * as Y from "yjs"; -export function createSchema< +/** + * Creates a Yjs-backed schema where state updates are synchronized via Y.Doc. + * This demonstrates using createSchemaWithUpdater for custom state management. + */ +export function createYjsSchema< O extends FxMap, - S extends { [key in keyof O]: ReturnType["initialState"] } ->(): [FxSchema, S] { - const initialState = {} as S; - function* update(ups: StoreUpdater | StoreUpdater[]) { - return yield* updateStore(ups); - } + S extends { [key in keyof O]: ReturnType["initialState"] }, +>(slices: O): FxSchema { + console.log("Creating Y.Doc"); + const ydoc = new Y.Doc({ autoLoad: true }); + const root = ydoc.getMap(); - const db = {} as FxSchema; - db.update = update; + const data = new Y.Map(); + root.set("data", data); + data.set("items", new Y.Array()); - return [db, initialState]; + return createSchemaWithUpdater(slices, { + createUpdateMdw: (store: FxStore) => { + // Set up observer to sync Y.Doc changes to store + root.observeDeep( + (events: Y.YEvent[], transaction: Y.Transaction) => { + console.log("Y.Doc changed", { events, transaction }); + store.setState(root.toJSON() as S); + } + ); + + // Initialize store with current Y.Doc state + store.setState(root.toJSON() as S); + + return function* updateMdw(ctx: UpdaterCtx, next: Next) { + ydoc.transact(() => { + const updaters = Array.isArray(ctx.updater) + ? ctx.updater + : [ctx.updater]; + for (const updater of updaters) { + updater(root as any); + } + }); + console.log({ updater: ctx.updater }); + store.setState(root.toJSON() as S); + yield* next(); + }; + }, + }); } + +// For backwards compatibility, also export as createSchema +export { createYjsSchema as createSchema }; diff --git a/examples/yjs/src/store/updater.ts b/examples/yjs/src/store/updater.ts deleted file mode 100644 index c6a98fd2..00000000 --- a/examples/yjs/src/store/updater.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as Y from "yjs"; -import type { AnyState, UpdaterCtx, Next, Operation } from "starfx"; - -export const defaultStoreUpdater = ( - setState: (state: S) => void, - getState: () => S -) => { - console.log("Creating Y.Doc"); - const ydoc = new Y.Doc({ autoLoad: true }); - const root = ydoc.getMap(); - - const data = new Y.Map(); - root.set("data", data); - data.set("items", new Y.Array()); - - root.observeDeep((events, transaction) => { - console.log("Y.Doc changed", { events, transaction }); - setState(root.toJSON() as S); - }); - - function* updateMdw(ctx: UpdaterCtx, next: Next) { - ydoc.transact(() => ctx.updater(root)); - console.log({ updater: ctx.updater }); - setState(root.toJSON() as S); - yield* next(); - } - - const initializeStore: () => Operation = function* () { - setState(root.toJSON() as S); - }; - return { updateMdw, initializeStore }; -}; diff --git a/examples/yjs/src/thunks.ts b/examples/yjs/src/thunks.ts index 865e37ef..8e0c2016 100644 --- a/examples/yjs/src/thunks.ts +++ b/examples/yjs/src/thunks.ts @@ -1,8 +1,8 @@ -import { createThunks, mdw, StoreContext } from "starfx"; +import { createThunks, mdw } from "starfx"; import { createSchema } from "./store/schema.js"; -export const [schema, initialState] = createSchema(); -export type AppState = typeof initialState; +export const schema = createSchema({}); +export type AppState = typeof schema.initialState; export const thunks = createThunks(); // catch errors from task and logs them with extra info diff --git a/src/react.ts b/src/react.ts index 4e7eedc1..0e3dd93c 100644 --- a/src/react.ts +++ b/src/react.ts @@ -12,6 +12,9 @@ import { type FxStore, PERSIST_LOADER_ID, } from "./store/index.js"; +import type { LoaderOutput } from "./store/slice/loaders.js"; +import type { TableOutput } from "./store/slice/table.js"; +import type { FxMap } from "./store/types.js"; import type { AnyState, LoaderState } from "./types.js"; import type { ActionFn, ActionFnWithPayload } from "./types.js"; @@ -49,25 +52,70 @@ export interface UseCacheResult data: D | null; } -const SchemaContext = createContext | null>(null); +const SchemaContext = createContext | null>(null); -export function Provider({ - store, - schema, - children, -}: { +// Strongly-typed overload that ties `store` and `schema` to the same state type S +export function Provider(props: { + store: FxStore; + schema?: FxSchema; + children?: React.ReactNode; +}): React.ReactElement; + +// Provider overload accepting any store and schema to avoid variance issues +export function Provider(props: { store: FxStore; - schema: FxSchema; - children: React.ReactNode; + schema?: FxSchema; + children?: React.ReactNode; +}): React.ReactElement; + +// Runtime implementation uses `AnyState` for minimal widening +export function Provider(props: { + store: FxStore; + schema?: FxSchema; + children?: React.ReactNode; }) { - return h(ReduxProvider, { - store, - children: h(SchemaContext.Provider, { value: schema, children }) as any, - }); + const { store, schema, children } = props; + // Use provided schema or pull from store + const schemaValue = (schema ?? store.schema) as FxSchema; + const inner = h(SchemaContext.Provider, { value: schemaValue }, children); + return h(ReduxProvider, { store, children: inner }); } export function useSchema() { - return useContext(SchemaContext) as FxSchema; + const ctx = useContext(SchemaContext); + if (!ctx) throw new Error("No Schema available in context"); + return ctx as FxSchema; +} + +// Typed variant for schemas that include `loaders`. +export function useSchemaWithLoaders(): FxSchema< + AnyState, + { loaders: (n: string) => LoaderOutput } +>; +export function useSchemaWithLoaders< + S extends { loaders: LoaderOutput["initialState"] }, +>(): FxSchema LoaderOutput }>; +export function useSchemaWithLoaders() { + const ctx = useContext(SchemaContext); + if (!ctx) throw new Error("No Schema available in context"); + return ctx as FxSchema< + any, + { loaders: (n: string) => LoaderOutput } + >; +} + +// Typed variant for schemas that include `cache`. +export function useSchemaWithCache(): FxSchema< + AnyState, + { cache: (n: string) => TableOutput } +>; +export function useSchemaWithCache< + S extends { cache: TableOutput["initialState"] }, +>(): FxSchema TableOutput }>; +export function useSchemaWithCache() { + const ctx = useContext(SchemaContext); + if (!ctx) throw new Error("No Schema available in context"); + return ctx as FxSchema TableOutput }>; } export function useStore() { @@ -97,12 +145,17 @@ export function useStore() { * } * ``` */ -export function useLoader( +export function useLoader( action: ThunkAction | ActionFnWithPayload, -) { - const schema = useSchema(); +): LoaderState; +export function useLoader< + S extends { loaders: LoaderOutput["initialState"] }, + M extends AnyState = any, +>(action: ThunkAction | ActionFnWithPayload): LoaderState; +export function useLoader(action: any) { + const schema = useSchemaWithLoaders(); const id = getIdFromAction(action); - return useSelector((s: S) => schema.loaders.selectById(s, { id })); + return useSelector((s: any) => schema.loaders.selectById(s, { id })); } /** @@ -201,14 +254,20 @@ export function useQuery

>( * } * ``` */ -export function useCache

( +export function useCache(action: ThunkAction): UseCacheResult; +export function useCache< + S extends { cache: TableOutput["initialState"] }, + P = any, + ApiSuccess = any, +>( action: ThunkAction, -): UseCacheResult> { - const schema = useSchema(); +): UseCacheResult>; +export function useCache(action: any) { + const schema = useSchemaWithCache(); const id = action.payload.key; - const data: any = useSelector((s: any) => schema.cache.selectById(s, { id })); + const data = useSelector((s: any) => schema.cache.selectById(s, { id })); const query = useQuery(action); - return { ...query, data: data || null }; + return { ...query, data: (data as any) || null }; } /** @@ -266,7 +325,7 @@ export function PersistGate({ children, loading = h(Loading), }: PersistGateProps) { - const schema = useSchema(); + const schema = useSchemaWithLoaders(); const ldr = useSelector((s: any) => schema.loaders.selectById(s, { id: PERSIST_LOADER_ID }), ); diff --git a/src/store/fx.ts b/src/store/fx.ts index 7c14574c..82e5a40f 100644 --- a/src/store/fx.ts +++ b/src/store/fx.ts @@ -7,13 +7,16 @@ import { StoreContext } from "./context.js"; import type { LoaderOutput } from "./slice/loaders.js"; import type { FxStore, StoreUpdater, UpdaterCtx } from "./types.js"; +/** + * Updates the store using the default schema's update method. + * For multiple schemas, use `store.schemas[name].update()` directly. + */ export function* updateStore( updater: StoreUpdater | StoreUpdater[], ): Operation> { const store = yield* StoreContext.expect(); - // had to cast the store since StoreContext has a generic store type const st = store as FxStore; - const ctx = yield* st.update(updater); + const ctx = yield* st.schema.update(updater); return ctx; } diff --git a/src/store/persist.ts b/src/store/persist.ts index 04654207..0b518e1d 100644 --- a/src/store/persist.ts +++ b/src/store/persist.ts @@ -1,4 +1,5 @@ import { Err, Ok, type Operation, type Result } from "effection"; +import type { Draft } from "immer"; import type { AnyState, Next } from "../types.js"; import { select, updateStore } from "./fx.js"; import type { UpdaterCtx } from "./types.js"; @@ -98,10 +99,13 @@ export function createPersistor({ const state = yield* select((s) => s); const nextState = reconciler(state as S, stateFromStorage); - yield* updateStore((state) => { - Object.keys(nextState).forEach((key: keyof S) => { - state[key] = nextState[key]; - }); + yield* updateStore((st) => { + const draft = st as Draft; + const stateObj = draft as unknown as { [K in keyof S]: S[K] }; + + for (const key of Object.keys(nextState) as Array) { + stateObj[key] = nextState[key]; + } }); return Ok(undefined); diff --git a/src/store/schema.ts b/src/store/schema.ts index 18cf5ba9..d1b5b25c 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -1,32 +1,207 @@ -import { updateStore } from "./fx.js"; +import { type Draft, enablePatches, produceWithPatches } from "immer"; +import { type BaseMiddleware, compose } from "../compose.js"; +import { type AnyState, type Next, StoreContext } from "../index.js"; import { slice } from "./slice/index.js"; -import type { FxMap, FxSchema, StoreUpdater } from "./types.js"; +import type { LoaderOutput } from "./slice/loaders.js"; +import type { TableOutput } from "./slice/table.js"; +import { StoreTailMdwContext } from "./store.js"; +import type { + BaseSchema, + FxMap, + FxSchema, + FxStore, + StoreUpdater, + UpdaterCtx, +} from "./types.js"; + +// Default FxMap that includes the built-in `cache` and `loaders` slices +type DefaultFxMap = FxMap & { + cache: (n: string) => TableOutput; + loaders: (n: string) => LoaderOutput; +}; + +// Helper types to extract the factory return type and its initialState +type FactoryReturn = T extends (name: string) => infer R ? R : never; +type FactoryInitial = FactoryReturn> extends BaseSchema< + infer IS +> + ? IS + : never; const defaultSchema = (): O => ({ cache: slice.table(), loaders: slice.loaders() }) as O; -export function createSchema< +/** + * Builds the slice map and initial state from a slices configuration. + * This is a helper for creating custom schema implementations. + */ +export function buildSlices( + slices: O, +): { + db: { [key in keyof O]: FactoryReturn }; + initialState: { [key in keyof O]: FactoryInitial }; +} { + const db = {} as { [key in keyof O]: FactoryReturn }; + for (const key of Object.keys(slices) as Array) { + const factory = slices[key]; + if (!factory) continue; // defensive - O may allow optional entries + const f = factory as (n: string) => FactoryReturn; + db[key] = f(String(key)); + } + + const initialState = {} as { [key in keyof O]: FactoryInitial }; + for (const key of Object.keys(db) as Array) { + initialState[key] = db[key].initialState as FactoryInitial; + } + + return { db, initialState }; +} + +export interface CreateSchemaWithUpdaterOptions { + /** + * Unique name for this schema. Used to access the schema from the store. + * @default "default" + */ + name?: string; + middleware?: BaseMiddleware>[]; + /** + * Factory function that creates the update middleware. + * This is where you implement your state update logic (e.g., immer, plain objects, etc.) + */ + createUpdateMdw: (store: FxStore) => BaseMiddleware>; +} + +/** + * Core schema factory that takes a custom update middleware creator. + * Use this to create schema implementations with different state update mechanisms. + * + * @example + * ```ts + * // Plain object update (no immer) + * const schema = createSchemaWithUpdater(mySlices, { + * createUpdateMdw: (store) => function* (ctx, next) { + * const updaters = Array.isArray(ctx.updater) ? ctx.updater : [ctx.updater]; + * let state = store.getState(); + * for (const updater of updaters) { + * const result = updater(state); + * if (result !== undefined) state = result; + * } + * store.setState(state); + * yield* next(); + * }, + * }); + * ``` + */ +export function createSchemaWithUpdater< O extends FxMap, - S extends { [key in keyof O]: ReturnType["initialState"] }, ->(slices: O = defaultSchema()): [FxSchema, S] { - const db = Object.keys(slices).reduce>( - (acc, key) => { - (acc as any)[key] = slices[key](key); - return acc; - }, - {} as FxSchema, - ); - - const initialState = Object.keys(db).reduce((acc, key) => { - (acc as any)[key] = db[key].initialState; - return acc; - }, {}) as S; + S extends AnyState = { [key in keyof O]: FactoryInitial }, +>( + slices: O, + { + name = "default", + middleware = [], + createUpdateMdw, + }: CreateSchemaWithUpdaterOptions, +): FxSchema { + const { db, initialState } = buildSlices(slices); + + // Precomputed middleware will be set on first update call + let composedMdw: ReturnType>> | null = null; function* update(ups: StoreUpdater | StoreUpdater[]) { - return yield* updateStore(ups); + const store = yield* StoreContext.expect(); + const st = store as FxStore; + + // Lazily compose the full middleware stack on first call + // This allows the store tail middleware context to be set before we compose + if (!composedMdw) { + const storeTailMdw = yield* StoreTailMdwContext.expect(); + const updateMdw = createUpdateMdw(st); + + // Precompute full middleware: updateMdw -> user middleware -> store tail + // Cast the combined array to the explicit middleware type to appease TS + composedMdw = compose>([ + updateMdw, + ...(middleware ?? []), + ...storeTailMdw, + ] as BaseMiddleware>[]); + } + + const ctx: UpdaterCtx = { + updater: ups, + patches: [], + }; + + yield* composedMdw(ctx); + + return ctx; + } + + function* reset(ignoreList: (string | number | symbol)[] = []) { + return yield* update((s) => { + const state = s as Draft; + const stateObj = state as unknown as { [K in keyof S]: S[K] }; + const keep = { ...(initialState as S) } as S; + + for (const key of ignoreList as Array) { + keep[key] = stateObj[key]; + } + + for (const key of Object.keys(stateObj) as Array) { + stateObj[key] = keep[key]; + } + }); } - db.update = update; + const schema = db as FxSchema; + schema.name = name; + schema.update = update; + schema.initialState = initialState as S; + schema.reset = reset; + + return schema; +} + +/** + * Creates a schema with immer-based state updates. + * This is the default implementation that uses immer's produceWithPatches. + */ +export function createSchema< + S extends AnyState, + O extends FxMap = DefaultFxMap, +>( + slices?: O, + options: { + /** + * Unique name for this schema. Used to access the schema from the store. + * @default "default" + */ + name?: string; + middleware?: BaseMiddleware>[]; + } = {}, +): FxSchema { + enablePatches(); + + return createSchemaWithUpdater(slices ?? defaultSchema(), { + name: options.name, + middleware: options.middleware, + createUpdateMdw: (store: FxStore) => + function* updateMdw(ctx: UpdaterCtx, next: Next) { + const upds: StoreUpdater[] = Array.isArray(ctx.updater) + ? ctx.updater + : [ctx.updater]; + + const [nextState, patches, _] = produceWithPatches( + store.getState(), + (draft: Draft) => { + upds.forEach((updater) => updater(draft)); + }, + ); + ctx.patches = patches; + + store.setState(nextState); - return [db, initialState]; + yield* next(); + }, + }); } diff --git a/src/store/store.ts b/src/store/store.ts index bb3dc284..42f691a8 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -11,14 +11,13 @@ import { lift, suspend, } from "effection"; -import { enablePatches, produceWithPatches } from "immer"; import { API_ACTION_PREFIX, ActionContext, emit } from "../action.js"; -import { type BaseMiddleware, compose } from "../compose.js"; +import type { BaseMiddleware } from "../compose.js"; import { createReplaySignal } from "../fx/replay-signal.js"; import type { AnyAction, AnyState, Next } from "../types.js"; import { StoreContext, StoreUpdateContext } from "./context.js"; import { createRun } from "./run.js"; -import type { FxStore, Listener, StoreUpdater, UpdaterCtx } from "./types.js"; +import type { FxSchema, FxStore, Listener, UpdaterCtx } from "./types.js"; const stubMsg = "This is merely a stub, not implemented"; let id = 0; @@ -37,57 +36,19 @@ function observable() { export interface CreateStore { scope?: Scope; - initialState: S; - middleware?: BaseMiddleware>[]; - setStoreUpdater?: ( - setState: (state: S) => void, - getState: () => S, - getScope: () => Scope, - getInitialState?: () => S, - ) => { - updateMdw: BaseMiddleware>; - initializeStore: Callable; - }; + schemas: FxSchema[]; } export const IdContext = createContext("starfx:id", 0); -const defaultStoreUpdater = ( - setState: (state: S) => void, - getState: () => S, -) => { - enablePatches(); - - function* updateMdw(ctx: UpdaterCtx, next: Next) { - const upds: StoreUpdater[] = []; +// Context to share store's tail middleware with schemas +export const StoreTailMdwContext = createContext< + BaseMiddleware>[] +>("starfx:store-tail-mdw", [] as BaseMiddleware>[]); - if (Array.isArray(ctx.updater)) { - upds.push(...ctx.updater); - } else { - upds.push(ctx.updater); - } - - const [nextState, patches, _] = produceWithPatches(getState(), (draft) => { - // TODO: check for return value inside updater - upds.forEach((updater) => updater(draft as any)); - }); - ctx.patches = patches; - - // set the state! - setState(nextState); - - yield* next(); - } - - const initializeStore = function* () {}; - return { updateMdw, initializeStore }; -}; - -export function createStore({ - initialState, +export function createStore({ scope: initScope, - middleware = [], - setStoreUpdater = defaultStoreUpdater, + schemas, }: CreateStore): FxStore { let scope: Scope; if (initScope) { @@ -97,6 +58,10 @@ export function createStore({ scope = tuple[0]; } + // Build initial state from all schemas + const initialState = schemas.reduce((acc, schema) => { + return Object.assign(acc, schema.initialState); + }, {} as AnyState) as S; let state = initialState; const listeners = new Set(); @@ -149,54 +114,16 @@ export function createStore({ yield* next(); } - const { updateMdw, initializeStore } = setStoreUpdater( - setState, - getState, - getScope, - getInitialState, - ); - const mdw = compose>([ - updateMdw, - ...middleware, + // Set the store's tail middleware in context for schemas to use + const storeTailMdw: BaseMiddleware>[] = [ logMdw, notifyChannelMdw, notifyListenersMdw, - ]); - - function* update(updater: StoreUpdater | StoreUpdater[]) { - const ctx = { - updater, - patches: [], - result: Ok(undefined), - }; - - yield* mdw(ctx); - - if (!ctx.result.ok) { - yield* lift(dispatch)({ - type: `${API_ACTION_PREFIX}store`, - payload: ctx.result.error, - }); - } - - return ctx; - } - - function* reset(ignoreList: (keyof S)[] = []) { - return yield* update((s) => { - const keep = ignoreList.reduce( - (acc, key) => { - acc[key] = s[key]; - return acc; - }, - { ...initialState }, - ); - - Object.keys(s).forEach((key: keyof S) => { - s[key] = keep[key]; - }); - }); - } + ]; + scope.set( + StoreTailMdwContext, + storeTailMdw as BaseMiddleware>[], + ); function manage(name: string, inputResource: Operation) { const CustomContext = createContext(name); @@ -216,7 +143,6 @@ export function createStore({ function initialize(op: () => Operation): Task { return scope.run(function* (): Operation { - yield* initializeStore(); yield* scope.spawn(function* () { for (const watched of yield* each(watch)) { yield* scope.spawn(watched); @@ -227,14 +153,27 @@ export function createStore({ }); } + // Use the first schema as the default + const schema: FxSchema = schemas[0] as FxSchema; + + // Build schemas map by name for selective access + const schemasMap = schemas.reduce( + (acc, s) => { + acc[s.name] = s as FxSchema; + return acc; + }, + {} as Record>, + ); + const store: FxStore = { getScope, getState, + setState, subscribe, initialize, manage, - update, - reset, + schema, + schemas: schemasMap, run, // instead of actions relating to store mutation, they // refer to pieces of business logic -- that can also mutate state diff --git a/src/store/types.ts b/src/store/types.ts index 5cfd8e09..9f00cd61 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -1,12 +1,12 @@ import type { Context, Operation, Scope, Task } from "effection"; -import type { Patch } from "immer"; +import type { Draft, Patch } from "immer"; import type { BaseCtx } from "../index.js"; import type { AnyAction, AnyState } from "../types.js"; import type { createRun } from "./run.js"; import type { LoaderOutput } from "./slice/loaders.js"; import type { TableOutput } from "./slice/table.js"; -export type StoreUpdater = (s: S) => S | void; +export type StoreUpdater = (s: S | Draft) => S | void; export type Listener = () => void; @@ -32,31 +32,46 @@ export type Output }> = { }; export interface FxMap { - loaders: (s: string) => LoaderOutput; - cache: (s: string) => TableOutput; - [key: string]: (name: string) => BaseSchema; + // keep a typed shape for default slices while allowing user-defined slices + loaders?: (s: string) => LoaderOutput; + cache?: (s: string) => TableOutput; + [key: string]: ((name: string) => BaseSchema) | undefined; } export type FxSchema = { - [key in keyof O]: ReturnType; -} & { update: FxStore["update"] }; + [key in keyof O]: ReturnType>; +} & { + name: string; + update: (u: StoreUpdater | StoreUpdater[]) => Operation>; + initialState: S; + reset: ( + ignoreList?: K[], + ) => Operation>; +}; export interface FxStore { getScope: () => Scope; + // part of redux store API getState: () => S; + setState: (s: S) => void; + // part of redux store API subscribe: (fn: Listener) => () => void; - update: (u: StoreUpdater | StoreUpdater[]) => Operation>; - reset: (ignoreList?: (keyof S)[]) => Operation>; + // the default schema for this store + schema: FxSchema; + // all schemas by name + schemas: Record>; manage: ( name: string, resource: Operation, ) => Context; run: ReturnType; initialize: (op: () => Operation) => Task; - dispatch: (a: AnyAction | AnyAction[]) => any; + // part of redux store API + dispatch: (a: AnyAction | AnyAction[]) => unknown; + // part of redux store API replaceReducer: (r: (s: S, a: AnyAction) => S) => void; getInitialState: () => S; - [Symbol.observable]: () => any; + [Symbol.observable]: () => unknown; } export interface QueryState { diff --git a/src/test/api.test.ts b/src/test/api.test.ts index 3a22a805..b2bc0e94 100644 --- a/src/test/api.test.ts +++ b/src/test/api.test.ts @@ -33,12 +33,12 @@ const emptyUser: User = { id: "", name: "", email: "" }; const mockUser: User = { id: "1", name: "test", email: "test@test.com" }; const testStore = () => { - const [schema, initialState] = createSchema({ + const schema = createSchema({ users: slice.table({ empty: emptyUser }), loaders: slice.loaders(), cache: slice.table({ empty: {} }), }); - const store = createStore({ initialState }); + const store = createStore({ schemas: [schema] }); return { schema, store }; }; @@ -100,7 +100,10 @@ test("POST", async () => { }, ); - const store = createStore({ initialState: { users: {} } }); + const schema = createSchema({ + users: slice.table(), + }); + const store = createStore({ schemas: [schema] }); store.run(query.register); store.dispatch(createUser({ email: mockUser.email })); @@ -163,7 +166,7 @@ test("POST with uri", () => { }, ); - const store = createStore({ initialState: { users: {} } }); + const store = createStore({ schemas: [createSchema()] }); store.run(query.register); store.dispatch(createUser({ email: mockUser.email })); }); @@ -184,7 +187,7 @@ test("middleware - with request fn", () => { { supervisor: takeEvery }, query.request({ method: "POST" }), ); - const store = createStore({ initialState: { users: {} } }); + const store = createStore({ schemas: [createSchema()] }); store.run(query.register); store.dispatch(createUser()); }); @@ -213,7 +216,7 @@ test("run() on endpoint action - should run the effect", () => { }, ); - const store = createStore({ initialState: { users: {} } }); + const store = createStore({ schemas: [createSchema()] }); store.run(api.register); store.dispatch(action2()); }); @@ -257,7 +260,7 @@ test("run() from a normal saga", async () => { yield* takeEvery(action2, onAction); } - const store = createStore({ initialState: { users: {} } }); + const store = createStore({ schemas: [createSchema()] }); store.run(() => keepAlive([api.register, watchAction])); store.dispatch(action2()); @@ -395,7 +398,7 @@ test("ensure types for get() endpoint", () => { }, ); - const store = createStore({ initialState: { users: {} } }); + const store = createStore({ schemas: [createSchema()] }); store.run(api.register); store.dispatch(action1({ id: "1" })); @@ -433,7 +436,7 @@ test("ensure ability to cast `ctx` in function definition", () => { }, ); - const store = createStore({ initialState: { users: {} } }); + const store = createStore({ schemas: [createSchema()] }); store.run(api.register); store.dispatch(action1({ id: "1" })); expect(acc).toEqual(["1", "wow"]); @@ -466,7 +469,7 @@ test("ensure ability to cast `ctx` in function definition with no props", () => }, ); - const store = createStore({ initialState: { users: {} } }); + const store = createStore({ schemas: [createSchema()] }); store.run(api.register); store.dispatch(action1()); expect(acc).toEqual(["wow"]); @@ -530,7 +533,7 @@ test("useCache - derive api success from endpoint", () => { }, ); - const store = createStore({ initialState: { users: {} } }); + const store = createStore({ schemas: [createSchema()] }); store.run(api.register); function _App() { diff --git a/src/test/batch.test.ts b/src/test/batch.test.ts index 19e1b84f..ba49f375 100644 --- a/src/test/batch.test.ts +++ b/src/test/batch.test.ts @@ -8,13 +8,17 @@ import { import { expect, test } from "../test.js"; test("should batch notify subscribers based on mdw", async () => { - const [schema, initialState] = createSchema({ - cache: slice.table({ empty: {} }), - loaders: slice.loaders(), - }); + const schema = createSchema( + { + cache: slice.table({ empty: {} }), + loaders: slice.loaders(), + }, + { + middleware: [createBatchMdw(queueMicrotask)], + }, + ); const store = createStore({ - initialState, - middleware: [createBatchMdw(queueMicrotask)], + schemas: [schema], }); let counter = 0; store.subscribe(() => { diff --git a/src/test/create-store.test.ts b/src/test/create-store.test.ts index 1008c7dd..cb4f6420 100644 --- a/src/test/create-store.test.ts +++ b/src/test/create-store.test.ts @@ -1,5 +1,5 @@ import { call } from "../index.js"; -import { createStore, select } from "../store/index.js"; +import { createSchema, createStore, select, slice } from "../store/index.js"; import { expect, test } from "../test.js"; interface TestState { @@ -8,7 +8,9 @@ interface TestState { test("should be able to grab values from store", async () => { let actual; - const store = createStore({ initialState: { user: { id: "1" } } }); + const store = createStore({ + schemas: [createSchema({ user: slice.obj({ id: "1" }) })], + }); await store.run(function* () { actual = yield* select((s: TestState) => s.user); }); @@ -17,7 +19,9 @@ test("should be able to grab values from store", async () => { test("should be able to grab store from a nested call", async () => { let actual; - const store = createStore({ initialState: { user: { id: "2" } } }); + const store = createStore({ + schemas: [createSchema({ user: slice.obj({ id: "2" }) })], + }); await store.run(function* () { actual = yield* call(function* () { return yield* select((s: TestState) => s.user); diff --git a/src/test/fetch.test.ts b/src/test/fetch.test.ts index d8758163..213332df 100644 --- a/src/test/fetch.test.ts +++ b/src/test/fetch.test.ts @@ -13,11 +13,11 @@ const baseUrl = "https://starfx.com"; const mockUser = { id: "1", email: "test@starfx.com" }; const testStore = () => { - const [schema, initialState] = createSchema({ + const schema = createSchema({ loaders: slice.loaders(), cache: slice.table({ empty: {} }), }); - const store = createStore({ initialState }); + const store = createStore({ schemas: [schema] }); return { schema, store }; }; diff --git a/src/test/matcher.test.ts b/src/test/matcher.test.ts index d42e5ce1..862eabda 100644 --- a/src/test/matcher.test.ts +++ b/src/test/matcher.test.ts @@ -6,7 +6,7 @@ import { takeLatest, } from "../index.js"; import { matcher } from "../matcher.js"; -import { createStore } from "../store/index.js"; +import { createSchema, createStore } from "../store/index.js"; import { expect, test } from "../test.js"; test("true", () => { @@ -17,7 +17,7 @@ test("true", () => { test("createAction should not match all actions", async () => { expect.assertions(1); - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); const matchedActions: string[] = []; const testAction = createAction("test/action"); @@ -52,7 +52,7 @@ test("matcher should correctly identify createAction functions", () => { test("typed createAction should work with takeLatest without type casting", async () => { expect.assertions(1); - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); const matchedActions: string[] = []; //typed action creator - this should work without 'as any' @@ -88,9 +88,7 @@ test("should correctly identify starfx thunk as a thunk", async () => { thunks.use(thunks.routes()); const store = createStore({ - initialState: { - users: {}, - }, + schemas: [createSchema()], }); store.run(thunks.register); @@ -120,9 +118,7 @@ test("matcher should correctly identify thunk functions", async () => { thunks.use(thunks.routes()); const store = createStore({ - initialState: { - users: {}, - }, + schemas: [createSchema()], }); store.run(thunks.register); @@ -145,7 +141,7 @@ test("matcher should correctly identify thunk functions", async () => { test("some bug: createAction incorrectly matching all actions", async () => { expect.assertions(1); - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); const matchedActions: string[] = []; const testAction = createAction<{ MenuOpened: any }>("ACTION"); diff --git a/src/test/mdw.test.ts b/src/test/mdw.test.ts index d943203c..637b68e3 100644 --- a/src/test/mdw.test.ts +++ b/src/test/mdw.test.ts @@ -34,12 +34,12 @@ const jsonBlob = (data: any) => { }; const testStore = () => { - const [schema, initialState] = createSchema({ + const schema = createSchema({ users: slice.table({ empty: emptyUser }), loaders: slice.loaders(), cache: slice.table({ empty: {} }), }); - const store = createStore({ initialState }); + const store = createStore({ schemas: [schema] }); return { schema, store }; }; @@ -517,14 +517,13 @@ test("errorHandler", () => { ); const store = createStore({ - initialState: { - users: {}, - }, + schemas: [createSchema()], }); store.run(query.register); store.dispatch(fetchUsers()); expect(store.getState()).toEqual({ - users: {}, + cache: {}, + loaders: {}, }); expect(a).toEqual(2); }); diff --git a/src/test/persist.test.ts b/src/test/persist.test.ts index 4f098241..6ddb4e72 100644 --- a/src/test/persist.test.ts +++ b/src/test/persist.test.ts @@ -15,18 +15,18 @@ import type { LoaderItemState } from "../types.js"; test("can persist to storage adapters", async () => { expect.assertions(1); - const [schema, initialState] = createSchema({ + let ls = "{}"; + const _typeSchema = createSchema({ token: slice.str(), loaders: slice.loaders(), cache: slice.table({ empty: {} }), }); - type State = typeof initialState; - let ls = "{}"; - const adapter: PersistAdapter = { + type TestState1 = typeof _typeSchema.initialState; + const adapter: PersistAdapter = { getItem: function* (_: string) { return Ok(JSON.parse(ls)); }, - setItem: function* (_: string, s: Partial) { + setItem: function* (_: string, s: Partial) { ls = JSON.stringify(s); return Ok(undefined); }, @@ -34,12 +34,20 @@ test("can persist to storage adapters", async () => { return Ok(undefined); }, }; - const persistor = createPersistor({ adapter, allowlist: ["token"] }); - const mdw = persistStoreMdw(persistor); - const store = createStore({ - initialState, - middleware: [mdw], + const persistor = createPersistor({ + adapter, + allowlist: ["token"], }); + const mdw = persistStoreMdw(persistor); + const schema = createSchema( + { + token: slice.str(), + loaders: slice.loaders(), + cache: slice.table({ empty: {} }), + }, + { middleware: [mdw] }, + ); + const store = createStore({ schemas: [schema] }); await store.run(function* (): Operation { yield* persistor.rehydrate(); @@ -63,18 +71,18 @@ test("can persist to storage adapters", async () => { test("rehydrates state", async () => { expect.assertions(1); - const [schema, initialState] = createSchema({ + let ls = JSON.stringify({ token: "123" }); + const _typeSchema = createSchema({ token: slice.str(), loaders: slice.loaders(), cache: slice.table({ empty: {} }), }); - type State = typeof initialState; - let ls = JSON.stringify({ token: "123" }); - const adapter: PersistAdapter = { + type TestState2 = typeof _typeSchema.initialState; + const adapter: PersistAdapter = { getItem: function* (_: string) { return Ok(JSON.parse(ls)); }, - setItem: function* (_: string, s: Partial) { + setItem: function* (_: string, s: Partial) { ls = JSON.stringify(s); return Ok(undefined); }, @@ -82,12 +90,20 @@ test("rehydrates state", async () => { return Ok(undefined); }, }; - const persistor = createPersistor({ adapter, allowlist: ["token"] }); - const mdw = persistStoreMdw(persistor); - const store = createStore({ - initialState, - middleware: [mdw], + const persistor = createPersistor({ + adapter, + allowlist: ["token"], }); + const mdw = persistStoreMdw(persistor); + const schema = createSchema( + { + token: slice.str(), + loaders: slice.loaders(), + cache: slice.table({ empty: {} }), + }, + { middleware: [mdw] }, + ); + const store = createStore({ schemas: [schema] }); await store.run(function* (): Operation { yield* persistor.rehydrate(); @@ -99,19 +115,20 @@ test("rehydrates state", async () => { test("persists inbound state using transform 'in' function", async () => { expect.assertions(1); - const [schema, initialState] = createSchema({ + let ls = "{}"; + + const _typeSchema = createSchema({ token: slice.str(), loaders: slice.loaders(), cache: slice.table({ empty: {} }), }); - type State = typeof initialState; - let ls = "{}"; + type TestState3 = typeof _typeSchema.initialState; - const adapter: PersistAdapter = { + const adapter: PersistAdapter = { getItem: function* (_: string) { return Ok(JSON.parse(ls)); }, - setItem: function* (_: string, s: Partial) { + setItem: function* (_: string, s: Partial) { ls = JSON.stringify(s); return Ok(undefined); }, @@ -120,24 +137,29 @@ test("persists inbound state using transform 'in' function", async () => { }, }; - const transform = createTransform(); + const transform = createTransform(); transform.in = (state) => ({ ...state, token: state?.token?.split("").reverse().join(""), }); - const persistor = createPersistor({ + const persistor = createPersistor({ adapter, allowlist: ["token", "cache"], transform, }); const mdw = persistStoreMdw(persistor); - const store = createStore({ - initialState, - middleware: [mdw], - }); + const schema = createSchema( + { + token: slice.str(), + loaders: slice.loaders(), + cache: slice.table({ empty: {} }), + }, + { middleware: [mdw] }, + ); + const store = createStore({ schemas: [schema] }); await store.run(function* (): Operation { yield* persistor.rehydrate(); @@ -161,19 +183,20 @@ test("persists inbound state using transform 'in' function", async () => { test("persists inbound state using tranform in (2)", async () => { expect.assertions(1); - const [schema, initialState] = createSchema({ + let ls = "{}"; + + const _typeSchema = createSchema({ token: slice.str(), loaders: slice.loaders(), cache: slice.table({ empty: {} }), }); - type State = typeof initialState; - let ls = "{}"; + type TestState2 = typeof _typeSchema.initialState; - const adapter: PersistAdapter = { + const adapter: PersistAdapter = { getItem: function* (_: string) { return Ok(JSON.parse(ls)); }, - setItem: function* (_: string, s: Partial) { + setItem: function* (_: string, s: Partial) { ls = JSON.stringify(s); return Ok(undefined); }, @@ -182,27 +205,33 @@ test("persists inbound state using tranform in (2)", async () => { }, }; - function revertToken(state: Partial) { + function revertToken(state: Partial) { const res = { ...state, token: state?.token?.split("").reverse().join(""), }; return res; } - const transform = createTransform(); + const transform = createTransform(); transform.in = revertToken; - const persistor = createPersistor({ + const persistor = createPersistor({ adapter, allowlist: ["token", "cache"], transform, }); const mdw = persistStoreMdw(persistor); - const store = createStore({ - initialState, - middleware: [mdw], - }); + const schema = createSchema( + { + token: slice.str(), + loaders: slice.loaders(), + cache: slice.table({ empty: {} }), + }, + { middleware: [mdw] }, + ); + type State = typeof schema.initialState; + const store = createStore({ schemas: [schema] }); await store.run(function* (): Operation { yield* persistor.rehydrate(); @@ -225,19 +254,20 @@ test("persists inbound state using tranform in (2)", async () => { test("persists a filtered nested part of a slice", async () => { expect.assertions(5); - const [schema, initialState] = createSchema({ + let ls = "{}"; + + const _typeSchema = createSchema({ token: slice.str(), loaders: slice.loaders(), cache: slice.table({ empty: {} }), }); - type State = typeof initialState; - let ls = "{}"; + type TestState3 = typeof _typeSchema.initialState; - const adapter: PersistAdapter = { + const adapter: PersistAdapter = { getItem: function* (_: string) { return Ok(JSON.parse(ls)); }, - setItem: function* (_: string, s: Partial) { + setItem: function* (_: string, s: Partial) { ls = JSON.stringify(s); return Ok(undefined); }, @@ -246,7 +276,9 @@ test("persists a filtered nested part of a slice", async () => { }, }; - function pickLatestOfLoadersAandC(state: Partial): Partial { + function pickLatestOfLoadersAandC( + state: Partial, + ): Partial { const nextState = { ...state }; if (state.loaders) { @@ -268,19 +300,25 @@ test("persists a filtered nested part of a slice", async () => { return nextState; } - const transform = createTransform(); + const transform = createTransform(); transform.in = pickLatestOfLoadersAandC; - const persistor = createPersistor({ + const persistor = createPersistor({ adapter, transform, }); const mdw = persistStoreMdw(persistor); - const store = createStore({ - initialState, - middleware: [mdw], - }); + const schema = createSchema( + { + token: slice.str(), + loaders: slice.loaders(), + cache: slice.table({ empty: {} }), + }, + { middleware: [mdw] }, + ); + type State = typeof schema.initialState; + const store = createStore({ schemas: [schema] }); await store.run(function* (): Operation { yield* persistor.rehydrate(); @@ -325,20 +363,20 @@ test("persists a filtered nested part of a slice", async () => { test("handles the empty state correctly", async () => { expect.assertions(1); - const [_schema, initialState] = createSchema({ + const schema = createSchema({ token: slice.str(), loaders: slice.loaders(), cache: slice.table({ empty: {} }), }); - type State = typeof initialState; + type TestState4 = typeof schema.initialState; let ls = "{}"; - const adapter: PersistAdapter = { + const adapter: PersistAdapter = { getItem: function* (_: string) { return Ok(JSON.parse(ls)); }, - setItem: function* (_: string, s: Partial) { + setItem: function* (_: string, s: Partial) { ls = JSON.stringify(s); return Ok(undefined); }, @@ -347,19 +385,16 @@ test("handles the empty state correctly", async () => { }, }; - const transform = createTransform(); - transform.in = (_: Partial) => ({}); + const transform = createTransform(); + transform.in = (_: Partial) => ({}); - const persistor = createPersistor({ + const persistor = createPersistor({ adapter, transform, }); const mdw = persistStoreMdw(persistor); - const store = createStore({ - initialState, - middleware: [mdw], - }); + const store = createStore({ schemas: [schema] }); await store.run(function* (): Operation { yield* persistor.rehydrate(); @@ -370,18 +405,18 @@ test("handles the empty state correctly", async () => { test("in absence of the inbound transformer, persists as it is", async () => { expect.assertions(1); - const [schema, initialState] = createSchema({ + let ls = "{}"; + const _typeSchema = createSchema({ token: slice.str(), loaders: slice.loaders(), cache: slice.table({ empty: {} }), }); - type State = typeof initialState; - let ls = "{}"; - const adapter: PersistAdapter = { + type TestState5 = typeof _typeSchema.initialState; + const adapter: PersistAdapter = { getItem: function* (_: string) { return Ok(JSON.parse(ls)); }, - setItem: function* (_: string, s: Partial) { + setItem: function* (_: string, s: Partial) { ls = JSON.stringify(s); return Ok(undefined); }, @@ -389,17 +424,22 @@ test("in absence of the inbound transformer, persists as it is", async () => { return Ok(undefined); }, }; - const persistor = createPersistor({ + const persistor = createPersistor({ adapter, allowlist: ["token"], - transform: createTransform(), // we deliberately do not set the inbound transformer + transform: createTransform(), // we deliberately do not set the inbound transformer }); const mdw = persistStoreMdw(persistor); - const store = createStore({ - initialState, - middleware: [mdw], - }); + const schema = createSchema( + { + token: slice.str(), + loaders: slice.loaders(), + cache: slice.table({ empty: {} }), + }, + { middleware: [mdw] }, + ); + const store = createStore({ schemas: [schema] }); await store.run(function* (): Operation { yield* persistor.rehydrate(); @@ -423,18 +463,18 @@ test("in absence of the inbound transformer, persists as it is", async () => { test("handles errors gracefully, defaluts to identity function", async () => { expect.assertions(1); - const [schema, initialState] = createSchema({ + const schema = createSchema({ token: slice.str(), loaders: slice.loaders(), cache: slice.table({ empty: {} }), }); - type State = typeof initialState; + type TestState6 = typeof schema.initialState; let ls = "{}"; - const adapter: PersistAdapter = { + const adapter: PersistAdapter = { getItem: function* (_: string) { return Ok(JSON.parse(ls)); }, - setItem: function* (_: string, s: Partial) { + setItem: function* (_: string, s: Partial) { ls = JSON.stringify(s); return Ok(undefined); }, @@ -443,19 +483,16 @@ test("handles errors gracefully, defaluts to identity function", async () => { }, }; - const transform = createTransform(); - transform.in = (_: Partial) => { + const transform = createTransform(); + transform.in = (_: Partial) => { throw new Error("testing the transform error"); }; - const persistor = createPersistor({ + const persistor = createPersistor({ adapter, transform, }); const mdw = persistStoreMdw(persistor); - const store = createStore({ - initialState, - middleware: [mdw], - }); + const store = createStore({ schemas: [schema] }); const err = console.error; console.error = () => {}; @@ -470,19 +507,19 @@ test("handles errors gracefully, defaluts to identity function", async () => { test("allowdList is filtered out after the inbound transformer is applied", async () => { expect.assertions(1); - const [schema, initialState] = createSchema({ + let ls = "{}"; + const _typeSchema = createSchema({ token: slice.str(), counter: slice.num(0), loaders: slice.loaders(), cache: slice.table({ empty: {} }), }); - type State = typeof initialState; - let ls = "{}"; - const adapter: PersistAdapter = { + type TestState7 = typeof _typeSchema.initialState; + const adapter: PersistAdapter = { getItem: function* (_: string) { return Ok(JSON.parse(ls)); }, - setItem: function* (_: string, s: Partial) { + setItem: function* (_: string, s: Partial) { ls = JSON.stringify(s); return Ok(undefined); }, @@ -491,23 +528,29 @@ test("allowdList is filtered out after the inbound transformer is applied", asy }, }; - const transform = createTransform(); - transform.in = (state) => ({ + const transform = createTransform(); + transform.in = (state: Partial) => ({ ...state, token: `${state.counter}${state?.token?.split("").reverse().join("")}`, }); - const persistor = createPersistor({ + const persistor = createPersistor({ adapter, allowlist: ["token"], transform, }); const mdw = persistStoreMdw(persistor); - const store = createStore({ - initialState, - middleware: [mdw], - }); + const schema = createSchema( + { + token: slice.str(), + counter: slice.num(0), + loaders: slice.loaders(), + cache: slice.table({ empty: {} }), + }, + { middleware: [mdw] }, + ); + const store = createStore({ schemas: [schema] }); await store.run(function* (): Operation { yield* persistor.rehydrate(); @@ -521,18 +564,18 @@ test("allowdList is filtered out after the inbound transformer is applied", asy test("the inbound transformer can be redifined during runtime", async () => { expect.assertions(2); - const [schema, initialState] = createSchema({ + let ls = "{}"; + const _typeSchema = createSchema({ token: slice.str(), loaders: slice.loaders(), cache: slice.table({ empty: {} }), }); - type State = typeof initialState; - let ls = "{}"; - const adapter: PersistAdapter = { + type TestState4 = typeof _typeSchema.initialState; + const adapter: PersistAdapter = { getItem: function* (_: string) { return Ok(JSON.parse(ls)); }, - setItem: function* (_: string, s: Partial) { + setItem: function* (_: string, s: Partial) { ls = JSON.stringify(s); return Ok(undefined); }, @@ -541,23 +584,29 @@ test("the inbound transformer can be redifined during runtime", async () => { }, }; - const transform = createTransform(); + const transform = createTransform(); transform.in = (state) => ({ ...state, token: `${state?.token?.split("").reverse().join("")}`, }); - const persistor = createPersistor({ + const persistor = createPersistor({ adapter, allowlist: ["token"], transform, }); const mdw = persistStoreMdw(persistor); - const store = createStore({ - initialState, - middleware: [mdw], - }); + const schema = createSchema( + { + token: slice.str(), + loaders: slice.loaders(), + cache: slice.table({ empty: {} }), + }, + { middleware: [mdw] }, + ); + type State = typeof schema.initialState; + const store = createStore({ schemas: [schema] }); await store.run(function* (): Operation { yield* persistor.rehydrate(); @@ -581,20 +630,20 @@ test("the inbound transformer can be redifined during runtime", async () => { test("persists state using transform 'out' function", async () => { expect.assertions(1); - const [schema, initialState] = createSchema({ + const schema = createSchema({ token: slice.str(), counter: slice.num(0), loaders: slice.loaders(), cache: slice.table({ empty: {} }), }); - type State = typeof initialState; + type TestState8 = typeof schema.initialState; let ls = '{"token": "01234"}'; - const adapter: PersistAdapter = { + const adapter: PersistAdapter = { getItem: function* (_: string) { return Ok(JSON.parse(ls)); }, - setItem: function* (_: string, s: Partial) { + setItem: function* (_: string, s: Partial) { ls = JSON.stringify(s); return Ok(undefined); }, @@ -603,23 +652,20 @@ test("persists state using transform 'out' function", async () => { }, }; - function revertToken(state: Partial) { + function revertToken(state: Partial) { return { ...state, token: state?.token?.split("").reverse().join("") }; } - const transform = createTransform(); + const transform = createTransform(); transform.out = revertToken; - const persistor = createPersistor({ + const persistor = createPersistor({ adapter, allowlist: ["token"], transform, }); const mdw = persistStoreMdw(persistor); - const store = createStore({ - initialState, - middleware: [mdw], - }); + const store = createStore({ schemas: [schema] }); await store.run(function* (): Operation { yield* persistor.rehydrate(); @@ -631,20 +677,21 @@ test("persists state using transform 'out' function", async () => { test("persists outbound state using tranform setOutTransformer", async () => { expect.assertions(1); - const [schema, initialState] = createSchema({ + let ls = '{"token": "43210"}'; + + const _typeSchema = createSchema({ token: slice.str(), counter: slice.num(0), loaders: slice.loaders(), cache: slice.table({ empty: {} }), }); - type State = typeof initialState; - let ls = '{"token": "43210"}'; + type TestState5 = typeof _typeSchema.initialState; - const adapter: PersistAdapter = { + const adapter: PersistAdapter = { getItem: function* (_: string) { return Ok(JSON.parse(ls)); }, - setItem: function* (_: string, s: Partial) { + setItem: function* (_: string, s: Partial) { ls = JSON.stringify(s); return Ok(undefined); }, @@ -653,7 +700,7 @@ test("persists outbound state using tranform setOutTransformer", async () => { }, }; - function revertToken(state: Partial) { + function revertToken(state: Partial) { return { ...state, token: ["5"] @@ -662,20 +709,27 @@ test("persists outbound state using tranform setOutTransformer", async () => { .join(""), }; } - const transform = createTransform(); + const transform = createTransform(); transform.out = revertToken; - const persistor = createPersistor({ + const persistor = createPersistor({ adapter, allowlist: ["token"], transform, }); const mdw = persistStoreMdw(persistor); - const store = createStore({ - initialState, - middleware: [mdw], - }); + const schema = createSchema( + { + token: slice.str(), + counter: slice.num(0), + loaders: slice.loaders(), + cache: slice.table({ empty: {} }), + }, + { middleware: [mdw] }, + ); + type State = typeof schema.initialState; + const store = createStore({ schemas: [schema] }); await store.run(function* (): Operation { yield* persistor.rehydrate(); @@ -687,12 +741,12 @@ test("persists outbound state using tranform setOutTransformer", async () => { test("persists outbound a filtered nested part of a slice", async () => { expect.assertions(1); - const [schema, initialState] = createSchema({ + const schema = createSchema({ token: slice.str(), loaders: slice.loaders(), cache: slice.table({ empty: {} }), }); - type State = typeof initialState; + type State = typeof schema.initialState; let ls = '{"loaders":{"A":{"id":"A [POST]|5678","status":"loading","message":"loading A-second","lastRun":1725048721168,"lastSuccess":0,"meta":{"flag":"01234_FLAG_PERSISTED"}}}}'; @@ -729,10 +783,7 @@ test("persists outbound a filtered nested part of a slice", async () => { }); const mdw = persistStoreMdw(persistor); - const store = createStore({ - initialState, - middleware: [mdw], - }); + const store = createStore({ schemas: [schema] }); await store.run(function* (): Operation { yield* persistor.rehydrate(); @@ -743,20 +794,21 @@ test("persists outbound a filtered nested part of a slice", async () => { test("the outbound transformer can be reset during runtime", async () => { expect.assertions(3); - const [schema, initialState] = createSchema({ + let ls = '{"token": "_1234"}'; + + const _typeSchema = createSchema({ token: slice.str(), counter: slice.num(0), loaders: slice.loaders(), cache: slice.table({ empty: {} }), }); - type State = typeof initialState; - let ls = '{"token": "_1234"}'; + type TestState6 = typeof _typeSchema.initialState; - const adapter: PersistAdapter = { + const adapter: PersistAdapter = { getItem: function* (_: string) { return Ok(JSON.parse(ls)); }, - setItem: function* (_: string, s: Partial) { + setItem: function* (_: string, s: Partial) { ls = JSON.stringify(s); return Ok(undefined); }, @@ -765,29 +817,36 @@ test("the outbound transformer can be reset during runtime", async () => { }, }; - function revertToken(state: Partial) { + function revertToken(state: Partial) { return { ...state, token: state?.token?.split("").reverse().join("") }; } - function postpendToken(state: Partial) { + function postpendToken(state: Partial) { return { ...state, token: `${state?.token}56789`, }; } - const transform = createTransform(); + const transform = createTransform(); transform.out = revertToken; - const persistor = createPersistor({ + const persistor = createPersistor({ adapter, allowlist: ["token"], transform, }); const mdw = persistStoreMdw(persistor); - const store = createStore({ - initialState, - middleware: [mdw], - }); + const schema = createSchema( + { + token: slice.str(), + counter: slice.num(0), + loaders: slice.loaders(), + cache: slice.table({ empty: {} }), + }, + { middleware: [mdw] }, + ); + type State = typeof schema.initialState; + const store = createStore({ schemas: [schema] }); await store.run(function* (): Operation { yield* persistor.rehydrate(); diff --git a/src/test/put.test.ts b/src/test/put.test.ts index dbb945b7..2e1e2ab4 100644 --- a/src/test/put.test.ts +++ b/src/test/put.test.ts @@ -1,5 +1,5 @@ import { ActionContext, each, put, sleep, spawn, take } from "../index.js"; -import { createStore } from "../store/index.js"; +import { createSchema, createStore } from "../store/index.js"; import { expect, test } from "../test.js"; test("should send actions through channel", async () => { @@ -26,7 +26,7 @@ test("should send actions through channel", async () => { yield* task; } - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); await store.run(() => genFn("arg")); const expected = ["arg", "2"]; @@ -59,7 +59,7 @@ test("should handle nested puts", async () => { yield* sleep(0); } - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); await store.run(() => root()); // TODO, was this backwards? we are using `take("a")` in `genB`, so it will wait for `genA` to finish @@ -74,7 +74,7 @@ test("should not cause stack overflow when puts are emitted while dispatching sa } } - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); await store.run(root); expect(true).toBe(true); }); @@ -101,7 +101,7 @@ test("should not miss `put` that was emitted directly after creating a task (cau yield* tsk; } - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); await store.run(root); const expected = ["didn't get missed"]; expect(actual).toEqual(expected); diff --git a/src/test/react.test.ts b/src/test/react.test.ts index bd9cdb1b..c2c94c24 100644 --- a/src/test/react.test.ts +++ b/src/test/react.test.ts @@ -5,11 +5,11 @@ import { expect, test } from "../test.js"; // typing test test("react types", () => { - const [schema, initialState] = createSchema({ + const schema = createSchema({ cache: slice.table(), loaders: slice.loaders(), }); - const store = createStore({ initialState }); + const store = createStore({ schemas: [schema] }); React.createElement(Provider, { schema, store, diff --git a/src/test/schema.test.ts b/src/test/schema.test.ts index 66d61371..2f944e46 100644 --- a/src/test/schema.test.ts +++ b/src/test/schema.test.ts @@ -12,8 +12,8 @@ interface UserWithRoles extends User { const emptyUser = { id: "", name: "" }; test("default schema", async () => { - const [schema, initialState] = createSchema(); - const store = createStore({ initialState }); + const schema = createSchema(); + const store = createStore({ schemas: [schema] }); expect(store.getState()).toEqual({ cache: {}, loaders: {}, @@ -35,7 +35,7 @@ test("default schema", async () => { test("general types and functionality", async () => { expect.assertions(8); - const [db, initialState] = createSchema({ + const db = createSchema({ users: slice.table({ initialState: { "1": { id: "1", name: "wow" } }, empty: emptyUser, @@ -47,7 +47,7 @@ test("general types and functionality", async () => { cache: slice.table({ empty: {} }), loaders: slice.loaders(), }); - const store = createStore({ initialState }); + const store = createStore({ schemas: [db] }); expect(store.getState()).toEqual({ users: { "1": { id: "1", name: "wow" } }, @@ -93,12 +93,12 @@ test("general types and functionality", async () => { test("can work with a nested object", async () => { expect.assertions(3); - const [db, initialState] = createSchema({ + const db = createSchema({ currentUser: slice.obj({ id: "", name: "", roles: [] }), cache: slice.table({ empty: {} }), loaders: slice.loaders(), }); - const store = createStore({ initialState }); + const store = createStore({ schemas: [db] }); await store.run(function* () { yield* db.update(db.currentUser.update({ key: "name", value: "vvv" })); const curUser = yield* select(db.currentUser.select); diff --git a/src/test/store.test.ts b/src/test/store.test.ts index d428f453..314bab99 100644 --- a/src/test/store.test.ts +++ b/src/test/store.test.ts @@ -12,9 +12,12 @@ import { import { StoreContext, StoreUpdateContext, + createSchema, createStore, + slice, updateStore, } from "../store/index.js"; +import type { FxMap } from "../store/types.js"; import { describe, expect, test } from "../test.js"; interface User { @@ -60,14 +63,22 @@ const updateUser = state.dev = true; }; +const testSchema = (initialState: Partial = {}) => { + return createSchema({ + users: slice.table({ initialState: initialState.users ?? {} }), + theme: slice.str(initialState.theme ?? ""), + token: slice.str(initialState.token ?? ""), + dev: slice.any(initialState.dev ?? false), + }); +}; + test("update store and receives update from channel `StoreUpdateContext`", async () => { expect.assertions(1); const [scope] = createScope(); - const initialState: Partial = { + const schema = testSchema({ users: { 1: { id: "1", name: "testing" }, 2: { id: "2", name: "wow" } }, - dev: false, - }; - createStore({ scope, initialState }); + }); + const testStore = createStore({ scope, schemas: [schema] }); let store; await scope.run(function* (): Operation[]> { const result = yield* parallel([ @@ -88,18 +99,17 @@ test("update store and receives update from channel `StoreUpdateContext`", async expect((store as any)?.getState()).toEqual({ users: { 1: { id: "1", name: "eric" }, 3: { id: "", name: "" } }, dev: true, + theme: "", + token: "", }); }); test("update store and receives update from `subscribe()`", async () => { expect.assertions(1); - const initialState: Partial = { + const schema = testSchema({ users: { 1: { id: "1", name: "testing" }, 2: { id: "2", name: "wow" } }, - dev: false, - theme: "", - token: "", - }; - const store = createStore({ initialState }); + }); + const store = createStore({ schemas: [schema] }); store.subscribe(() => { expect(store.getState()).toEqual({ @@ -117,13 +127,10 @@ test("update store and receives update from `subscribe()`", async () => { test("emit Action and update store", async () => { expect.assertions(1); - const initialState: Partial = { + const schema = testSchema({ users: { 1: { id: "1", name: "testing" }, 2: { id: "2", name: "wow" } }, - dev: false, - theme: "", - token: "", - }; - const store = createStore({ initialState }); + }); + const store = createStore({ schemas: [schema] }); await store.run(function* (): Operation { const result = yield* parallel([ @@ -150,16 +157,13 @@ test("emit Action and update store", async () => { test("resets store", async () => { expect.assertions(2); - const initialState: Partial = { + const schema = testSchema({ users: { 1: { id: "1", name: "testing" }, 2: { id: "2", name: "wow" } }, - dev: false, - theme: "", - token: "", - }; - const store = createStore({ initialState }); + }); + const store = createStore({ schemas: [schema] }); await store.run(function* () { - yield* store.update((s) => { + yield* schema.update((s: State) => { s.users = { 3: { id: "3", name: "hehe" } }; s.dev = true; s.theme = "darkness"; @@ -173,7 +177,7 @@ test("resets store", async () => { dev: true, }); - await store.run(() => store.reset(["users"])); + await store.run(() => schema.reset(["users"])); expect(store.getState()).toEqual({ users: { 3: { id: "3", name: "hehe" } }, @@ -209,7 +213,7 @@ describe(".manage", () => { const thunk = createThunks(); thunk.use(thunk.routes()); - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); const TestContext = store.manage("test:context", guessAge()); store.initialize(thunk.register); let acc = "bla"; @@ -228,7 +232,7 @@ describe(".manage", () => { const thunk = createThunks(); thunk.use(thunk.routes()); - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); const TestContext = store.manage("test:context", guessAge()); store.initialize(thunk.register); let guess = 0; diff --git a/src/test/store/slice/obj.test.ts b/src/test/store/slice/obj.test.ts index 6fbf8055..c8184b46 100644 --- a/src/test/store/slice/obj.test.ts +++ b/src/test/store/slice/obj.test.ts @@ -1,4 +1,5 @@ -import { configureStore, updateStore } from "../../../store/index.js"; +import { createStore, updateStore } from "../../../store/index.js"; +import { createSchema } from "../../../store/schema.js"; import { expect, test } from "../../../test.js"; import { createObj } from "../../../store/slice/obj.js"; @@ -24,10 +25,11 @@ const slice = createObj({ }); test("sets up an obj", async () => { - const store = configureStore({ - initialState: { - [NAME]: crtInitialState, - }, + const schema = createSchema({ + [NAME]: () => slice, + }); + const store = createStore({ + schemas: [schema], }); await store.run(function* () { diff --git a/src/test/store/slice/table.test.ts b/src/test/store/slice/table.test.ts index 141d7e5c..51fd7214 100644 --- a/src/test/store/slice/table.test.ts +++ b/src/test/store/slice/table.test.ts @@ -1,6 +1,7 @@ import { updateStore } from "../../../store/fx.js"; +import { createSchema } from "../../../store/schema.js"; import { createTable, table } from "../../../store/slice/table.js"; -import { configureStore } from "../../../store/store.js"; +import { createStore } from "../../../store/store.js"; import { expect, test } from "../../../test.js"; type TUser = { @@ -10,65 +11,74 @@ type TUser = { const NAME = "table"; const empty = { id: 0, user: "" }; -const slice = createTable({ +const tableSlice = createTable({ name: NAME, empty, }); -const initialState = { - [NAME]: slice.initialState, -}; - const first = { id: 1, user: "A" }; const second = { id: 2, user: "B" }; const third = { id: 3, user: "C" }; test("sets up a table", async () => { - const store = configureStore({ - initialState, + const schema = createSchema({ + [NAME]: () => tableSlice, + }); + const store = createStore({ + schemas: [schema], }); await store.run(function* () { - yield* updateStore(slice.set({ [first.id]: first })); + yield* updateStore(tableSlice.set({ [first.id]: first })); }); expect(store.getState()[NAME]).toEqual({ [first.id]: first }); }); test("adds a row", async () => { - const store = configureStore({ - initialState, + const schema = createSchema({ + [NAME]: () => tableSlice, + }); + const store = createStore({ + schemas: [schema], }); await store.run(function* () { - yield* updateStore(slice.set({ [second.id]: second })); + yield* updateStore(tableSlice.set({ [second.id]: second })); }); expect(store.getState()[NAME]).toEqual({ 2: second }); }); test("removes a row", async () => { - const store = configureStore({ - initialState: { - ...initialState, - [NAME]: { [first.id]: first, [second.id]: second } as Record< - string, - TUser - >, - }, + const schema = createSchema({ + [NAME]: () => tableSlice, + }); + const store = createStore({ + schemas: [schema], + }); + + // Pre-populate the store + await store.run(function* () { + yield* updateStore( + tableSlice.set({ [first.id]: first, [second.id]: second }), + ); }); await store.run(function* () { - yield* updateStore(slice.remove(["1"])); + yield* updateStore(tableSlice.remove(["1"])); }); expect(store.getState()[NAME]).toEqual({ [second.id]: second }); }); test("updates a row", async () => { - const store = configureStore({ - initialState, + const schema = createSchema({ + [NAME]: () => tableSlice, + }); + const store = createStore({ + schemas: [schema], }); await store.run(function* () { const updated = { id: second.id, user: "BB" }; - yield* updateStore(slice.patch({ [updated.id]: updated })); + yield* updateStore(tableSlice.patch({ [updated.id]: updated })); }); expect(store.getState()[NAME]).toEqual({ [second.id]: { ...second, user: "BB" }, @@ -76,35 +86,48 @@ test("updates a row", async () => { }); test("gets a row", async () => { - const store = configureStore({ - initialState, + const schema = createSchema({ + [NAME]: () => tableSlice, + }); + const store = createStore({ + schemas: [schema], }); await store.run(function* () { yield* updateStore( - slice.add({ [first.id]: first, [second.id]: second, [third.id]: third }), + tableSlice.add({ + [first.id]: first, + [second.id]: second, + [third.id]: third, + }), ); }); - const row = slice.selectById(store.getState(), { id: "2" }); + const row = tableSlice.selectById(store.getState(), { id: "2" }); expect(row).toEqual(second); }); test("when the record doesnt exist, it returns empty record", () => { - const store = configureStore({ - initialState, + const schema = createSchema({ + [NAME]: () => tableSlice, + }); + const store = createStore({ + schemas: [schema], }); - const row = slice.selectById(store.getState(), { id: "2" }); + const row = tableSlice.selectById(store.getState(), { id: "2" }); expect(row).toEqual(empty); }); test("gets all rows", async () => { - const store = configureStore({ - initialState, + const schema = createSchema({ + [NAME]: () => tableSlice, + }); + const store = createStore({ + schemas: [schema], }); const data = { [first.id]: first, [second.id]: second, [third.id]: third }; await store.run(function* () { - yield* updateStore(slice.add(data)); + yield* updateStore(tableSlice.add(data)); }); expect(store.getState()[NAME]).toEqual(data); }); @@ -112,8 +135,11 @@ test("gets all rows", async () => { // checking types of `result` here test("with empty", async () => { const tbl = table({ empty: first })("users"); - const store = configureStore({ - initialState, + const schema = createSchema({ + users: () => tbl, + }); + const store = createStore({ + schemas: [schema], }); expect(tbl.empty).toEqual(first); @@ -130,8 +156,11 @@ test("with empty", async () => { // checking types of `result` here test("with no empty", async () => { const tbl = table()("users"); - const store = configureStore({ - initialState, + const schema = createSchema({ + users: () => tbl, + }); + const store = createStore({ + schemas: [schema], }); expect(tbl.empty).toEqual(undefined); diff --git a/src/test/take-helper.test.ts b/src/test/take-helper.test.ts index 0f7493d0..f2a739b3 100644 --- a/src/test/take-helper.test.ts +++ b/src/test/take-helper.test.ts @@ -1,7 +1,7 @@ -import { spawn, suspend } from "effection"; +import { spawn } from "effection"; import type { AnyAction } from "../index.js"; import { sleep, take, takeEvery, takeLatest, takeLeading } from "../index.js"; -import { createStore } from "../store/index.js"; +import { createSchema, createStore } from "../store/index.js"; import { expect, test } from "../test.js"; test("should cancel previous tasks and only use latest", async () => { @@ -19,7 +19,7 @@ test("should cancel previous tasks and only use latest", async () => { yield* take("CANCEL_WATCHER"); yield* task.halt(); } - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); const task = store.run(root); store.dispatch({ type: "ACTION", payload: "1" }); @@ -48,7 +48,7 @@ test("should keep first action and discard the rest", async () => { yield* sleep(150); yield* task.halt(); } - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); const task = store.run(root); store.dispatch({ type: "ACTION", payload: "1" }); @@ -78,7 +78,7 @@ test("should receive all actions", async () => { actual.push([arg1, arg2, action.payload]); } - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); const task = store.run(root); for (let i = 1; i <= loop / 2; i += 1) { diff --git a/src/test/take.test.ts b/src/test/take.test.ts index aba2a7ea..83fc1c56 100644 --- a/src/test/take.test.ts +++ b/src/test/take.test.ts @@ -1,6 +1,6 @@ import type { AnyAction } from "../index.js"; import { put, sleep, spawn, take } from "../index.js"; -import { createStore } from "../store/index.js"; +import { createSchema, createStore } from "../store/index.js"; import { expect, test } from "../test.js"; test("a put should complete before more `take` are added and then consumed automatically", async () => { @@ -22,7 +22,7 @@ test("a put should complete before more `take` are added and then consumed autom actual.push(yield* take("action-1")); } - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); await store.run(root); expect(actual).toEqual([ @@ -94,7 +94,7 @@ test("take from default channel", async () => { yield* takes; // wait for the takes to complete } - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); await store.run(genFn); const expected = [ diff --git a/src/test/thunk.test.ts b/src/test/thunk.test.ts index 0e8ad650..a047f628 100644 --- a/src/test/thunk.test.ts +++ b/src/test/thunk.test.ts @@ -8,7 +8,12 @@ import { takeEvery, waitFor, } from "../index.js"; -import { createStore, updateStore } from "../store/index.js"; +import { + createSchema, + createStore, + slice, + updateStore, +} from "../store/index.js"; import { describe, expect, test } from "../test.js"; import type { @@ -148,14 +153,20 @@ test("when create a query fetch pipeline - execute all middleware and save to re api.use(processTickets); const fetchUsers = api.create("/users", { supervisor: takeEvery }); - const store = createStore({ - initialState: { users: {}, tickets: {} }, + const schema = createSchema({ + cache: slice.table(), + loaders: slice.loaders(), + users: slice.table({ empty: { id: "", name: "", email: "" } }), + tickets: slice.table({ empty: { id: "", name: "" } }), }); + const store = createStore({ schemas: [schema] }); store.run(api.register); store.dispatch(fetchUsers()); expect(store.getState()).toEqual({ + cache: {}, + loaders: {}, users: { [mockUser.id]: deserializeUser(mockUser) }, tickets: {}, }); @@ -186,13 +197,19 @@ test("when providing a generator the to api.create function - should call that g }, ); - const store = createStore({ - initialState: { users: {}, tickets: {} }, + const schema = createSchema({ + cache: slice.table(), + loaders: slice.loaders(), + users: slice.table({ empty: { id: "", name: "", email: "" } }), + tickets: slice.table({ empty: { id: "", name: "" } }), }); + const store = createStore({ schemas: [schema] }); store.run(api.register); store.dispatch(fetchTickets()); expect(store.getState()).toEqual({ + cache: {}, + loaders: {}, users: { [mockUser.id]: deserializeUser(mockUser) }, tickets: { [mockTicket.id]: deserializeTicket(mockTicket) }, }); @@ -216,7 +233,7 @@ test("error handling", () => { const action = api.create("/error", { supervisor: takeEvery }); - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); store.run(api.register); store.dispatch(action()); expect(called).toBe(true); @@ -242,7 +259,7 @@ test("error handling inside create", () => { } }, ); - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); store.run(api.register); store.dispatch(action()); expect(called).toBe(true); @@ -271,9 +288,7 @@ test("error inside endpoint mdw", () => { ); const store = createStore({ - initialState: { - users: {}, - }, + schemas: [createSchema()], }); store.run(query.register); store.dispatch(fetchUsers()); @@ -306,7 +321,7 @@ test("create fn is an array", () => { }, ]); - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); store.run(api.register); store.dispatch(action()); }); @@ -338,7 +353,7 @@ test("run() on endpoint action - should run the effect", () => { }, ); - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); store.run(api.register); store.dispatch(action2()); expect(acc).toBe("ab"); @@ -379,7 +394,7 @@ test("run() on endpoint action with payload - should run the effect", () => { }, ); - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); store.run(api.register); store.dispatch(action2()); expect(acc).toBe("ab"); @@ -426,7 +441,7 @@ test("middleware order of execution", async () => { }, ); - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); store.run(api.register); store.dispatch(action()); @@ -460,7 +475,7 @@ test("retry with actionFn", async () => { } }); - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); store.run(api.register); store.dispatch(action()); @@ -495,7 +510,7 @@ test("retry with actionFn with payload", async () => { }, ); - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); store.run(api.register); store.dispatch(action({ page: 1 })); @@ -530,7 +545,7 @@ test("should only call thunk once", () => { }, ); - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); store.run(api.register); store.dispatch(action2()); expect(acc).toBe("a"); @@ -540,7 +555,7 @@ test("should be able to create thunk after `register()`", () => { expect.assertions(1); const api = createThunks(); api.use(api.routes()); - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); store.run(api.register); let acc = ""; @@ -560,7 +575,7 @@ test("should warn when calling thunk before registered", () => { }; const api = createThunks(); api.use(api.routes()); - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); const action = api.create("/users"); store.dispatch(action()); @@ -572,7 +587,7 @@ test("it should call the api once even if we register it twice", () => { expect.assertions(1); const api = createThunks(); api.use(api.routes()); - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); store.run(api.register); store.run(api.register); @@ -592,7 +607,7 @@ test("should call the API only once, even if registered multiple times, with mul const api2 = createThunks(); api2.use(api2.routes()); - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); store.run(api1.register); store.run(api1.register); @@ -623,7 +638,7 @@ test("should unregister the thunk when the registration function exits", async ( const api1 = createThunks(); api1.use(api1.routes()); - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); const task = store.run(api1.register); await task.halt(); store.run(api1.register); @@ -641,8 +656,8 @@ test("should allow multiple stores to register a thunk", () => { expect.assertions(1); const api1 = createThunks(); api1.use(api1.routes()); - const storeA = createStore({ initialState: {} }); - const storeB = createStore({ initialState: {} }); + const storeA = createStore({ schemas: [createSchema()] }); + const storeB = createStore({ schemas: [createSchema()] }); storeA.run(api1.register); storeB.run(api1.register); let acc = ""; @@ -682,7 +697,7 @@ describe(".manage", () => { const thunk = createThunks(); thunk.use(thunk.routes()); const TestContext = thunk.manage("test:context", guessAge()); - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); store.run(thunk.register); let acc = ""; const action = thunk.create("/users", function* (payload, next) { @@ -700,7 +715,7 @@ describe(".manage", () => { const thunk = createThunks(); thunk.use(thunk.routes()); const TestContext = thunk.manage("test:context", guessAge()); - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); store.run(thunk.register); let acc = ""; const action = thunk.create("/users", function* (payload, next) { @@ -719,7 +734,7 @@ describe(".manage", () => { const thunk = createThunks(); thunk.use(thunk.routes()); const TestContext = thunk.manage("test:context", guessAge()); - const store = createStore({ initialState: {} }); + const store = createStore({ schemas: [createSchema()] }); store.run(thunk.register); let guess = 0; let acc = 0; From 04d709bfc809065318e2095d0e9898ff46a88fd3 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Sun, 1 Feb 2026 12:17:14 -0600 Subject: [PATCH 13/35] type test --- src/test/slice-types.test.ts | 479 +++++++++++++++++++++++++++++++++++ 1 file changed, 479 insertions(+) create mode 100644 src/test/slice-types.test.ts diff --git a/src/test/slice-types.test.ts b/src/test/slice-types.test.ts new file mode 100644 index 00000000..03274c5b --- /dev/null +++ b/src/test/slice-types.test.ts @@ -0,0 +1,479 @@ +/** + * Type tests for slice creation + * + * These tests verify the type inference and type safety of slice creation. + * They don't run at runtime - they verify types at compile time. + */ +import { assertType, describe, expectTypeOf, test } from "vitest"; +import type { AnyState } from "../types.js"; +import { createSchema, slice } from "../store/index.js"; +import type { + AnyOutput, + LoaderOutput, + NumOutput, + ObjOutput, + StrOutput, + TableOutput, +} from "../store/slice/index.js"; +import type { FxSchema } from "../store/types.js"; + +// ============================================================================= +// Test Entity Types +// ============================================================================= + +interface User { + id: string; + name: string; + email: string; +} + +interface Post { + id: string; + title: string; + authorId: string; +} + +const emptyUser: User = { id: "", name: "", email: "" }; + +// ============================================================================= +// slice.str() type tests +// ============================================================================= + +describe("slice.str types", () => { + test("str factory returns a function that produces StrOutput", () => { + const strFactory = slice.str(); + expectTypeOf(strFactory).toBeFunction(); + expectTypeOf(strFactory).parameter(0).toBeString(); + + const strSlice = strFactory("token"); + expectTypeOf(strSlice).toMatchTypeOf>(); + }); + + test("str slice has correct initialState type", () => { + const strSlice = slice.str()("token"); + expectTypeOf(strSlice.initialState).toBeString(); + }); + + test("str slice set accepts string", () => { + const strSlice = slice.str()("token"); + const updater = strSlice.set("new-value"); + expectTypeOf(updater).toBeFunction(); + expectTypeOf(updater).parameter(0).toMatchTypeOf(); + }); + + test("str slice select returns string", () => { + const strSlice = slice.str()("token"); + expectTypeOf(strSlice.select).returns.toBeString(); + }); + + test("str with custom initial value", () => { + const strSlice = slice.str("default-token")("token"); + expectTypeOf(strSlice.initialState).toBeString(); + }); +}); + +// ============================================================================= +// slice.num() type tests +// ============================================================================= + +describe("slice.num types", () => { + test("num factory returns a function that produces NumOutput", () => { + const numFactory = slice.num(); + expectTypeOf(numFactory).toBeFunction(); + expectTypeOf(numFactory).parameter(0).toBeString(); + + const numSlice = numFactory("counter"); + expectTypeOf(numSlice).toMatchTypeOf>(); + }); + + test("num slice has correct initialState type", () => { + const numSlice = slice.num()("counter"); + expectTypeOf(numSlice.initialState).toBeNumber(); + }); + + test("num slice set accepts number", () => { + const numSlice = slice.num()("counter"); + const updater = numSlice.set(42); + expectTypeOf(updater).toBeFunction(); + }); + + test("num slice increment/decrement accept optional number", () => { + const numSlice = slice.num()("counter"); + expectTypeOf(numSlice.increment) + .parameter(0) + .toEqualTypeOf(); + expectTypeOf(numSlice.decrement) + .parameter(0) + .toEqualTypeOf(); + }); + + test("num slice select returns number", () => { + const numSlice = slice.num()("counter"); + expectTypeOf(numSlice.select).returns.toBeNumber(); + }); +}); + +// ============================================================================= +// slice.any() type tests +// ============================================================================= + +describe("slice.any types", () => { + test("any factory infers type from initial value", () => { + const boolFactory = slice.any(false); + const boolSlice = boolFactory("enabled"); + expectTypeOf(boolSlice).toMatchTypeOf>(); + expectTypeOf(boolSlice.initialState).toBeBoolean(); + }); + + test("any slice set accepts the inferred type", () => { + const boolSlice = slice.any(false)("enabled"); + const updater = boolSlice.set(true); + expectTypeOf(updater).toBeFunction(); + + // @ts-expect-error - should not accept string + boolSlice.set("invalid"); + }); + + test("any slice select returns the inferred type", () => { + const boolSlice = slice.any(false)("enabled"); + expectTypeOf(boolSlice.select).returns.toBeBoolean(); + }); + + test("any with complex type", () => { + type Theme = "light" | "dark" | "system"; + const themeSlice = slice.any("system")("theme"); + expectTypeOf(themeSlice.initialState).toEqualTypeOf(); + expectTypeOf(themeSlice.select).returns.toEqualTypeOf(); + }); + + test("any with array type", () => { + const tagsSlice = slice.any([])("tags"); + expectTypeOf(tagsSlice.initialState).toEqualTypeOf(); + expectTypeOf(tagsSlice.select).returns.toEqualTypeOf(); + }); +}); + +// ============================================================================= +// slice.obj() type tests +// ============================================================================= + +describe("slice.obj types", () => { + test("obj factory infers type from initial value", () => { + const userFactory = slice.obj(emptyUser); + const userSlice = userFactory("currentUser"); + expectTypeOf(userSlice).toMatchTypeOf>(); + }); + + test("obj slice has correct initialState type", () => { + const userSlice = slice.obj(emptyUser)("currentUser"); + expectTypeOf(userSlice.initialState).toEqualTypeOf(); + }); + + test("obj slice set accepts the object type", () => { + const userSlice = slice.obj(emptyUser)("currentUser"); + const updater = userSlice.set({ + id: "1", + name: "Test", + email: "test@example.com", + }); + expectTypeOf(updater).toBeFunction(); + }); + + test("obj slice update has typed key and value", () => { + const userSlice = slice.obj(emptyUser)("currentUser"); + + // Valid updates + userSlice.update({ key: "name", value: "New Name" }); + userSlice.update({ key: "email", value: "new@example.com" }); + + // @ts-expect-error - invalid key + userSlice.update({ key: "invalid", value: "test" }); + + // @ts-expect-error - wrong value type for key + userSlice.update({ key: "name", value: 123 }); + }); + + test("obj slice select returns the object type", () => { + const userSlice = slice.obj(emptyUser)("currentUser"); + expectTypeOf(userSlice.select).returns.toEqualTypeOf(); + }); +}); + +// ============================================================================= +// slice.table() type tests +// ============================================================================= + +describe("slice.table types", () => { + test("table factory returns TableOutput", () => { + const tableFactory = slice.table(); + const usersSlice = tableFactory("users"); + expectTypeOf(usersSlice).toMatchTypeOf< + TableOutput + >(); + }); + + test("table with empty returns non-undefined selectById", () => { + const usersSlice = slice.table({ empty: emptyUser })("users"); + expectTypeOf(usersSlice).toMatchTypeOf>(); + + // selectById should return User (not User | undefined) + expectTypeOf(usersSlice.selectById).returns.toEqualTypeOf(); + expectTypeOf(usersSlice.findById).returns.toEqualTypeOf(); + }); + + test("table without empty returns possibly undefined selectById", () => { + const usersSlice = slice.table()("users"); + + // selectById should return User | undefined + expectTypeOf(usersSlice.selectById).returns.toEqualTypeOf< + User | undefined + >(); + }); + + test("table slice add accepts correct record type", () => { + const usersSlice = slice.table()("users"); + const updater = usersSlice.add({ + "1": { id: "1", name: "Alice", email: "alice@example.com" }, + }); + expectTypeOf(updater).toBeFunction(); + }); + + test("table slice patch accepts partial entity", () => { + const usersSlice = slice.table()("users"); + // patch should accept Partial for each key + usersSlice.patch({ "1": { name: "Updated" } }); + }); + + test("table slice selectTable returns correct record type", () => { + const usersSlice = slice.table()("users"); + expectTypeOf(usersSlice.selectTable).returns.toMatchTypeOf< + Record + >(); + }); + + test("table slice selectTableAsList returns array", () => { + const usersSlice = slice.table()("users"); + expectTypeOf(usersSlice.selectTableAsList).returns.toEqualTypeOf(); + }); + + test("table slice selectByIds returns array", () => { + const usersSlice = slice.table()("users"); + expectTypeOf(usersSlice.selectByIds).returns.toEqualTypeOf(); + }); + + test("table with empty factory function", () => { + const usersSlice = slice.table({ empty: () => emptyUser })("users"); + expectTypeOf(usersSlice.empty).toEqualTypeOf(); + expectTypeOf(usersSlice.selectById).returns.toEqualTypeOf(); + }); +}); + +// ============================================================================= +// slice.loaders() type tests +// ============================================================================= + +describe("slice.loaders types", () => { + test("loaders factory returns LoaderOutput", () => { + const loadersFactory = slice.loaders(); + const loadersSlice = loadersFactory("loaders"); + expectTypeOf(loadersSlice).toMatchTypeOf< + LoaderOutput + >(); + }); + + test("loaders slice start/success/error accept LoaderPayload", () => { + const loadersSlice = slice.loaders()("loaders"); + + // Basic usage with just id + loadersSlice.start({ id: "fetch-users" }); + loadersSlice.success({ id: "fetch-users" }); + loadersSlice.error({ id: "fetch-users" }); + + // With message + loadersSlice.error({ id: "fetch-users", message: "Failed to fetch" }); + }); + + test("loaders with custom meta type", () => { + interface LoaderMeta { + endpoint: string; + retryCount: number; + } + + const loadersSlice = slice.loaders()("loaders"); + + // Should accept meta with correct type + loadersSlice.start({ + id: "fetch-users", + meta: { endpoint: "/api/users", retryCount: 0 }, + }); + }); + + test("loaders selectById returns LoaderState", () => { + const loadersSlice = slice.loaders()("loaders"); + // Type-only test - we check the return type without calling the selector + type SelectByIdResult = ReturnType; + + expectTypeOf().toHaveProperty("status"); + expectTypeOf().toHaveProperty("isLoading"); + expectTypeOf().toHaveProperty("isError"); + expectTypeOf().toHaveProperty("isSuccess"); + expectTypeOf().toHaveProperty("isIdle"); + }); +}); + +// ============================================================================= +// createSchema type tests +// ============================================================================= + +describe("createSchema types", () => { + test("schema infers state type from slices", () => { + const schema = createSchema({ + users: slice.table({ empty: emptyUser }), + posts: slice.table(), + token: slice.str(), + counter: slice.num(), + isDarkMode: slice.any(false), + currentUser: slice.obj(emptyUser), + cache: slice.table({ empty: {} }), + loaders: slice.loaders(), + }); + + // Schema should have the correct slice outputs + expectTypeOf(schema.users).toMatchTypeOf>(); + expectTypeOf(schema.posts).toMatchTypeOf< + TableOutput + >(); + expectTypeOf(schema.token).toMatchTypeOf>(); + expectTypeOf(schema.counter).toMatchTypeOf>(); + expectTypeOf(schema.currentUser).toMatchTypeOf>(); + }); + + test("schema initialState has correct shape", () => { + const schema = createSchema({ + users: slice.table(), + token: slice.str("default"), + counter: slice.num(10), + cache: slice.table({ empty: {} }), + loaders: slice.loaders(), + }); + + expectTypeOf(schema.initialState).toHaveProperty("users"); + expectTypeOf(schema.initialState).toHaveProperty("token"); + expectTypeOf(schema.initialState).toHaveProperty("counter"); + expectTypeOf(schema.initialState).toHaveProperty("cache"); + expectTypeOf(schema.initialState).toHaveProperty("loaders"); + }); + + test("default schema has cache and loaders", () => { + const schema = createSchema(); + + expectTypeOf(schema).toHaveProperty("cache"); + expectTypeOf(schema).toHaveProperty("loaders"); + expectTypeOf(schema).toHaveProperty("update"); + expectTypeOf(schema).toHaveProperty("initialState"); + expectTypeOf(schema).toHaveProperty("reset"); + }); + + test("schema update accepts store updater", () => { + const schema = createSchema({ + counter: slice.num(), + cache: slice.table({ empty: {} }), + loaders: slice.loaders(), + }); + + // update should accept the slice updater functions + expectTypeOf(schema.update).toBeFunction(); + }); +}); + +// ============================================================================= +// Composite slice type tests (state parameter inference) +// ============================================================================= + +describe("state parameter inference", () => { + test("slice methods should work with composed state", () => { + // This tests that slice methods can be used correctly within schema.update() + // The state parameter S should be inferred correctly from the schema + + const schema = createSchema({ + users: slice.table({ empty: emptyUser }), + counter: slice.num(), + cache: slice.table({ empty: {} }), + loaders: slice.loaders(), + }); + + // These should all type-check correctly + const addUser = schema.users.add({ "1": emptyUser }); + const increment = schema.counter.increment(); + + expectTypeOf(addUser).toBeFunction(); + expectTypeOf(increment).toBeFunction(); + }); +}); + +// ============================================================================= +// Edge cases and advanced scenarios +// ============================================================================= + +describe("advanced type scenarios", () => { + test("nested object in slice.obj", () => { + interface Settings { + theme: { + primary: string; + secondary: string; + }; + notifications: { + email: boolean; + push: boolean; + }; + } + + const settingsSlice = slice.obj({ + theme: { primary: "#000", secondary: "#fff" }, + notifications: { email: true, push: false }, + })("settings"); + + expectTypeOf(settingsSlice.select).returns.toEqualTypeOf(); + + // update should work with nested keys + settingsSlice.update({ + key: "theme", + value: { primary: "#111", secondary: "#eee" }, + }); + }); + + test("table with complex entity", () => { + interface ComplexEntity { + id: string; + data: { + nested: { + value: number; + }; + }; + tags: string[]; + metadata: Record; + } + + const complexSlice = slice.table()("complex"); + expectTypeOf(complexSlice.selectTableAsList).returns.toEqualTypeOf< + ComplexEntity[] + >(); + }); + + test("multiple tables with different entity types", () => { + const schema = createSchema({ + users: slice.table({ empty: emptyUser }), + posts: slice.table({ empty: { id: "", title: "", authorId: "" } }), + cache: slice.table({ empty: {} }), + loaders: slice.loaders(), + }); + + // Each table should maintain its own entity type + // Type-only checks - we verify return types without calling selectors + type UserListResult = ReturnType; + type PostListResult = ReturnType; + + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); +}); From 781b13993b224b6a8a120d89371c310588c26f5c Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Tue, 24 Feb 2026 14:39:55 -0600 Subject: [PATCH 14/35] full type refactor removing low value generics and overloads --- biome.json | 4 +- examples/yjs/src/store/schema.ts | 4 +- package.json | 1 + src/action.ts | 2 +- src/fx/supervisor.ts | 4 +- src/mdw/store.ts | 46 +++-- src/query/types.ts | 4 +- src/react.ts | 71 +++----- src/store/context.ts | 9 +- src/store/fx.ts | 60 +++--- src/store/schema.ts | 188 ++++++++++--------- src/store/slice/any.ts | 51 ++++-- src/store/slice/loaders.ts | 165 ++++++++--------- src/store/slice/num.ts | 51 +++--- src/store/slice/obj.ts | 67 ++++--- src/store/slice/str.ts | 44 +++-- src/store/slice/table.ts | 282 ++++++++++++++--------------- src/store/store.ts | 97 ++++------ src/store/types.ts | 64 +++++-- src/test/schema.test.ts | 6 +- src/test/slice-types.test.ts | 94 +++++----- src/test/store.test.ts | 10 +- src/test/store/slice/obj.test.ts | 6 +- src/test/store/slice/table.test.ts | 19 +- src/test/supervisor.test.ts | 18 +- src/types.ts | 35 ++-- tsconfig.json | 1 + 27 files changed, 728 insertions(+), 675 deletions(-) diff --git a/biome.json b/biome.json index 382fc626..7687dcde 100644 --- a/biome.json +++ b/biome.json @@ -13,8 +13,8 @@ "rules": { "recommended": true, "suspicious": { - "noExplicitAny": "off", - "noImplicitAnyLet": "off", + "noExplicitAny": "warn", + "noImplicitAnyLet": "warn", "noConfusingVoidType": "off" }, "correctness": { diff --git a/examples/yjs/src/store/schema.ts b/examples/yjs/src/store/schema.ts index 09ef7873..61db5462 100644 --- a/examples/yjs/src/store/schema.ts +++ b/examples/yjs/src/store/schema.ts @@ -24,14 +24,14 @@ export function createYjsSchema< root.set("data", data); data.set("items", new Y.Array()); - return createSchemaWithUpdater(slices, { + return createSchemaWithUpdater(slices, { createUpdateMdw: (store: FxStore) => { // Set up observer to sync Y.Doc changes to store root.observeDeep( (events: Y.YEvent[], transaction: Y.Transaction) => { console.log("Y.Doc changed", { events, transaction }); store.setState(root.toJSON() as S); - } + }, ); // Initialize store with current Y.Doc state diff --git a/package.json b/package.json index 93a0b458..6d86665e 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "module": "./dist/esm/index.js", "scripts": { "test": "vitest --exclude examples", + "typecheck": "tsc --noEmit", "lint": "biome check --write", "fmt": "biome check --write --linter-enabled=false", "ci": "biome ci .", diff --git a/src/action.ts b/src/action.ts index 63d931f9..1b7f6302 100644 --- a/src/action.ts +++ b/src/action.ts @@ -124,7 +124,7 @@ export function* waitFor(predicate: () => Operation): Operation { } export function getIdFromAction( - action: ActionWithPayload<{ key: string }> | ActionFnWithPayload, + action: ActionWithPayload<{ key: string }> | ActionFnWithPayload, ): string { return typeof action === "function" ? action.toString() : action.payload.key; } diff --git a/src/fx/supervisor.ts b/src/fx/supervisor.ts index 22780507..599257dc 100644 --- a/src/fx/supervisor.ts +++ b/src/fx/supervisor.ts @@ -33,7 +33,9 @@ export function supervise( yield* put({ type: `${API_ACTION_PREFIX}supervise`, payload: res.error, - meta: `Exception caught, waiting ${waitFor}ms before restarting operation`, + meta: { + message: `Exception caught, waiting ${waitFor}ms before restarting operation`, + }, }); yield* sleep(waitFor); } diff --git a/src/mdw/store.ts b/src/mdw/store.ts index afdf1a62..0e65d308 100644 --- a/src/mdw/store.ts +++ b/src/mdw/store.ts @@ -6,17 +6,14 @@ import { select, updateStore, } from "../store/index.js"; -import type { AnyState, Next } from "../types.js"; +import type { AnyState, LoaderState, Next } from "../types.js"; import { nameParser } from "./fetch.js"; import { actions, customKey, err, queryCtx } from "./query.js"; -export interface ApiMdwProps< - Ctx extends ApiCtx = ApiCtx, - M extends AnyState = AnyState, -> { +export interface ApiMdwProps { schema: { - loaders: LoaderOutput; - cache: TableOutput; + loaders: LoaderOutput; + cache: TableOutput; }; errorFn?: (ctx: Ctx) => string; } @@ -44,9 +41,7 @@ function isErrorLike(err: unknown): err is ErrorLike { * - {@link mdw.loaderApi} * - {@link mdw.cache} */ -export function api( - props: ApiMdwProps, -) { +export function api(props: ApiMdwProps) { return compose([ err, actions, @@ -63,13 +58,14 @@ export function api( * which is where we store JSON data from the {@link mdw.fetch} middleware. */ export function cache(schema: { - cache: TableOutput; + cache: TableOutput; }) { return function* cache(ctx: Ctx, next: Next) { ctx.cacheData = yield* select(schema.cache.selectById, { id: ctx.key }); yield* next(); if (!ctx.cache) return; - let data; + // biome-ignore lint/suspicious/noExplicitAny: generically add the return to cache + let data: any; if (ctx.json.ok) { data = ctx.json.value; } else { @@ -83,9 +79,7 @@ export function cache(schema: { /** * This middleware will track the status of a middleware fn */ -export function loader(schema: { - loaders: LoaderOutput; -}) { +export function loader(schema: { loaders: LoaderOutput }) { return function* ( ctx: Ctx, next: Next, @@ -95,7 +89,7 @@ export function loader(schema: { schema.loaders.start({ id: ctx.key }), ]); - if (!ctx.loader) ctx.loader = {} as any; + if (!ctx.loader) ctx.loader = {}; try { yield* next(); @@ -127,8 +121,9 @@ export function loader(schema: { }), ]); } finally { - const loaders = yield* select((s: any) => - schema.loaders.selectByIds(s, { ids: [ctx.name, ctx.key] }), + const loaders = yield* select( + (s: Parameters[0]) => + schema.loaders.selectByIds(s, { ids: [ctx.name, ctx.key] }), ); const ids = loaders .filter((loader) => loader.status === "loading") @@ -150,17 +145,17 @@ function defaultErrorFn(ctx: Ctx) { /** * This middleware will track the status of a fetch request. */ -export function loaderApi< - Ctx extends ApiCtx = ApiCtx, - S extends AnyState = AnyState, ->({ schema, errorFn = defaultErrorFn }: ApiMdwProps) { +export function loaderApi({ + schema, + errorFn = defaultErrorFn, +}: ApiMdwProps) { return function* trackLoading(ctx: Ctx, next: Next) { try { yield* updateStore([ schema.loaders.start({ id: ctx.name }), schema.loaders.start({ id: ctx.key }), ]); - if (!ctx.loader) ctx.loader = {} as any; + if (!ctx.loader) ctx.loader = {}; yield* next(); @@ -208,8 +203,9 @@ export function loaderApi< }), ]); } finally { - const loaders = yield* select((s: any) => - schema.loaders.selectByIds(s, { ids: [ctx.name, ctx.key] }), + const loaders = yield* select( + (s: Parameters[0]) => + schema.loaders.selectByIds(s, { ids: [ctx.name, ctx.key] }), ); const ids = loaders .filter((loader) => loader.status === "loading") diff --git a/src/query/types.ts b/src/query/types.ts index bb8f674a..da30aee9 100644 --- a/src/query/types.ts +++ b/src/query/types.ts @@ -23,7 +23,7 @@ export interface ThunkCtx

extends Payload

{ } export interface ThunkCtxWLoader extends ThunkCtx { - loader: Omit, "id"> | null; + loader: Omit | null; } export interface LoaderCtx

extends ThunkCtx

{ @@ -64,7 +64,7 @@ export interface FetchJsonCtx

export interface ApiCtx extends FetchJsonCtx { actions: Action[]; - loader: Omit, "id"> | null; + loader: Omit | null; // should we cache ctx.json? cache: boolean; // should we use mdw.stub? diff --git a/src/react.ts b/src/react.ts index 0e3dd93c..a99b7538 100644 --- a/src/react.ts +++ b/src/react.ts @@ -15,7 +15,7 @@ import { import type { LoaderOutput } from "./store/slice/loaders.js"; import type { TableOutput } from "./store/slice/table.js"; import type { FxMap } from "./store/types.js"; -import type { AnyState, LoaderState } from "./types.js"; +import type { LoaderState } from "./types.js"; import type { ActionFn, ActionFnWithPayload } from "./types.js"; export { useDispatch, useSelector } from "react-redux"; @@ -29,6 +29,9 @@ const { createElement: h, } = React; +type WithLoadersMap = FxMap & { loaders: (n: string) => LoaderOutput }; +type WithCacheMap = FxMap & { cache: (n: string) => TableOutput }; + export interface UseApiProps

extends LoaderState { trigger: (p: P) => void; action: ActionFnWithPayload

; @@ -47,79 +50,66 @@ export type UseApiResult = | UseApiSimpleProps | UseApiAction; -export interface UseCacheResult +export interface UseCacheResult extends UseApiAction { data: D | null; } -const SchemaContext = createContext | null>(null); +const SchemaContext = createContext | null>(null); // Strongly-typed overload that ties `store` and `schema` to the same state type S -export function Provider(props: { - store: FxStore; - schema?: FxSchema; +export function Provider(props: { + store: FxStore; + schema?: FxSchema; children?: React.ReactNode; }): React.ReactElement; // Provider overload accepting any store and schema to avoid variance issues export function Provider(props: { - store: FxStore; - schema?: FxSchema; + store: FxStore; + schema?: FxSchema; children?: React.ReactNode; }): React.ReactElement; // Runtime implementation uses `AnyState` for minimal widening export function Provider(props: { - store: FxStore; - schema?: FxSchema; + store: FxStore; + schema?: FxSchema; children?: React.ReactNode; }) { const { store, schema, children } = props; // Use provided schema or pull from store - const schemaValue = (schema ?? store.schema) as FxSchema; + const schemaValue = (schema ?? store.schema) as FxSchema; const inner = h(SchemaContext.Provider, { value: schemaValue }, children); return h(ReduxProvider, { store, children: inner }); } -export function useSchema() { +export function useSchema() { const ctx = useContext(SchemaContext); if (!ctx) throw new Error("No Schema available in context"); - return ctx as FxSchema; + return ctx as FxSchema; } // Typed variant for schemas that include `loaders`. -export function useSchemaWithLoaders(): FxSchema< - AnyState, - { loaders: (n: string) => LoaderOutput } ->; -export function useSchemaWithLoaders< - S extends { loaders: LoaderOutput["initialState"] }, ->(): FxSchema LoaderOutput }>; +export function useSchemaWithLoaders(): FxSchema; +export function useSchemaWithLoaders(): FxSchema; export function useSchemaWithLoaders() { const ctx = useContext(SchemaContext); if (!ctx) throw new Error("No Schema available in context"); - return ctx as FxSchema< - any, - { loaders: (n: string) => LoaderOutput } - >; + return ctx as FxSchema; } // Typed variant for schemas that include `cache`. -export function useSchemaWithCache(): FxSchema< - AnyState, - { cache: (n: string) => TableOutput } ->; -export function useSchemaWithCache< - S extends { cache: TableOutput["initialState"] }, ->(): FxSchema TableOutput }>; +export function useSchemaWithCache(): FxSchema; +export function useSchemaWithCache(): FxSchema; export function useSchemaWithCache() { const ctx = useContext(SchemaContext); if (!ctx) throw new Error("No Schema available in context"); - return ctx as FxSchema TableOutput }>; + return ctx as FxSchema; } -export function useStore() { - return useReduxStore() as FxStore; +export function useStore() { + return useReduxStore() as FxStore; } /** @@ -146,12 +136,11 @@ export function useStore() { * ``` */ export function useLoader( - action: ThunkAction | ActionFnWithPayload, -): LoaderState; -export function useLoader< - S extends { loaders: LoaderOutput["initialState"] }, - M extends AnyState = any, ->(action: ThunkAction | ActionFnWithPayload): LoaderState; + action: ThunkAction | ActionFnWithPayload, +): LoaderState; +export function useLoader( + action: ThunkAction | ActionFnWithPayload, +): LoaderState; export function useLoader(action: any) { const schema = useSchemaWithLoaders(); const id = getIdFromAction(action); @@ -256,7 +245,7 @@ export function useQuery

>( */ export function useCache(action: ThunkAction): UseCacheResult; export function useCache< - S extends { cache: TableOutput["initialState"] }, + S extends { cache: TableOutput["initialState"] }, P = any, ApiSuccess = any, >( diff --git a/src/store/context.ts b/src/store/context.ts index ed69fe71..d72b8812 100644 --- a/src/store/context.ts +++ b/src/store/context.ts @@ -1,9 +1,12 @@ import { type Channel, createChannel, createContext } from "effection"; -import type { AnyState } from "../types.js"; -import type { FxStore } from "./types.js"; +import type { FxMap, FxStore } from "./types.js"; export const StoreUpdateContext = createContext>( "starfx:store:update", createChannel(), ); -export const StoreContext = createContext>("starfx:store"); +export const StoreContext = createContext>("starfx:store"); + +export function* expectStore() { + return (yield* StoreContext.expect()) as FxStore; +} diff --git a/src/store/fx.ts b/src/store/fx.ts index 82e5a40f..dd74adb7 100644 --- a/src/store/fx.ts +++ b/src/store/fx.ts @@ -3,9 +3,14 @@ import { getIdFromAction, take } from "../action.js"; import { parallel, safe } from "../fx/index.js"; import type { ThunkAction } from "../query/index.js"; import type { ActionFnWithPayload, AnyState, LoaderState } from "../types.js"; -import { StoreContext } from "./context.js"; +import { expectStore } from "./context.js"; import type { LoaderOutput } from "./slice/loaders.js"; -import type { FxStore, StoreUpdater, UpdaterCtx } from "./types.js"; +import type { + FxMap, + SliceFromSchema, + StoreUpdater, + UpdaterCtx, +} from "./types.js"; /** * Updates the store using the default schema's update method. @@ -14,31 +19,30 @@ import type { FxStore, StoreUpdater, UpdaterCtx } from "./types.js"; export function* updateStore( updater: StoreUpdater | StoreUpdater[], ): Operation> { - const store = yield* StoreContext.expect(); - const st = store as FxStore; - const ctx = yield* st.schema.update(updater); - return ctx; + const store = yield* expectStore(); + const ctx = yield* store.schema.update( + updater as + | StoreUpdater> + | StoreUpdater>[], + ); + return ctx as UpdaterCtx; } -export function select(selectorFn: (s: S) => R): Operation; -export function select( - selectorFn: (s: S, p: P) => R, - p: P, -): Operation; -export function* select( - selectorFn: (s: S, p?: P) => R, - p?: P, +export function* select( + selectorFn: (s: S, ...args: Args) => R, + ...args: Args ): Operation { - const store = yield* StoreContext.expect(); - return selectorFn(store.getState() as S, p); + const store = yield* expectStore(); + return selectorFn(store.getState() as S, ...args); } -export function* waitForLoader( - loaders: LoaderOutput, - action: ThunkAction | ActionFnWithPayload, -): Operation> { +export function* waitForLoader( + loaders: LoaderOutput, + action: ThunkAction | ActionFnWithPayload, +): Operation { const id = getIdFromAction(action); - const selector = (s: AnyState) => loaders.selectById(s, { id }); + const selector = (s: Parameters[0]) => + loaders.selectById(s, { id }); // check for done state on init let loader = yield* select(selector); @@ -55,18 +59,16 @@ export function* waitForLoader( } } -export function* waitForLoaders( - loaders: LoaderOutput, - actions: (ThunkAction | ActionFnWithPayload)[], -): Operation>[]> { +export function* waitForLoaders( + loaders: LoaderOutput, + actions: (ThunkAction | ActionFnWithPayload)[], +): Operation[]> { const ops = actions.map((action) => () => waitForLoader(loaders, action)); - const group = yield* parallel>(ops); + const group = yield* parallel(ops); return yield* group; } -export function createTracker>( - loader: LoaderOutput, -) { +export function createTracker(loader: LoaderOutput) { return (id: string) => { return function* ( op: () => Operation>, diff --git a/src/store/schema.ts b/src/store/schema.ts index d1b5b25c..a51d71e9 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -1,33 +1,21 @@ +import { lift } from "effection"; import { type Draft, enablePatches, produceWithPatches } from "immer"; +import { API_ACTION_PREFIX, ActionContext, emit } from "../action.js"; import { type BaseMiddleware, compose } from "../compose.js"; -import { type AnyState, type Next, StoreContext } from "../index.js"; +import { type AnyState, ListenersContext, type Next } from "../index.js"; +import { StoreUpdateContext, expectStore } from "./context.js"; import { slice } from "./slice/index.js"; -import type { LoaderOutput } from "./slice/loaders.js"; -import type { TableOutput } from "./slice/table.js"; -import { StoreTailMdwContext } from "./store.js"; import type { - BaseSchema, + FactoryInitial, + FactoryReturn, FxMap, FxSchema, FxStore, + SliceFromSchema, StoreUpdater, UpdaterCtx, } from "./types.js"; -// Default FxMap that includes the built-in `cache` and `loaders` slices -type DefaultFxMap = FxMap & { - cache: (n: string) => TableOutput; - loaders: (n: string) => LoaderOutput; -}; - -// Helper types to extract the factory return type and its initialState -type FactoryReturn = T extends (name: string) => infer R ? R : never; -type FactoryInitial = FactoryReturn> extends BaseSchema< - infer IS -> - ? IS - : never; - const defaultSchema = (): O => ({ cache: slice.table(), loaders: slice.loaders() }) as O; @@ -68,7 +56,39 @@ export interface CreateSchemaWithUpdaterOptions { * Factory function that creates the update middleware. * This is where you implement your state update logic (e.g., immer, plain objects, etc.) */ - createUpdateMdw: (store: FxStore) => BaseMiddleware>; + updateMdw: BaseMiddleware>; +} + +function* logMdw( + ctx: UpdaterCtx>, + next: Next, +) { + const signal = yield* ActionContext.expect(); + const action = { + type: `${API_ACTION_PREFIX}store`, + payload: ctx, + }; + + yield* lift(emit)({ signal, action }); + yield* next(); +} + +function* notifyChannelMdw( + _: UpdaterCtx>, + next: Next, +) { + const chan = yield* StoreUpdateContext.expect(); + yield* chan.send(); + yield* next(); +} + +function* notifyListenersMdw( + _: UpdaterCtx>, + next: Next, +) { + const listeners = yield* ListenersContext.expect(); + listeners.forEach((f) => f()); + yield* next(); } /** @@ -92,46 +112,41 @@ export interface CreateSchemaWithUpdaterOptions { * }); * ``` */ -export function createSchemaWithUpdater< - O extends FxMap, - S extends AnyState = { [key in keyof O]: FactoryInitial }, ->( +export function createSchemaWithUpdater( slices: O, { name = "default", middleware = [], - createUpdateMdw, - }: CreateSchemaWithUpdaterOptions, -): FxSchema { + updateMdw, + }: CreateSchemaWithUpdaterOptions>, +): FxSchema { const { db, initialState } = buildSlices(slices); // Precomputed middleware will be set on first update call - let composedMdw: ReturnType>> | null = null; - - function* update(ups: StoreUpdater | StoreUpdater[]) { - const store = yield* StoreContext.expect(); - const st = store as FxStore; - - // Lazily compose the full middleware stack on first call - // This allows the store tail middleware context to be set before we compose - if (!composedMdw) { - const storeTailMdw = yield* StoreTailMdwContext.expect(); - const updateMdw = createUpdateMdw(st); - - // Precompute full middleware: updateMdw -> user middleware -> store tail - // Cast the combined array to the explicit middleware type to appease TS - composedMdw = compose>([ - updateMdw, - ...(middleware ?? []), - ...storeTailMdw, - ] as BaseMiddleware>[]); - } - - const ctx: UpdaterCtx = { + const composedMdw: ReturnType< + typeof compose>> + > = compose>>([ + updateMdw, + ...middleware, + logMdw, + notifyChannelMdw, + notifyListenersMdw, + ]); + + function* update( + ups: StoreUpdater> | StoreUpdater>[], + ) { + const ctx: UpdaterCtx> = { updater: ups, patches: [], }; + if (!composedMdw) { + throw new Error( + "Schema update middleware not initialized. Ensure the store is properly initialized before dispatching updates.", + ); + } + yield* composedMdw(ctx); return ctx; @@ -139,37 +154,36 @@ export function createSchemaWithUpdater< function* reset(ignoreList: (string | number | symbol)[] = []) { return yield* update((s) => { - const state = s as Draft; - const stateObj = state as unknown as { [K in keyof S]: S[K] }; - const keep = { ...(initialState as S) } as S; - - for (const key of ignoreList as Array) { + const state = s as Draft>; + const stateObj = state as unknown as { + [K in keyof SliceFromSchema]: SliceFromSchema[K]; + }; + const keep = { + ...(initialState as SliceFromSchema), + } as SliceFromSchema; + + for (const key of ignoreList as Array>) { keep[key] = stateObj[key]; } - for (const key of Object.keys(stateObj) as Array) { + for (const key of Object.keys(stateObj) as Array< + keyof SliceFromSchema + >) { stateObj[key] = keep[key]; } }); } - const schema = db as FxSchema; + const schema = db as FxSchema; schema.name = name; schema.update = update; - schema.initialState = initialState as S; + schema.initialState = initialState as SliceFromSchema; schema.reset = reset; return schema; } -/** - * Creates a schema with immer-based state updates. - * This is the default implementation that uses immer's produceWithPatches. - */ -export function createSchema< - S extends AnyState, - O extends FxMap = DefaultFxMap, ->( +export function createSchema( slices?: O, options: { /** @@ -177,31 +191,33 @@ export function createSchema< * @default "default" */ name?: string; - middleware?: BaseMiddleware>[]; + middleware?: BaseMiddleware>>[]; } = {}, -): FxSchema { +): FxSchema { enablePatches(); - return createSchemaWithUpdater(slices ?? defaultSchema(), { + return createSchemaWithUpdater(slices ?? defaultSchema(), { name: options.name, middleware: options.middleware, - createUpdateMdw: (store: FxStore) => - function* updateMdw(ctx: UpdaterCtx, next: Next) { - const upds: StoreUpdater[] = Array.isArray(ctx.updater) - ? ctx.updater - : [ctx.updater]; - - const [nextState, patches, _] = produceWithPatches( - store.getState(), - (draft: Draft) => { - upds.forEach((updater) => updater(draft)); - }, - ); - ctx.patches = patches; - - store.setState(nextState); - - yield* next(); - }, + *updateMdw(ctx: UpdaterCtx>, next: Next) { + const store: FxStore = yield* expectStore(); + const upds: StoreUpdater>[] = Array.isArray( + ctx.updater, + ) + ? ctx.updater + : [ctx.updater]; + + const [nextState, patches, _] = produceWithPatches>( + store.getState(), + (draft: Draft>) => { + upds.forEach((updater) => updater(draft)); + }, + ); + ctx.patches = patches; + + store.setState(nextState); + + yield* next(); + }, }); } diff --git a/src/store/slice/any.ts b/src/store/slice/any.ts index 42a7d849..773f7241 100644 --- a/src/store/slice/any.ts +++ b/src/store/slice/any.ts @@ -1,37 +1,54 @@ -import type { AnyState } from "../../types.js"; -import type { BaseSchema } from "../types.js"; +import type { Draft, Immutable } from "immer"; +import type { BaseSchema, SliceState } from "../types.js"; -export interface AnyOutput extends BaseSchema { +type AnyRootState = SliceState; + +export interface AnyActions { + set: (v: V) => (s: Draft>) => void; + reset: () => (s: Draft>) => void; +} + +export interface AnySelectors { + select: (s: AnyRootState) => Immutable; +} + +// export interface AnyOutput extends BaseSchema { +// schema: "any"; +// initialState: V; +// set: (v: V) => (s: S) => void; +// reset: () => (s: S) => void; +// select: (s: S) => V; +// } + +export interface AnyOutput + extends BaseSchema, + AnyActions, + AnySelectors { schema: "any"; initialState: V; - set: (v: V) => (s: S) => void; - reset: () => (s: S) => void; - select: (s: S) => V; } -export function createAny({ +export function createAny({ name, initialState, }: { - name: keyof S; + name: keyof AnyRootState; initialState: V; -}): AnyOutput { +}): AnyOutput { return { schema: "any", - name: name as string, + name: String(name), initialState, set: (value) => (state) => { - (state as any)[name] = value; + Object.assign(state, { [name]: value }); }, reset: () => (state) => { - (state as any)[name] = initialState; - }, - select: (state) => { - return (state as any)[name]; + Object.assign(state, { [name]: initialState }); }, - }; + select: (state) => state[name], + } satisfies AnyOutput; } export function any(initialState: V) { - return (name: string) => createAny({ name, initialState }); + return (name: string) => createAny({ name, initialState }); } diff --git a/src/store/slice/loaders.ts b/src/store/slice/loaders.ts index 1f1f67e7..ac479c66 100644 --- a/src/store/slice/loaders.ts +++ b/src/store/slice/loaders.ts @@ -1,11 +1,11 @@ +import type { Draft, Immutable } from "immer"; import { createSelector } from "reselect"; import type { - AnyState, LoaderItemState, LoaderPayload, LoaderState, } from "../../types.js"; -import type { BaseSchema } from "../types.js"; +import type { BaseSchema, SliceState } from "../types.js"; interface PropId { id: string; @@ -17,23 +17,21 @@ interface PropIds { const excludesFalse = (n?: T): n is T => Boolean(n); -export function defaultLoaderItem( - li: Partial> = {}, -): LoaderItemState { +export function defaultLoaderItem( + li: Partial = {}, +): LoaderItemState { return { id: "", status: "idle", message: "", lastRun: 0, lastSuccess: 0, - meta: {} as M, + meta: {}, ...li, }; } -export function defaultLoader( - l: Partial> = {}, -): LoaderState { +export function defaultLoader(l: Partial = {}): LoaderState { const loading = defaultLoaderItem(l); return { ...loading, @@ -46,100 +44,87 @@ export function defaultLoader( loading.lastSuccess === 0, }; } +type LoaderTable = Record; -interface LoaderSelectors< - M extends AnyState = AnyState, - S extends AnyState = AnyState, -> { - findById: ( - d: Record>, - { id }: PropId, - ) => LoaderState; - findByIds: ( - d: Record>, - { ids }: PropIds, - ) => LoaderState[]; - selectTable: (s: S) => Record>; - selectTableAsList: (state: S) => LoaderItemState[]; - selectById: (s: S, p: PropId) => LoaderState; - selectByIds: (s: S, p: PropIds) => LoaderState[]; +export interface LoaderSelectors { + findById: (d: Immutable, p: PropId) => LoaderState; + findByIds: (d: Immutable, p: PropIds) => LoaderState[]; + selectTable: ( + s: Immutable>, + ) => Immutable; + selectTableAsList: ( + state: Immutable>, + ) => LoaderItemState[]; + selectById: (s: Immutable>, p: PropId) => LoaderState; + selectByIds: ( + s: Immutable>, + p: PropIds, + ) => LoaderState[]; } -function loaderSelectors< - M extends AnyState = AnyState, - S extends AnyState = AnyState, ->( - selectTable: (s: S) => Record>, -): LoaderSelectors { - const empty = defaultLoader(); - const tableAsList = ( - data: Record>, - ): LoaderItemState[] => Object.values(data).filter(excludesFalse); - - const findById = (data: Record>, { id }: PropId) => - defaultLoader(data[id]) || empty; - const findByIds = ( - data: Record>, - { ids }: PropIds, - ): LoaderState[] => - ids.map((id) => defaultLoader(data[id])).filter(excludesFalse); - const selectById = createSelector( - selectTable, - (_: S, p: PropId) => p.id, - (loaders, id): LoaderState => findById(loaders, { id }), - ); +function loaderSelectors(selectTable: LoaderSelectors["selectTable"]) { + const findById = ((data, { id }) => + defaultLoader(data[id])) satisfies LoaderSelectors["findById"]; - return { + const findByIds = ((data, { ids }) => + ids + .map((id) => defaultLoader(data[id])) + .filter(excludesFalse)) satisfies LoaderSelectors["findByIds"]; + + const selectors = { findById, findByIds, selectTable, - selectTableAsList: createSelector( - selectTable, - (data): LoaderItemState[] => tableAsList(data), + selectTableAsList: createSelector([selectTable], (data) => + Object.values(data).filter(excludesFalse), + ), + selectById: createSelector( + [selectTable, (_, p: PropId) => p.id], + (data, id) => findById(data, { id }), ), - selectById, selectByIds: createSelector( - selectTable, - (_: S, p: PropIds) => p.ids, - (loaders, ids) => findByIds(loaders, { ids }), + [selectTable, (_, p: PropIds) => p.ids], + (data, ids) => findByIds(data, { ids }), ), - }; + } satisfies LoaderSelectors; + + return selectors; } -export interface LoaderOutput< - M extends Record, - S extends AnyState, -> extends LoaderSelectors, - BaseSchema>> { +export interface LoaderActions { + start: (e: LoaderPayload) => (s: Draft>) => void; + success: (e: LoaderPayload) => (s: Draft>) => void; + error: (e: LoaderPayload) => (s: Draft>) => void; + reset: () => (s: Draft>) => void; + resetByIds: (ids: string[]) => (s: Draft>) => void; +} + +export interface LoaderOutput + extends BaseSchema, + LoaderActions, + LoaderSelectors { schema: "loader"; - initialState: Record>; - start: (e: LoaderPayload) => (s: S) => void; - success: (e: LoaderPayload) => (s: S) => void; - error: (e: LoaderPayload) => (s: S) => void; - reset: () => (s: S) => void; - resetByIds: (ids: string[]) => (s: S) => void; + initialState: LoaderTable; } const ts = () => new Date().getTime(); -export const createLoaders = < - M extends AnyState = AnyState, - S extends AnyState = AnyState, ->({ +export const createLoaders = ({ name, initialState = {}, }: { - name: keyof S; - initialState?: Record>; -}): LoaderOutput => { - const selectors = loaderSelectors((s: S) => s[name]); + name: keyof SliceState; + initialState?: LoaderTable; +}): LoaderOutput => { + const loaderInitialState = initialState ?? {}; + const selectors = loaderSelectors((s) => s[name]); - return { + const output = { schema: "loader", - name: name as string, - initialState, + name, + initialState: loaderInitialState, start: (e) => (s) => { - const table = selectors.selectTable(s); + const table = s[name]; const loader = table[e.id]; table[e.id] = defaultLoaderItem({ ...loader, @@ -149,7 +134,7 @@ export const createLoaders = < }); }, success: (e) => (s) => { - const table = selectors.selectTable(s); + const table = s[name]; const loader = table[e.id]; table[e.id] = defaultLoaderItem({ ...loader, @@ -159,7 +144,7 @@ export const createLoaders = < }); }, error: (e) => (s) => { - const table = selectors.selectTable(s); + const table = s[name]; const loader = table[e.id]; table[e.id] = defaultLoaderItem({ ...loader, @@ -168,20 +153,22 @@ export const createLoaders = < }); }, reset: () => (s) => { - (s as any)[name] = initialState; + const table = s[name]; + for (const key of Object.keys(table)) delete table[key]; + Object.assign(table, loaderInitialState); }, resetByIds: (ids: string[]) => (s) => { - const table = selectors.selectTable(s); + const table = s[name]; ids.forEach((id) => { delete table[id]; }); }, ...selectors, - }; + } satisfies LoaderOutput; + + return output; }; -export function loaders( - initialState?: Record>, -) { - return (name: string) => createLoaders({ name, initialState }); +export function loaders(initialState?: Record) { + return (name: string) => createLoaders({ name, initialState }); } diff --git a/src/store/slice/num.ts b/src/store/slice/num.ts index 1700a7cf..e1ad8595 100644 --- a/src/store/slice/num.ts +++ b/src/store/slice/num.ts @@ -1,49 +1,58 @@ -import type { AnyState } from "../../types.js"; -import type { BaseSchema } from "../types.js"; +import type { Draft, Immutable } from "immer"; +import type { BaseSchema, SliceState } from "../types.js"; -export interface NumOutput extends BaseSchema { +type NumRootState = SliceState; + +export interface NumActions { + set: (v: number) => (s: Draft) => void; + reset: () => (s: Draft) => void; + increment: (by?: number) => (s: Draft) => void; + decrement: (by?: number) => (s: Draft) => void; +} + +export interface NumSelectors { + select: (s: Immutable) => number; +} + +export interface NumOutput + extends BaseSchema, + NumActions, + NumSelectors { schema: "num"; initialState: number; - set: (v: number) => (s: S) => void; - increment: (by?: number) => (s: S) => void; - decrement: (by?: number) => (s: S) => void; - reset: () => (s: S) => void; - select: (s: S) => number; } -export function createNum({ +export function createNum({ name, initialState = 0, }: { - name: keyof S; + name: keyof NumRootState; initialState?: number; -}): NumOutput { +}): NumOutput { return { - name: name as string, + name: String(name), schema: "num", initialState, set: (value) => (state) => { - (state as any)[name] = value; + state[name] = value; }, increment: (by = 1) => (state) => { - (state as any)[name] += by; + state[name] += by; }, decrement: (by = 1) => (state) => { - (state as any)[name] -= by; + state[name] -= by; }, reset: () => (state) => { - (state as any)[name] = initialState; - }, - select: (state) => { - return (state as any)[name]; + state[name] = initialState; }, - }; + select: (state) => state[name], + } satisfies NumOutput; } export function num(initialState?: number) { - return (name: string) => createNum({ name, initialState }); + return (name: string) => createNum({ name, initialState }); } diff --git a/src/store/slice/obj.ts b/src/store/slice/obj.ts index 9f5f020d..95ab7adc 100644 --- a/src/store/slice/obj.ts +++ b/src/store/slice/obj.ts @@ -1,44 +1,61 @@ -import type { AnyState } from "../../types.js"; -import type { BaseSchema } from "../types.js"; +import type { Draft, Immutable } from "immer"; +import type { BaseSchema, SliceState } from "../types.js"; -export interface ObjOutput - extends BaseSchema { +// biome-ignore lint/suspicious/noExplicitAny: this data could be shape as defined by the user and doesn't necessarily match between items (so no generic can be used) +type ObjBase = Record; + +export interface ObjActions { + set: (v: V) => (s: Draft>) => void; + reset: () => (s: Draft>) => void; + update:

(prop: { + key: P; + value: V[P]; + }) => (s: Draft>) => void; +} + +export interface ObjSelectors { + select: (s: SliceState) => Immutable; +} + +export interface ObjOutput + extends BaseSchema, + ObjActions, + ObjSelectors { schema: "obj"; initialState: V; - set: (v: V) => (s: S) => void; - reset: () => (s: S) => void; - update:

(prop: { key: P; value: V[P] }) => (s: S) => void; - select: (s: S) => V; } -export function createObj({ +export function createObj({ name, initialState, }: { - name: keyof S; + name: keyof SliceState; initialState: V; -}): ObjOutput { +}): ObjOutput { + const objInitialState: V = initialState ?? ({} as V); + return { schema: "obj", - name: name as string, - initialState, + name: String(name), + initialState: objInitialState, set: (value) => (state) => { - (state as any)[name] = value; + Object.assign(state, { [name]: value }); }, reset: () => (state) => { - (state as any)[name] = initialState; + Object.assign(state, { [name]: initialState }); }, - update: -

(prop: { key: P; value: V[P] }) => - (state) => { - (state as any)[name][prop.key] = prop.value; - }, - select: (state) => { - return (state as any)[name]; + update: (prop) => (state) => { + const target = state[name]; + if (target && typeof target === "object") { + Object.assign(target, { [prop.key]: prop.value }); + } else { + Object.assign(state, { [name]: { [prop.key]: prop.value } }); + } }, - }; + select: (state) => state[name], + } satisfies ObjOutput; } -export function obj(initialState: V) { - return (name: string) => createObj({ name, initialState }); +export function obj(initialState: V) { + return (name: string) => createObj({ name, initialState }); } diff --git a/src/store/slice/str.ts b/src/store/slice/str.ts index 62f03fbd..8d8ddee3 100644 --- a/src/store/slice/str.ts +++ b/src/store/slice/str.ts @@ -1,38 +1,46 @@ -import type { AnyState } from "../../types.js"; -import type { BaseSchema } from "../types.js"; +import type { Draft, Immutable } from "immer"; +import type { BaseSchema, SliceState } from "../types.js"; -export interface StrOutput - extends BaseSchema { +type StrRootState = SliceState; + +export interface StrActions { + set: (v: string) => (s: Draft) => void; + reset: () => (s: Draft) => void; +} + +export interface StrSelectors { + select: (s: Immutable) => string; +} + +export interface StrOutput + extends BaseSchema, + StrActions, + StrSelectors { schema: "str"; initialState: string; - set: (v: string) => (s: S) => void; - reset: () => (s: S) => void; - select: (s: S) => string; } -export function createStr({ +export function createStr({ name, initialState = "", }: { - name: keyof S; + name: keyof StrRootState; initialState?: string; -}): StrOutput { +}): StrOutput { return { schema: "str", - name: name as string, + name: String(name), initialState, set: (value) => (state) => { - (state as any)[name] = value; + state[name] = value; }, reset: () => (state) => { - (state as any)[name] = initialState; - }, - select: (state) => { - return (state as any)[name]; + state[name] = initialState; }, - }; + select: (state) => state[name], + } satisfies StrOutput; } export function str(initialState?: string) { - return (name: string) => createStr({ name, initialState }); + return (name: string) => createStr({ name, initialState }); } diff --git a/src/store/slice/table.ts b/src/store/slice/table.ts index 9b3c7ee6..bbacf015 100644 --- a/src/store/slice/table.ts +++ b/src/store/slice/table.ts @@ -1,6 +1,12 @@ +import type { Draft, Immutable } from "immer"; import { createSelector } from "reselect"; -import type { AnyState, IdProp } from "../../types.js"; -import type { BaseSchema } from "../types.js"; +import type { IdProp } from "../../types.js"; +import type { BaseSchema, SliceState } from "../types.js"; + +type TableData = Record; +type TableRootState = Record>; +type TableState = Immutable>; +type TableDraftState = Draft>; interface PropId { id: IdProp; @@ -15,192 +21,186 @@ interface PatchEntity { } const excludesFalse = (n?: T): n is T => Boolean(n); +type EntityOrFactory = Entity | (() => Entity); +const isFactory = (value: T | (() => T)): value is () => T => + typeof value === "function"; +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === "object"; -function mustSelectEntity( - defaultEntity: Entity | (() => Entity), -) { - const isFn = typeof defaultEntity === "function"; - - return function selectEntity( - selectById: (s: S, p: PropId) => Entity | undefined, - ) { - return (state: S, { id }: PropId): Entity => { - if (isFn) { - const entity = defaultEntity as () => Entity; - return selectById(state, { id }) || entity(); - } - - return selectById(state, { id }) || (defaultEntity as Entity); - }; - }; +export interface TableSelectors< + Entity = unknown, + Empty extends EntityOrFactory = EntityOrFactory, +> { + findById: ( + d: Immutable>, + p: PropId, + ) => Immutable | undefined; + findByIds: ( + d: Immutable>, + p: PropIds, + ) => Immutable[]; + tableAsList: (d: Immutable>) => Immutable[]; + selectTable: (s: TableState) => Immutable>; + selectTableAsList: (state: TableState) => Immutable; + selectById: ( + s: TableState, + p: PropId, + ) => Empty extends undefined + ? Immutable | undefined + : Immutable; + selectByIds: (s: TableState, p: PropIds) => Immutable; } function tableSelectors< - Entity extends AnyState = AnyState, - S extends AnyState = AnyState, + Entity = unknown, + Empty extends EntityOrFactory | undefined = EntityOrFactory, >( - selectTable: (s: S) => Record, - empty?: Entity | (() => Entity) | undefined, + selectTable: (s: TableState) => Immutable>, + empty: Empty, ) { - const must = empty ? mustSelectEntity(empty) : null; - const tableAsList = (data: Record): Entity[] => - Object.values(data).filter(excludesFalse); - const findById = (data: Record, { id }: PropId) => data[id]; - const findByIds = ( - data: Record, - { ids }: PropIds, - ): Entity[] => ids.map((id) => data[id]).filter(excludesFalse); - const selectById = ( - state: S, - { id }: PropId, - ): typeof empty extends undefined ? Entity | undefined : Entity => { + const tableAsList = ((data) => + Object.values(data).filter( + excludesFalse, + )) satisfies TableSelectors["tableAsList"]; + const findById = ((data, { id }) => + data[id]) satisfies TableSelectors["findById"]; + const findByIds = ((data, { ids }) => + ids + .map((id) => data[id]) + .filter(excludesFalse)) satisfies TableSelectors["findByIds"]; + + const selectById = ((state, { id }) => { const data = selectTable(state); return findById(data, { id }); - }; - - const sbi = must ? must(selectById) : selectById; + }) satisfies TableSelectors["selectById"]; return { - findById: must ? must(findById) : findById, + findById, findByIds, tableAsList, selectTable, - selectTableAsList: createSelector(selectTable, (data): Entity[] => - tableAsList(data), - ), - selectById: sbi, + selectTableAsList: createSelector(selectTable, (data) => tableAsList(data)), + selectById: !empty + ? selectById + : (state, { id }) => { + if (isFactory(empty)) { + return selectById(state, { id }) || (empty() as Immutable); + } + return selectById(state, { id }) || (empty as Immutable); + }, selectByIds: createSelector( selectTable, - (_: S, p: PropIds) => p.ids, + (_, p: PropIds) => p.ids, (data, ids) => findByIds(data, { ids }), ), - }; + } satisfies TableSelectors; +} + +export interface TableActions { + // actions operate on Draft + add: (e: SliceState) => (s: TableDraftState) => void; + set: (e: SliceState) => (s: TableDraftState) => void; + remove: (ids: IdProp[]) => (s: TableDraftState) => void; + patch: ( + e: PatchEntity>, + ) => (s: TableDraftState) => void; + merge: ( + e: PatchEntity>, + ) => (s: TableDraftState) => void; + reset: () => (s: TableDraftState) => void; } -export interface TableOutput< - Entity extends AnyState, - S extends AnyState, - Empty extends Entity | undefined = Entity | undefined, -> extends BaseSchema> { +export interface TableOutput + extends BaseSchema>, + TableActions, + TableSelectors { schema: "table"; + /** runtime initial state for the table slice */ initialState: Record; - empty: Empty; - add: (e: Record) => (s: S) => void; - set: (e: Record) => (s: S) => void; - remove: (ids: IdProp[]) => (s: S) => void; - patch: (e: PatchEntity>) => (s: S) => void; - merge: (e: PatchEntity>) => (s: S) => void; - reset: () => (s: S) => void; - findById: (d: Record, { id }: PropId) => Empty; - findByIds: (d: Record, { ids }: PropIds) => Entity[]; - tableAsList: (d: Record) => Entity[]; - selectTable: (s: S) => Record; - selectTableAsList: (state: S) => Entity[]; - selectById: (s: S, p: PropId) => Empty; - selectByIds: (s: S, p: PropIds) => Entity[]; + /** default/empty entity value (or factory) */ + empty: Entity | undefined; } -export function createTable< - Entity extends AnyState = AnyState, - S extends AnyState = AnyState, ->(p: { - name: keyof S; - initialState?: Record; - empty: Entity | (() => Entity); -}): TableOutput; -export function createTable< - Entity extends AnyState = AnyState, - S extends AnyState = AnyState, ->(p: { - name: keyof S; - initialState?: Record; - empty?: Entity | (() => Entity); -}): TableOutput; -export function createTable< - Entity extends AnyState = AnyState, - S extends AnyState = AnyState, ->({ +export function createTable({ name, empty, - initialState = {}, + initialState, }: { - name: keyof S; + name: keyof TableRootState; initialState?: Record; empty?: Entity | (() => Entity); -}): TableOutput { - const selectors = tableSelectors((s: S) => s[name], empty); +}): TableOutput { + const tableInitialState: TableData = initialState ?? {}; + const selectors = tableSelectors((s) => s[name], empty); - return { + const output = { schema: "table", - name: name as string, - initialState, - empty: typeof empty === "function" ? empty() : empty, + name, + initialState: tableInitialState, + empty: empty === undefined ? undefined : isFactory(empty) ? empty() : empty, add: (entities) => (s) => { - const state = selectors.selectTable(s); - Object.keys(entities).forEach((id) => { - state[id] = entities[id]; - }); + const state = s[name]; + Object.assign(state, entities); }, set: (entities) => (s) => { - (s as any)[name] = entities; + const state = s[name]; + // replace table contents in-place + for (const k of Object.keys(state)) delete state[k]; + Object.assign(state, entities); }, remove: (ids) => (s) => { - const state = selectors.selectTable(s); - ids.forEach((id) => { - delete state[id]; - }); + const state = s[name]; + for (const id of ids) delete state[id]; }, patch: (entities) => (s) => { - const state = selectors.selectTable(s); - Object.keys(entities).forEach((id) => { - state[id] = { ...state[id], ...entities[id] }; - }); + const state = s[name]; + for (const id of Object.keys(entities)) { + const existing = state[id]; + const patch = entities[id]; + if (existing && typeof existing === "object") { + Object.assign(existing, patch); + } + } }, merge: (entities) => (s) => { - const state = selectors.selectTable(s); - Object.keys(entities).forEach((id) => { - const entity = entities[id]; - Object.keys(entity).forEach((prop) => { - const val = entity[prop]; + const state = s[name]; + for (const id of Object.keys(entities)) { + const src = entities[id]; + if (!src) continue; + const srcRec: Record = isRecord(src) ? src : {}; + const current = state[id]; + const tgtRec: Record = isRecord(current) + ? current + : {}; + for (const prop of Object.keys(srcRec)) { + const val = srcRec[prop]; if (Array.isArray(val)) { - const list = val as any[]; - (state as any)[id][prop].push(...list); + const arr = Array.isArray(tgtRec[prop]) ? tgtRec[prop] : []; + tgtRec[prop] = [...arr, ...val]; } else { - (state as any)[id][prop] = entities[id][prop]; + tgtRec[prop] = val; } - }); - }); + } + Object.assign(state, { [id]: tgtRec }); + } }, reset: () => (s) => { - (s as any)[name] = initialState; + const state = s[name]; + for (const k of Object.keys(state)) delete state[k]; + Object.assign(state, tableInitialState); }, ...selectors, - }; + } satisfies TableOutput; + + return output; } -export function table< - Entity extends AnyState = AnyState, - S extends AnyState = AnyState, ->(p: { - initialState?: Record; - empty: Entity | (() => Entity); -}): (n: string) => TableOutput; -export function table< - Entity extends AnyState = AnyState, - S extends AnyState = AnyState, ->(p?: { - initialState?: Record; - empty?: Entity | (() => Entity); -}): (n: string) => TableOutput; -export function table< - Entity extends AnyState = AnyState, - S extends AnyState = AnyState, ->({ - initialState, - empty, -}: { - initialState?: Record; - empty?: Entity | (() => Entity); -} = {}): (n: string) => TableOutput { +export function table( + options: { + initialState?: Record; + empty?: Entity | (() => Entity); + } = {}, +): (n: string) => TableOutput { + const { initialState, empty } = options; return (name: string) => createTable({ name, empty, initialState }); } diff --git a/src/store/store.ts b/src/store/store.ts index 42f691a8..ddf5800e 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,6 +1,5 @@ import { type Callable, - Ok, type Operation, type Scope, type Task, @@ -11,13 +10,19 @@ import { lift, suspend, } from "effection"; -import { API_ACTION_PREFIX, ActionContext, emit } from "../action.js"; -import type { BaseMiddleware } from "../compose.js"; +import { ActionContext, emit } from "../action.js"; import { createReplaySignal } from "../fx/replay-signal.js"; -import type { AnyAction, AnyState, Next } from "../types.js"; -import { StoreContext, StoreUpdateContext } from "./context.js"; +import type { AnyAction } from "../types.js"; +import { StoreContext } from "./context.js"; import { createRun } from "./run.js"; -import type { FxSchema, FxStore, Listener, UpdaterCtx } from "./types.js"; +import type { + FxMap, + FxSchema, + FxStore, + Listener, + SliceFromSchema, + UpdaterCtx, +} from "./types.js"; const stubMsg = "This is merely a stub, not implemented"; let id = 0; @@ -34,22 +39,21 @@ function observable() { }; } -export interface CreateStore { +export interface CreateStore { scope?: Scope; - schemas: FxSchema[]; + schemas: FxSchema[]; } export const IdContext = createContext("starfx:id", 0); +export const ListenersContext = createContext>( + "starfx:store:listeners", + new Set(), +); -// Context to share store's tail middleware with schemas -export const StoreTailMdwContext = createContext< - BaseMiddleware>[] ->("starfx:store-tail-mdw", [] as BaseMiddleware>[]); - -export function createStore({ +export function createStore({ scope: initScope, schemas, -}: CreateStore): FxStore { +}: CreateStore): FxStore { let scope: Scope; if (initScope) { scope = initScope; @@ -59,14 +63,18 @@ export function createStore({ } // Build initial state from all schemas - const initialState = schemas.reduce((acc, schema) => { - return Object.assign(acc, schema.initialState); - }, {} as AnyState) as S; + const initialState = schemas.reduce( + (acc, schema) => { + return Object.assign(acc, schema.initialState); + }, + {} as SliceFromSchema, + ); let state = initialState; const listeners = new Set(); + scope.set(ListenersContext, listeners); const signal = createSignal(); - const watch = createReplaySignal(); + const watch = createReplaySignal>, void>(); scope.set(ActionContext, signal); scope.set(IdContext, id++); @@ -78,7 +86,7 @@ export function createStore({ return state; } - function setState(newState: S) { + function setState(newState: SliceFromSchema) { state = newState; } @@ -95,36 +103,6 @@ export function createStore({ emit({ signal, action }); } - function* logMdw(ctx: UpdaterCtx, next: Next) { - yield* lift(dispatch)({ - type: `${API_ACTION_PREFIX}store`, - payload: ctx, - }); - yield* next(); - } - - function* notifyChannelMdw(_: UpdaterCtx, next: Next) { - const chan = yield* StoreUpdateContext.expect(); - yield* chan.send(); - yield* next(); - } - - function* notifyListenersMdw(_: UpdaterCtx, next: Next) { - listeners.forEach((f) => f()); - yield* next(); - } - - // Set the store's tail middleware in context for schemas to use - const storeTailMdw: BaseMiddleware>[] = [ - logMdw, - notifyChannelMdw, - notifyListenersMdw, - ]; - scope.set( - StoreTailMdwContext, - storeTailMdw as BaseMiddleware>[], - ); - function manage(name: string, inputResource: Operation) { const CustomContext = createContext(name); function* manager() { @@ -154,18 +132,18 @@ export function createStore({ } // Use the first schema as the default - const schema: FxSchema = schemas[0] as FxSchema; + const schema = schemas[0]; // Build schemas map by name for selective access const schemasMap = schemas.reduce( (acc, s) => { - acc[s.name] = s as FxSchema; + acc[s.name] = s; return acc; }, - {} as Record>, + {} as Record>, ); - const store: FxStore = { + const store: FxStore = { getScope, getState, setState, @@ -179,8 +157,8 @@ export function createStore({ // refer to pieces of business logic -- that can also mutate state dispatch, // stubs so `react-redux` is happy - replaceReducer( - _nextReducer: (_s: S, _a: AnyAction) => void, + replaceReducer( + _nextReducer: (s: SliceFromSchema, a: AnyAction) => SliceFromSchema, ): void { throw new Error(stubMsg); }, @@ -188,11 +166,6 @@ export function createStore({ [Symbol.observable]: observable, }; - scope.set(StoreContext, store as FxStore); + scope.set(StoreContext, store as FxStore); return store; } - -/** - * @deprecated use {@link createStore} - */ -export const configureStore = createStore; diff --git a/src/store/types.ts b/src/store/types.ts index 9f00cd61..979f6551 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -1,12 +1,19 @@ import type { Context, Operation, Scope, Task } from "effection"; -import type { Draft, Patch } from "immer"; -import type { BaseCtx } from "../index.js"; +import type { Draft, Immutable, Patch } from "immer"; +import type { BaseCtx, BaseMiddleware } from "../index.js"; import type { AnyAction, AnyState } from "../types.js"; import type { createRun } from "./run.js"; import type { LoaderOutput } from "./slice/loaders.js"; import type { TableOutput } from "./slice/table.js"; -export type StoreUpdater = (s: S | Draft) => S | void; +export type StoreUpdater = (s: Draft) => S | void; + +export type SliceActionFn = ( + p: P, +) => (s: Draft) => R; +export type SliceSelectorFn = P extends void + ? (s: S) => R + : (s: S, p: P) => R; export type Listener = () => void; @@ -31,35 +38,52 @@ export type Output }> = { [key in keyof O]: O[key]["initialState"]; }; +export type SliceState = Record>; export interface FxMap { // keep a typed shape for default slices while allowing user-defined slices - loaders?: (s: string) => LoaderOutput; - cache?: (s: string) => TableOutput; + loaders?: (s: string) => LoaderOutput; + cache?: (s: string) => TableOutput; [key: string]: ((name: string) => BaseSchema) | undefined; } -export type FxSchema = { +// Helper types to extract the factory return type and its initialState +export type FactoryReturn = T extends (name: string) => infer R ? R : never; +export type FactoryInitial = FactoryReturn< + NonNullable +> extends BaseSchema + ? IS + : never; +export type SliceFromSchema = { + [K in keyof O]: FactoryInitial; +}; + +export type FxSchema = { [key in keyof O]: ReturnType>; } & { name: string; - update: (u: StoreUpdater | StoreUpdater[]) => Operation>; - initialState: S; - reset: ( + initialize: ( + storeTailMdw: BaseMiddleware>>[], + ) => Operation; + update: ( + u: StoreUpdater> | StoreUpdater>[], + ) => Operation>>; + initialState: SliceFromSchema; + reset: = keyof SliceFromSchema>( ignoreList?: K[], - ) => Operation>; + ) => Operation>>; }; -export interface FxStore { +export interface FxStore { getScope: () => Scope; // part of redux store API - getState: () => S; - setState: (s: S) => void; + getState: () => SliceFromSchema; + setState: (s: SliceFromSchema) => void; // part of redux store API subscribe: (fn: Listener) => () => void; // the default schema for this store - schema: FxSchema; + schema: FxSchema; // all schemas by name - schemas: Record>; + schemas: Record>; manage: ( name: string, resource: Operation, @@ -69,12 +93,14 @@ export interface FxStore { // part of redux store API dispatch: (a: AnyAction | AnyAction[]) => unknown; // part of redux store API - replaceReducer: (r: (s: S, a: AnyAction) => S) => void; - getInitialState: () => S; + replaceReducer: ( + r: (s: SliceFromSchema, a: AnyAction) => SliceFromSchema, + ) => void; + getInitialState: () => SliceFromSchema; [Symbol.observable]: () => unknown; } export interface QueryState { - cache: TableOutput["initialState"]; - loaders: LoaderOutput["initialState"]; + cache: TableOutput["initialState"]; + loaders: LoaderOutput["initialState"]; } diff --git a/src/test/schema.test.ts b/src/test/schema.test.ts index 2f944e46..1bfd5b1f 100644 --- a/src/test/schema.test.ts +++ b/src/test/schema.test.ts @@ -19,7 +19,7 @@ test("default schema", async () => { loaders: {}, }); - await store.run(function* () { + await store.initialize(function* () { yield* schema.update(schema.loaders.start({ id: "1" })); yield* schema.update(schema.cache.add({ "1": true })); }); @@ -61,7 +61,7 @@ test("general types and functionality", async () => { const userMap = db.users.selectTable(store.getState()); expect(userMap).toEqual({ "1": { id: "1", name: "wow" } }); - await store.run(function* () { + await store.initialize(function* () { yield* db.update([ db.users.add({ "2": { id: "2", name: "bob" } }), db.users.patch({ "1": { name: "zzz" } }), @@ -99,7 +99,7 @@ test("can work with a nested object", async () => { loaders: slice.loaders(), }); const store = createStore({ schemas: [db] }); - await store.run(function* () { + await store.initialize(function* () { yield* db.update(db.currentUser.update({ key: "name", value: "vvv" })); const curUser = yield* select(db.currentUser.select); expect(curUser).toEqual({ id: "", name: "vvv", roles: [] }); diff --git a/src/test/slice-types.test.ts b/src/test/slice-types.test.ts index 03274c5b..cce96573 100644 --- a/src/test/slice-types.test.ts +++ b/src/test/slice-types.test.ts @@ -4,8 +4,8 @@ * These tests verify the type inference and type safety of slice creation. * They don't run at runtime - they verify types at compile time. */ -import { assertType, describe, expectTypeOf, test } from "vitest"; -import type { AnyState } from "../types.js"; +import { describe, expectTypeOf, test } from "vitest"; +import type { Immutable } from "immer"; import { createSchema, slice } from "../store/index.js"; import type { AnyOutput, @@ -15,7 +15,7 @@ import type { StrOutput, TableOutput, } from "../store/slice/index.js"; -import type { FxSchema } from "../store/types.js"; +import type { AnyState } from "../types.js"; // ============================================================================= // Test Entity Types @@ -46,7 +46,7 @@ describe("slice.str types", () => { expectTypeOf(strFactory).parameter(0).toBeString(); const strSlice = strFactory("token"); - expectTypeOf(strSlice).toMatchTypeOf>(); + expectTypeOf(strSlice).toExtend(); }); test("str slice has correct initialState type", () => { @@ -58,7 +58,7 @@ describe("slice.str types", () => { const strSlice = slice.str()("token"); const updater = strSlice.set("new-value"); expectTypeOf(updater).toBeFunction(); - expectTypeOf(updater).parameter(0).toMatchTypeOf(); + expectTypeOf(updater).parameter(0).toExtend(); }); test("str slice select returns string", () => { @@ -83,7 +83,7 @@ describe("slice.num types", () => { expectTypeOf(numFactory).parameter(0).toBeString(); const numSlice = numFactory("counter"); - expectTypeOf(numSlice).toMatchTypeOf>(); + expectTypeOf(numSlice).toExtend(); }); test("num slice has correct initialState type", () => { @@ -121,7 +121,7 @@ describe("slice.any types", () => { test("any factory infers type from initial value", () => { const boolFactory = slice.any(false); const boolSlice = boolFactory("enabled"); - expectTypeOf(boolSlice).toMatchTypeOf>(); + expectTypeOf(boolSlice).toExtend>(); expectTypeOf(boolSlice.initialState).toBeBoolean(); }); @@ -149,7 +149,7 @@ describe("slice.any types", () => { test("any with array type", () => { const tagsSlice = slice.any([])("tags"); expectTypeOf(tagsSlice.initialState).toEqualTypeOf(); - expectTypeOf(tagsSlice.select).returns.toEqualTypeOf(); + expectTypeOf(tagsSlice.select).returns.toEqualTypeOf>(); }); }); @@ -161,7 +161,7 @@ describe("slice.obj types", () => { test("obj factory infers type from initial value", () => { const userFactory = slice.obj(emptyUser); const userSlice = userFactory("currentUser"); - expectTypeOf(userSlice).toMatchTypeOf>(); + expectTypeOf(userSlice).toExtend>(); }); test("obj slice has correct initialState type", () => { @@ -195,7 +195,7 @@ describe("slice.obj types", () => { test("obj slice select returns the object type", () => { const userSlice = slice.obj(emptyUser)("currentUser"); - expectTypeOf(userSlice.select).returns.toEqualTypeOf(); + expectTypeOf(userSlice.select).returns.toEqualTypeOf>(); }); }); @@ -204,30 +204,33 @@ describe("slice.obj types", () => { // ============================================================================= describe("slice.table types", () => { + test("determines type from empty parameter", () => { + const tableWithEmpty = slice.table({ empty: emptyUser })("users"); + expectTypeOf(tableWithEmpty.empty).toEqualTypeOf(); + }); + test("table factory returns TableOutput", () => { const tableFactory = slice.table(); const usersSlice = tableFactory("users"); - expectTypeOf(usersSlice).toMatchTypeOf< - TableOutput - >(); + expectTypeOf(usersSlice).toExtend>(); }); test("table with empty returns non-undefined selectById", () => { const usersSlice = slice.table({ empty: emptyUser })("users"); - expectTypeOf(usersSlice).toMatchTypeOf>(); + expectTypeOf(usersSlice).toExtend>(); + + type SelectByIdResult = ReturnType; + type FindByIdResult = ReturnType; - // selectById should return User (not User | undefined) - expectTypeOf(usersSlice.selectById).returns.toEqualTypeOf(); - expectTypeOf(usersSlice.findById).returns.toEqualTypeOf(); + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf | undefined>(); }); test("table without empty returns possibly undefined selectById", () => { const usersSlice = slice.table()("users"); - // selectById should return User | undefined - expectTypeOf(usersSlice.selectById).returns.toEqualTypeOf< - User | undefined - >(); + type SelectByIdResult = ReturnType; + expectTypeOf().toEqualTypeOf>(); }); test("table slice add accepts correct record type", () => { @@ -246,25 +249,30 @@ describe("slice.table types", () => { test("table slice selectTable returns correct record type", () => { const usersSlice = slice.table()("users"); - expectTypeOf(usersSlice.selectTable).returns.toMatchTypeOf< - Record + expectTypeOf(usersSlice.selectTable).returns.toEqualTypeOf< + Immutable> >(); }); test("table slice selectTableAsList returns array", () => { const usersSlice = slice.table()("users"); - expectTypeOf(usersSlice.selectTableAsList).returns.toEqualTypeOf(); + expectTypeOf(usersSlice.selectTableAsList).returns.toEqualTypeOf< + Immutable + >(); }); test("table slice selectByIds returns array", () => { const usersSlice = slice.table()("users"); - expectTypeOf(usersSlice.selectByIds).returns.toEqualTypeOf(); + expectTypeOf(usersSlice.selectByIds).returns.toEqualTypeOf< + Immutable + >(); }); test("table with empty factory function", () => { const usersSlice = slice.table({ empty: () => emptyUser })("users"); - expectTypeOf(usersSlice.empty).toEqualTypeOf(); - expectTypeOf(usersSlice.selectById).returns.toEqualTypeOf(); + expectTypeOf(usersSlice.empty).toEqualTypeOf(); + type SelectByIdResult = ReturnType; + expectTypeOf().toEqualTypeOf>(); }); }); @@ -276,9 +284,7 @@ describe("slice.loaders types", () => { test("loaders factory returns LoaderOutput", () => { const loadersFactory = slice.loaders(); const loadersSlice = loadersFactory("loaders"); - expectTypeOf(loadersSlice).toMatchTypeOf< - LoaderOutput - >(); + expectTypeOf(loadersSlice).toExtend(); }); test("loaders slice start/success/error accept LoaderPayload", () => { @@ -294,12 +300,7 @@ describe("slice.loaders types", () => { }); test("loaders with custom meta type", () => { - interface LoaderMeta { - endpoint: string; - retryCount: number; - } - - const loadersSlice = slice.loaders()("loaders"); + const loadersSlice = slice.loaders()("loaders"); // Should accept meta with correct type loadersSlice.start({ @@ -339,13 +340,11 @@ describe("createSchema types", () => { }); // Schema should have the correct slice outputs - expectTypeOf(schema.users).toMatchTypeOf>(); - expectTypeOf(schema.posts).toMatchTypeOf< - TableOutput - >(); - expectTypeOf(schema.token).toMatchTypeOf>(); - expectTypeOf(schema.counter).toMatchTypeOf>(); - expectTypeOf(schema.currentUser).toMatchTypeOf>(); + expectTypeOf(schema.users).toExtend>(); + expectTypeOf(schema.posts).toExtend>(); + expectTypeOf(schema.token).toExtend(); + expectTypeOf(schema.counter).toExtend(); + expectTypeOf(schema.currentUser).toExtend>(); }); test("schema initialState has correct shape", () => { @@ -433,7 +432,8 @@ describe("advanced type scenarios", () => { notifications: { email: true, push: false }, })("settings"); - expectTypeOf(settingsSlice.select).returns.toEqualTypeOf(); + type SettingsSelectResult = ReturnType; + expectTypeOf().toEqualTypeOf>(); // update should work with nested keys settingsSlice.update({ @@ -456,7 +456,7 @@ describe("advanced type scenarios", () => { const complexSlice = slice.table()("complex"); expectTypeOf(complexSlice.selectTableAsList).returns.toEqualTypeOf< - ComplexEntity[] + Immutable >(); }); @@ -473,7 +473,7 @@ describe("advanced type scenarios", () => { type UserListResult = ReturnType; type PostListResult = ReturnType; - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf>(); }); }); diff --git a/src/test/store.test.ts b/src/test/store.test.ts index 314bab99..d2359f93 100644 --- a/src/test/store.test.ts +++ b/src/test/store.test.ts @@ -56,7 +56,8 @@ const updateUser = const users = findUsers(state); users[id].name = name; - (users as any)[2] = undefined; + // biome-ignore lint/suspicious/noExplicitAny: test-only + (users[2] as any) = undefined; users[3] = { id: "", name: "" }; // or mutate state directly without selectors @@ -64,7 +65,7 @@ const updateUser = }; const testSchema = (initialState: Partial = {}) => { - return createSchema({ + return createSchema({ users: slice.table({ initialState: initialState.users ?? {} }), theme: slice.str(initialState.theme ?? ""), token: slice.str(initialState.token ?? ""), @@ -79,7 +80,8 @@ test("update store and receives update from channel `StoreUpdateContext`", async users: { 1: { id: "1", name: "testing" }, 2: { id: "2", name: "wow" } }, }); const testStore = createStore({ scope, schemas: [schema] }); - let store; + // biome-ignore lint/suspicious/noExplicitAny: test-only + let store: any; await scope.run(function* (): Operation[]> { const result = yield* parallel([ function* () { @@ -96,7 +98,7 @@ test("update store and receives update from channel `StoreUpdateContext`", async ]); return yield* result; }); - expect((store as any)?.getState()).toEqual({ + expect(store?.getState()).toEqual({ users: { 1: { id: "1", name: "eric" }, 3: { id: "", name: "" } }, dev: true, theme: "", diff --git a/src/test/store/slice/obj.test.ts b/src/test/store/slice/obj.test.ts index c8184b46..df5b26d7 100644 --- a/src/test/store/slice/obj.test.ts +++ b/src/test/store/slice/obj.test.ts @@ -32,7 +32,7 @@ test("sets up an obj", async () => { schemas: [schema], }); - await store.run(function* () { + await store.initialize(function* () { yield* updateStore( slice.set({ username: "bob", @@ -50,7 +50,7 @@ test("sets up an obj", async () => { roles: ["admin", "user"], }); - await store.run(function* () { + await store.initialize(function* () { yield* updateStore(slice.update({ key: "username", value: "alice" })); }); @@ -61,7 +61,7 @@ test("sets up an obj", async () => { roles: ["admin", "user"], }); - await store.run(function* () { + await store.initialize(function* () { yield* updateStore( slice.update({ key: "roles", value: ["admin", "superuser"] }), ); diff --git a/src/test/store/slice/table.test.ts b/src/test/store/slice/table.test.ts index 51fd7214..ad4405be 100644 --- a/src/test/store/slice/table.test.ts +++ b/src/test/store/slice/table.test.ts @@ -28,7 +28,7 @@ test("sets up a table", async () => { schemas: [schema], }); - await store.run(function* () { + await store.initialize(function* () { yield* updateStore(tableSlice.set({ [first.id]: first })); }); expect(store.getState()[NAME]).toEqual({ [first.id]: first }); @@ -42,7 +42,7 @@ test("adds a row", async () => { schemas: [schema], }); - await store.run(function* () { + await store.initialize(function* () { yield* updateStore(tableSlice.set({ [second.id]: second })); }); expect(store.getState()[NAME]).toEqual({ 2: second }); @@ -57,13 +57,11 @@ test("removes a row", async () => { }); // Pre-populate the store - await store.run(function* () { + await store.initialize(function* () { yield* updateStore( tableSlice.set({ [first.id]: first, [second.id]: second }), ); - }); - await store.run(function* () { yield* updateStore(tableSlice.remove(["1"])); }); expect(store.getState()[NAME]).toEqual({ [second.id]: second }); @@ -76,8 +74,9 @@ test("updates a row", async () => { const store = createStore({ schemas: [schema], }); - await store.run(function* () { + await store.initialize(function* () { const updated = { id: second.id, user: "BB" }; + yield* updateStore(tableSlice.set({ [second.id]: second })); yield* updateStore(tableSlice.patch({ [updated.id]: updated })); }); expect(store.getState()[NAME]).toEqual({ @@ -92,7 +91,7 @@ test("gets a row", async () => { const store = createStore({ schemas: [schema], }); - await store.run(function* () { + await store.initialize(function* () { yield* updateStore( tableSlice.add({ [first.id]: first, @@ -126,7 +125,7 @@ test("gets all rows", async () => { schemas: [schema], }); const data = { [first.id]: first, [second.id]: second, [third.id]: third }; - await store.run(function* () { + await store.initialize(function* () { yield* updateStore(tableSlice.add(data)); }); expect(store.getState()[NAME]).toEqual(data); @@ -143,7 +142,7 @@ test("with empty", async () => { }); expect(tbl.empty).toEqual(first); - await store.run(function* () { + await store.initialize(function* () { yield* updateStore(tbl.set({ [first.id]: first })); }); expect(tbl.selectTable(store.getState())).toEqual({ @@ -164,7 +163,7 @@ test("with no empty", async () => { }); expect(tbl.empty).toEqual(undefined); - await store.run(function* () { + await store.initialize(function* () { yield* updateStore(tbl.set({ [first.id]: first })); }); expect(tbl.selectTable(store.getState())).toEqual({ diff --git a/src/test/supervisor.test.ts b/src/test/supervisor.test.ts index 480f38d0..57428d7b 100644 --- a/src/test/supervisor.test.ts +++ b/src/test/supervisor.test.ts @@ -53,17 +53,17 @@ test("should recover with backoff pressure", async () => { expect(actions.length).toEqual(3); expect(actions[0].type).toEqual(`${API_ACTION_PREFIX}supervise`); - expect(actions[0].meta).toEqual( - "Exception caught, waiting 1ms before restarting operation", - ); + expect(actions[0].meta).toEqual({ + message: "Exception caught, waiting 1ms before restarting operation", + }); expect(actions[1].type).toEqual(`${API_ACTION_PREFIX}supervise`); - expect(actions[1].meta).toEqual( - "Exception caught, waiting 2ms before restarting operation", - ); + expect(actions[1].meta).toEqual({ + message: "Exception caught, waiting 2ms before restarting operation", + }); expect(actions[2].type).toEqual(`${API_ACTION_PREFIX}supervise`); - expect(actions[2].meta).toEqual( - "Exception caught, waiting 3ms before restarting operation", - ); + expect(actions[2].meta).toEqual({ + message: "Exception caught, waiting 3ms before restarting operation", + }); console.error = err; }); diff --git a/src/types.ts b/src/types.ts index 7c603ad3..f7ba5e2a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,19 +4,17 @@ export type Next = () => Operation; export type IdProp = string | number; export type LoadingStatus = "loading" | "success" | "error" | "idle"; -export interface LoaderItemState< - M extends Record = Record, -> { +export interface LoaderItemState { id: string; status: LoadingStatus; message: string; lastRun: number; lastSuccess: number; - meta: M; + // biome-ignore lint/suspicious/noExplicitAny: allow anything but can't be generic per this type + meta: Record; } -export interface LoaderState - extends LoaderItemState { +export interface LoaderState extends LoaderItemState { isIdle: boolean; isLoading: boolean; isError: boolean; @@ -24,31 +22,38 @@ export interface LoaderState isInitialLoading: boolean; } -export type LoaderPayload = Pick, "id"> & - Partial, "message" | "meta">>; +export type LoaderPayload = Pick & + Partial>; +// this type is too broad, do not use it. Likely to be removed in the future. export type AnyState = Record; -export interface Payload

{ - payload: P; +export interface Payload { + payload: PayloadContent; } export interface Action { type: string; + // biome-ignore lint/suspicious/noExplicitAny: allow anything from the user [extraProps: string]: any; } export type ActionFn = () => { toString: () => string }; -export type ActionFnWithPayload

= (p: P) => { toString: () => string }; +export type ActionFnWithPayload = ( + p: PayloadContent, +) => { + toString: () => string; +}; // https://github.com/redux-utilities/flux-standard-action export interface AnyAction extends Action { + // biome-ignore lint/suspicious/noExplicitAny: : allow anything from the user, but also define type with generic payload payload?: any; - meta?: any; + // biome-ignore lint/suspicious/noExplicitAny: : allow anything from the user + meta?: Record; error?: boolean; - [extraProps: string]: any; } -export interface ActionWithPayload

extends AnyAction { - payload: P; +export interface ActionWithPayload extends AnyAction { + payload: PayloadContent; } diff --git a/tsconfig.json b/tsconfig.json index 6a64159e..f543da74 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, + "noImplicitAny": true, "skipLibCheck": true, "resolveJsonModule": true, "emitDecoratorMetadata": true, From 01d19865c12eed800f3ef1c641234d90ecdcd872 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Fri, 27 Feb 2026 12:51:57 -0600 Subject: [PATCH 15/35] merge states, run initialize through resource loop --- src/store/store.ts | 39 ++++++++++++++++++++++++++------------- src/store/types.ts | 4 +--- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/store/store.ts b/src/store/store.ts index 0560786e..252a8309 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -10,6 +10,7 @@ import { lift, suspend, } from "effection"; +import { produce } from "immer"; import { ActionContext, emit } from "../action.js"; import { createReplaySignal } from "../fx/replay-signal.js"; import type { AnyAction } from "../types.js"; @@ -91,6 +92,18 @@ export function createStore({ scope = tuple[0]; } + // Use the first schema as the default + const schema = schemas[0]; + + // Build schemas map by name for selective access + const schemasMap = schemas.reduce( + (acc, s) => { + acc[s.name] = s; + return acc; + }, + {} as Record>, + ); + // Build initial state from all schemas const initialState = schemas.reduce( (acc, schema) => { @@ -99,6 +112,13 @@ export function createStore({ {} as SliceFromSchema, ); let state = initialState; + + schemas.forEach((s) => { + if (s.initialize) { + watch.send(s.initialize); + } + }); + const listeners = new Set(); scope.set(ListenersContext, listeners); @@ -116,7 +136,12 @@ export function createStore({ } function setState(newState: SliceFromSchema) { - state = newState; + // enables merging multiple states from + // different schemas without overwriting the whole state + // TODO but this means double produce on the default single schema case + state = produce(state, (draft) => { + Object.assign(draft, newState); + }); } function getInitialState() { @@ -160,18 +185,6 @@ export function createStore({ }); } - // Use the first schema as the default - const schema = schemas[0]; - - // Build schemas map by name for selective access - const schemasMap = schemas.reduce( - (acc, s) => { - acc[s.name] = s; - return acc; - }, - {} as Record>, - ); - const store: FxStore = { getScope, getState, diff --git a/src/store/types.ts b/src/store/types.ts index 831c2e76..4f43facd 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -90,9 +90,7 @@ export type FxSchema = { [key in keyof O]: ReturnType>; } & { name: string; - initialize: ( - storeTailMdw: BaseMiddleware>>[], - ) => Operation; + initialize?: () => Operation; update: ( u: StoreUpdater> | StoreUpdater>[], ) => Operation>>; From 7b6c6410d27a75915d7bca59d4ef4b1939f918bb Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Fri, 27 Feb 2026 13:25:11 -0600 Subject: [PATCH 16/35] pass initialize through schema --- src/store/schema.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/store/schema.ts b/src/store/schema.ts index 7f6c3d04..67b6f45a 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -1,4 +1,4 @@ -import { lift } from "effection"; +import { lift, Operation } from "effection"; import { type Draft, enablePatches, produceWithPatches } from "immer"; import { API_ACTION_PREFIX, ActionContext, emit } from "../action.js"; import { type BaseMiddleware, compose } from "../compose.js"; @@ -57,6 +57,7 @@ export interface CreateSchemaWithUpdaterOptions { * This is where you implement your state update logic (e.g., immer, plain objects, etc.) */ updateMdw: BaseMiddleware>; + initialize?: () => Operation; } function* logMdw( @@ -99,7 +100,7 @@ function* notifyListenersMdw( * ```ts * // Plain object update (no immer) * const schema = createSchemaWithUpdater(mySlices, { - * createUpdateMdw: (store) => function* (ctx, next) { + * *updateMdw(ctx: UpdaterCtx>, next: Next) { * const updaters = Array.isArray(ctx.updater) ? ctx.updater : [ctx.updater]; * let state = store.getState(); * for (const updater of updaters) { @@ -117,6 +118,7 @@ export function createSchemaWithUpdater( { name = "default", middleware = [], + initialize, updateMdw, }: CreateSchemaWithUpdaterOptions>, ): FxSchema { @@ -177,6 +179,7 @@ export function createSchemaWithUpdater( const schema = db as FxSchema; schema.name = name; schema.update = update; + schema.initialize = initialize; schema.initialState = initialState as SliceFromSchema; schema.reset = reset; From a642825df5a6a87dea0337bf9dd69e45c6d3543e Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Fri, 27 Feb 2026 13:35:00 -0600 Subject: [PATCH 17/35] move up watch init --- src/store/schema.ts | 2 +- src/store/store.ts | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/store/schema.ts b/src/store/schema.ts index 67b6f45a..7d94bd95 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -1,4 +1,4 @@ -import { lift, Operation } from "effection"; +import { type Operation, lift } from "effection"; import { type Draft, enablePatches, produceWithPatches } from "immer"; import { API_ACTION_PREFIX, ActionContext, emit } from "../action.js"; import { type BaseMiddleware, compose } from "../compose.js"; diff --git a/src/store/store.ts b/src/store/store.ts index 252a8309..741cbfbd 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -92,6 +92,14 @@ export function createStore({ scope = tuple[0]; } + const listeners = new Set(); + scope.set(ListenersContext, listeners); + + const signal = createSignal(); + const watch = createReplaySignal>, void>(); + scope.set(ActionContext, signal); + scope.set(IdContext, id++); + // Use the first schema as the default const schema = schemas[0]; @@ -119,14 +127,6 @@ export function createStore({ } }); - const listeners = new Set(); - scope.set(ListenersContext, listeners); - - const signal = createSignal(); - const watch = createReplaySignal>, void>(); - scope.set(ActionContext, signal); - scope.set(IdContext, id++); - function getScope() { return scope; } From 550f31ae4d6088afe17f2ce2f20970ea7e51838c Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Fri, 10 Apr 2026 12:22:56 -0500 Subject: [PATCH 18/35] refine loader and react types --- src/action.ts | 8 ++- src/react.ts | 122 ++++++++++++++++----------------- src/store/schema.ts | 33 ++++++--- src/store/slice/loaders.ts | 36 +++++----- src/test/react-types.test.ts | 126 +++++++++++++++++++++++++++++++++++ src/test/slice-types.test.ts | 44 ++++++++++++ 6 files changed, 273 insertions(+), 96 deletions(-) create mode 100644 src/test/react-types.test.ts diff --git a/src/action.ts b/src/action.ts index 98785a5e..71911d8f 100644 --- a/src/action.ts +++ b/src/action.ts @@ -12,8 +12,7 @@ import { } from "effection"; import { type ActionPattern, matcher } from "./matcher.js"; import { createFilterQueue } from "./queue.js"; -import type { Action, ActionWithPayload, AnyAction } from "./types.js"; -import type { ActionFnWithPayload } from "./types.js"; +import type { Action, ActionFn, ActionFnWithPayload, ActionWithPayload, AnyAction } from "./types.js"; /** * Shared action signal used by `put`, `useActions`, and related helpers. @@ -320,7 +319,10 @@ export function* waitFor(predicate: () => Operation): Operation { * Extract the deterministic id from an action or action-creator. */ export function getIdFromAction( - action: ActionWithPayload<{ key: string }> | ActionFnWithPayload, + action: + | ActionWithPayload<{ key: string }> + | ActionFn + | ActionFnWithPayload, ): string { return typeof action === "function" ? action.toString() : action.payload.key; } diff --git a/src/react.ts b/src/react.ts index 00ac2753..607d4f44 100644 --- a/src/react.ts +++ b/src/react.ts @@ -14,8 +14,8 @@ import { } from "./store/index.js"; import type { LoaderOutput } from "./store/slice/loaders.js"; import type { TableOutput } from "./store/slice/table.js"; -import type { FxMap } from "./store/types.js"; -import type { LoaderState } from "./types.js"; +import type { FxMap, SliceFromSchema } from "./store/types.js"; +import type { AnyState, LoaderState } from "./types.js"; import type { ActionFn, ActionFnWithPayload } from "./types.js"; export { useDispatch, useSelector } from "react-redux"; @@ -30,9 +30,9 @@ const { } = React; type WithLoadersMap = FxMap & { loaders: (n: string) => LoaderOutput }; -type WithCacheMap = FxMap & { cache: (n: string) => TableOutput }; +type WithCacheMap = FxMap & { cache: (n: string) => TableOutput }; -export interface UseApiProps

extends LoaderState { +export interface UseApiProps

extends LoaderState { trigger: (p: P) => void; action: ActionFnWithPayload

; } @@ -55,6 +55,16 @@ export interface UseCacheResult data: D | null; } +type LoaderActionInput = ThunkAction | ActionFn | ActionFnWithPayload; +type ApiActionInput = ThunkAction | ActionFn | ActionFnWithPayload; +type UseApiReturn = A extends ActionFn + ? UseApiSimpleProps + : A extends ActionFnWithPayload + ? UseApiProps

+ : A extends ThunkAction + ? UseApiAction + : never; + const SchemaContext = createContext | null>(null); /** @@ -90,23 +100,11 @@ const SchemaContext = createContext | null>(null); * } * ``` */ -export function Provider(props: { +export function Provider(props: { store: FxStore; schema?: FxSchema; children?: React.ReactNode; -}): React.ReactElement; - -export function Provider(props: { - store: FxStore; - schema?: FxSchema; - children?: React.ReactNode; -}): React.ReactElement; - -export function Provider(props: { - store: FxStore; - schema?: FxSchema; - children?: React.ReactNode; -}) { +}): React.ReactElement { const { store, schema, children } = props; // Use provided schema or pull from store const schemaValue = (schema ?? store.schema) as FxSchema; @@ -114,28 +112,28 @@ export function Provider(props: { return h(ReduxProvider, { store, children: inner }); } -export function useSchema() { +export function useSchema(): FxSchema { const ctx = useContext(SchemaContext); if (!ctx) throw new Error("No Schema available in context"); return ctx as FxSchema; } // Typed variant for schemas that include `loaders`. -export function useSchemaWithLoaders(): FxSchema; -export function useSchemaWithLoaders(): FxSchema; -export function useSchemaWithLoaders() { +export function useSchemaWithLoaders< + O extends WithLoadersMap = WithLoadersMap, +>(): FxSchema { const ctx = useContext(SchemaContext); if (!ctx) throw new Error("No Schema available in context"); - return ctx as FxSchema; + return ctx as FxSchema; } // Typed variant for schemas that include `cache`. -export function useSchemaWithCache(): FxSchema; -export function useSchemaWithCache(): FxSchema; -export function useSchemaWithCache() { +export function useSchemaWithCache< + O extends WithCacheMap = WithCacheMap, +>(): FxSchema { const ctx = useContext(SchemaContext); if (!ctx) throw new Error("No Schema available in context"); - return ctx as FxSchema; + return ctx as FxSchema; } export function useStore() { @@ -174,16 +172,16 @@ export function useStore() { * } * ``` */ -export function useLoader( - action: ThunkAction | ActionFnWithPayload, -): LoaderState; -export function useLoader( - action: ThunkAction | ActionFnWithPayload, -): LoaderState; -export function useLoader(action: any) { - const schema = useSchemaWithLoaders(); +export function useLoader< + O extends WithLoadersMap = WithLoadersMap, + A extends LoaderActionInput = LoaderActionInput, +>(action: A): LoaderState { + const schema = useSchemaWithLoaders(); const id = getIdFromAction(action); - return useSelector((s: any) => schema.loaders.selectById(s, { id })); + type LoaderSelectState = Parameters[0]; + return useSelector((s: SliceFromSchema) => + schema.loaders.selectById(s as unknown as LoaderSelectState, { id }), + ); } /** @@ -216,26 +214,17 @@ export function useLoader(action: any) { * } * ``` */ -export function useApi

>( - action: A, -): UseApiAction; -export function useApi

>( - action: ActionFnWithPayload

, -): UseApiProps

; -export function useApi( - action: ActionFn, -): UseApiSimpleProps; -export function useApi(action: any): any { +export function useApi(action: A): UseApiReturn { const dispatch = useDispatch(); const loader = useLoader(action); - const trigger = (p: any) => { + const trigger = (p?: unknown) => { if (typeof action === "function") { - dispatch(action(p)); + dispatch((action as ActionFnWithPayload)(p)); } else { dispatch(action); } }; - return { ...loader, trigger, action }; + return { ...loader, trigger, action } as UseApiReturn; } /** @@ -248,10 +237,13 @@ export function useApi(action: any): any { * @param action - The dispatched action to execute. * @returns Loader state and trigger function. */ -export function useQuery

>( +export function useQuery< + P = unknown, + A extends ThunkAction

= ThunkAction

, +>( action: A, ): UseApiAction { - const api = useApi(action); + const api = useApi(action) as UseApiAction; useEffect(() => { api.trigger(); }, [action.payload.key]); @@ -275,20 +267,21 @@ export function useQuery

>( * return ; * ``` */ -export function useCache(action: ThunkAction): UseCacheResult; export function useCache< - S extends { cache: TableOutput["initialState"] }, - P = any, - ApiSuccess = any, + O extends WithCacheMap = WithCacheMap, + P = unknown, + ApiSuccess = unknown, >( action: ThunkAction, -): UseCacheResult>; -export function useCache(action: any) { - const schema = useSchemaWithCache(); +): UseCacheResult> { + const schema = useSchemaWithCache(); const id = action.payload.key; - const data = useSelector((s: any) => schema.cache.selectById(s, { id })); + type CacheSelectState = Parameters[0]; + const data = useSelector((s: SliceFromSchema) => + schema.cache.selectById(s as unknown as CacheSelectState, { id }), + ); const query = useQuery(action); - return { ...query, data: (data as any) || null }; + return { ...query, data: (data as ApiSuccess | undefined) ?? null }; } /** @@ -310,7 +303,7 @@ export function useCache(action: any) { */ export function useLoaderSuccess( cur: Pick, - success: () => any, + success: () => void, ) { const prev = useRef(cur); useEffect(() => { @@ -342,8 +335,11 @@ export function PersistGate({ loading = h(Loading), }: PersistGateProps) { const schema = useSchemaWithLoaders(); - const ldr = useSelector((s: any) => - schema.loaders.selectById(s, { id: PERSIST_LOADER_ID }), + type LoaderSelectState = Parameters[0]; + const ldr = useSelector((s: SliceFromSchema) => + schema.loaders.selectById(s as unknown as LoaderSelectState, { + id: PERSIST_LOADER_ID, + }), ); if (ldr.status === "error") { diff --git a/src/store/schema.ts b/src/store/schema.ts index 7d94bd95..d13f0b62 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -60,6 +60,22 @@ export interface CreateSchemaWithUpdaterOptions { initialize?: () => Operation; } +interface CreateSchemaOptions { + /** + * Unique name for this schema. Used to access the schema from the store. + * @default "default" + */ + name?: string; + /** + * Escape hatch for middleware values that are not typed to this schema. + * + * @remarks + * This preserves slice inference from the `slices` argument when callers + * intentionally cast middleware. + */ + middleware?: BaseMiddleware>>[] | unknown[]; +} + function* logMdw( ctx: UpdaterCtx>, next: Next, @@ -198,22 +214,19 @@ export function createSchemaWithUpdater( * @param options - Schema options including `name` and custom middleware. * @returns A configured schema with `update`, `reset`, and generated slices. */ -export function createSchema( +export function createSchema( slices?: O, - options: { - /** - * Unique name for this schema. Used to access the schema from the store. - * @default "default" - */ - name?: string; - middleware?: BaseMiddleware>>[]; - } = {}, + options: CreateSchemaOptions = {}, ): FxSchema { enablePatches(); + const middleware = options.middleware as + | BaseMiddleware>>[] + | undefined; + return createSchemaWithUpdater(slices ?? defaultSchema(), { name: options.name, - middleware: options.middleware, + middleware, *updateMdw(ctx: UpdaterCtx>, next: Next) { const store: FxStore = yield* expectStore(); const upds: StoreUpdater>[] = Array.isArray( diff --git a/src/store/slice/loaders.ts b/src/store/slice/loaders.ts index c3468aec..bc675e4c 100644 --- a/src/store/slice/loaders.ts +++ b/src/store/slice/loaders.ts @@ -5,7 +5,7 @@ import type { LoaderPayload, LoaderState, } from "../../types.js"; -import type { BaseSchema, SliceState } from "../types.js"; +import type { BaseSchema } from "../types.js"; interface PropId { id: string; @@ -85,21 +85,17 @@ export function defaultLoader(l: Partial = {}): LoaderState { }; } type LoaderTable = Record; +type LoaderRootState = Record; +type LoaderStateView = Immutable; +type LoaderDraftState = Draft; export interface LoaderSelectors { findById: (d: Immutable, p: PropId) => LoaderState; findByIds: (d: Immutable, p: PropIds) => LoaderState[]; - selectTable: ( - s: Immutable>, - ) => Immutable; - selectTableAsList: ( - state: Immutable>, - ) => LoaderItemState[]; - selectById: (s: Immutable>, p: PropId) => LoaderState; - selectByIds: ( - s: Immutable>, - p: PropIds, - ) => LoaderState[]; + selectTable: (s: LoaderStateView) => Immutable; + selectTableAsList: (state: LoaderStateView) => Immutable; + selectById: (s: LoaderStateView, p: PropId) => LoaderState; + selectByIds: (s: LoaderStateView, p: PropIds) => LoaderState[]; } function loaderSelectors(selectTable: LoaderSelectors["selectTable"]) { @@ -115,7 +111,7 @@ function loaderSelectors(selectTable: LoaderSelectors["selectTable"]) { findById, findByIds, selectTable, - selectTableAsList: createSelector([selectTable], (data) => + selectTableAsList: createSelector(selectTable, (data) => Object.values(data).filter(excludesFalse), ), selectById: createSelector( @@ -132,11 +128,11 @@ function loaderSelectors(selectTable: LoaderSelectors["selectTable"]) { } export interface LoaderActions { - start: (e: LoaderPayload) => (s: Draft>) => void; - success: (e: LoaderPayload) => (s: Draft>) => void; - error: (e: LoaderPayload) => (s: Draft>) => void; - reset: () => (s: Draft>) => void; - resetByIds: (ids: string[]) => (s: Draft>) => void; + start: (e: LoaderPayload) => (s: LoaderDraftState) => void; + success: (e: LoaderPayload) => (s: LoaderDraftState) => void; + error: (e: LoaderPayload) => (s: LoaderDraftState) => void; + reset: () => (s: LoaderDraftState) => void; + resetByIds: (ids: string[]) => (s: LoaderDraftState) => void; } export interface LoaderOutput @@ -153,7 +149,7 @@ export const createLoaders = ({ name, initialState = {}, }: { - name: keyof SliceState; + name: keyof LoaderRootState; initialState?: LoaderTable; }): LoaderOutput => { const loaderInitialState = initialState ?? {}; @@ -161,7 +157,7 @@ export const createLoaders = ({ const output = { schema: "loader", - name, + name: String(name), initialState: loaderInitialState, start: (e) => (s) => { const table = s[name]; diff --git a/src/test/react-types.test.ts b/src/test/react-types.test.ts new file mode 100644 index 00000000..ebdfe993 --- /dev/null +++ b/src/test/react-types.test.ts @@ -0,0 +1,126 @@ +import { describe, expectTypeOf, test } from "vitest"; +import { + type UseApiAction, + type UseApiProps, + type UseApiSimpleProps, + type UseCacheResult, + useApi, + useCache, + useLoader, + useQuery, + useSchema, + useSchemaWithCache, + useSchemaWithLoaders, + useStore, +} from "../react.js"; +import { createSchema, createStore, slice } from "../store/index.js"; +import type { + FxSchema, + FxStore, + SliceFromSchema, +} from "../store/types.js"; +import type { LoaderOutput, ObjOutput, TableOutput } from "../store/slice/index.js"; +import type { ActionFn, ActionFnWithPayload, AnyState, LoaderState } from "../types.js"; +import type { ThunkAction } from "../query/index.js"; + +interface User { + id: string; + name: string; +} + +type Metadata = Record; + +type AppMap = { + cache: (name: string) => TableOutput; + loaders: (name: string) => LoaderOutput; + metadata: (name: string) => ObjOutput; + users: (name: string) => TableOutput; +}; + +declare const fetchUsersAction: ThunkAction<{ page: number }, User[]>; +declare const saveUserAction: ActionFnWithPayload; +declare const refreshUsersAction: ActionFn; + +describe("react hook types", () => { + test("typed schema/store creation works with react-facing types", () => { + const schema = createSchema({ + cache: slice.table(), + loaders: slice.loaders(), + metadata: slice.obj({}), + users: slice.table(), + }); + const store = createStore({ schemas: [schema] }); + + expectTypeOf(schema).toExtend>(); + expectTypeOf(store).toExtend>(); + }); + + test("useSchema returns typed schema", () => { + const useTypedSchema = () => useSchema(); + expectTypeOf(useTypedSchema).returns.toEqualTypeOf>(); + }); + + test("useSchemaWithLoaders returns typed schema", () => { + type WithLoaders = AppMap & { loaders: (name: string) => LoaderOutput }; + const useTypedSchema = () => useSchemaWithLoaders(); + expectTypeOf(useTypedSchema).returns.toEqualTypeOf>(); + }); + + test("useSchemaWithCache returns typed schema", () => { + type WithCache = AppMap & { cache: (name: string) => TableOutput }; + const useTypedSchema = () => useSchemaWithCache(); + expectTypeOf(useTypedSchema).returns.toEqualTypeOf>(); + }); + + test("useStore returns typed store", () => { + const useTypedStore = () => useStore(); + expectTypeOf(useTypedStore).returns.toEqualTypeOf>(); + }); + + test("useLoader accepts thunk actions and returns loader state", () => { + const useThunkLoader = () => useLoader(fetchUsersAction); + expectTypeOf(useThunkLoader).returns.toEqualTypeOf(); + }); + + test("useApi infers thunk action result", () => { + const useThunkApi = () => useApi(fetchUsersAction); + expectTypeOf(useThunkApi).returns.toExtend< + UseApiAction + >(); + }); + + test("useApi infers payload action function result", () => { + const usePayloadApi = () => useApi(saveUserAction); + expectTypeOf(usePayloadApi).returns.toExtend>(); + }); + + test("useApi infers simple action function result", () => { + const useSimpleApi = () => useApi(refreshUsersAction); + expectTypeOf(useSimpleApi).returns.toExtend(); + }); + + test("useQuery returns thunk api shape", () => { + const useThunkQuery = () => useQuery(fetchUsersAction); + expectTypeOf(useThunkQuery).returns.toExtend< + UseApiAction + >(); + }); + + test("useCache returns cached thunk result type", () => { + const useThunkCache = () => useCache(fetchUsersAction); + expectTypeOf(useThunkCache).returns.toExtend< + UseCacheResult + >(); + }); + + test("useSchema supports custom slice selectors", () => { + const useMetadataSelect = () => { + const schema = useSchema(); + return schema.metadata.select; + }; + + expectTypeOf(useMetadataSelect).returns.toExtend< + ObjOutput["select"] + >(); + }); +}); diff --git a/src/test/slice-types.test.ts b/src/test/slice-types.test.ts index 95a55a90..12abb721 100644 --- a/src/test/slice-types.test.ts +++ b/src/test/slice-types.test.ts @@ -15,6 +15,7 @@ import type { StrOutput, TableOutput, } from "../store/slice/index.js"; +import type { FxMap } from "../store/types.js"; import type { AnyState } from "../types.js"; // ============================================================================= @@ -383,6 +384,49 @@ describe("createSchema types", () => { // update should accept the slice updater functions expectTypeOf(schema.update).toBeFunction(); }); + + test("schema custom slices are directly accessible", () => { + const schema = createSchema({ + metadata: slice.obj>({}), + cache: slice.table({ empty: {} }), + loaders: slice.loaders(), + }); + + // Repro case for downstream React usage: + // const metadata = useSelector(schema.metadata.select) + expectTypeOf(schema.metadata).toExtend>>(); + expectTypeOf(schema.metadata).not.toBeUndefined(); + expectTypeOf(schema.metadata.select).toBeFunction(); + }); + + test("failure: broad FxMap annotation drops custom slice typing", () => { + const slices: FxMap = { + metadata: slice.obj>({}), + cache: slice.table({ empty: {} }), + loaders: slice.loaders(), + }; + const schema = createSchema(slices); + + // This reproduces the downstream issue when schema typing is widened. + // @ts-expect-error metadata comes from FxMap index signature here + schema.metadata.select; + }); + + test("schema keeps custom slice typing with loose middleware option", () => { + const schema = createSchema( + { + metadata: slice.obj({ name: "Default", lastUpdated: "" }), + cache: slice.table({ empty: {} }), + loaders: slice.loaders(), + }, + { + // Matches downstream usage where middleware is intentionally cast. + middleware: [(() => undefined) as unknown], + }, + ); + + expectTypeOf(schema.metadata.select).toBeFunction(); + }); }); // ============================================================================= From a5a7f7446ec7a05031135ef02a2fb771cd6623d7 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Fri, 10 Apr 2026 15:03:59 -0500 Subject: [PATCH 19/35] tasks arg and manage helper --- src/store/store.ts | 120 +++++++++++++++++++--------------- src/test/create-store.test.ts | 34 +++++++++- 2 files changed, 101 insertions(+), 53 deletions(-) diff --git a/src/store/store.ts b/src/store/store.ts index 741cbfbd..ab67d82c 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,18 +1,13 @@ import { - type Callable, type Operation, type Scope, - type Task, createContext, createScope, createSignal, - each, - lift, suspend, } from "effection"; import { produce } from "immer"; import { ActionContext, emit } from "../action.js"; -import { createReplaySignal } from "../fx/replay-signal.js"; import type { AnyAction } from "../types.js"; import { StoreContext } from "./context.js"; import { createRun } from "./run.js"; @@ -22,8 +17,9 @@ import type { FxStore, Listener, SliceFromSchema, - UpdaterCtx, } from "./types.js"; +import { parallel } from "../fx/parallel.js"; +import { supervise } from "../fx/supervisor.js"; const stubMsg = "This is merely a stub, not implemented"; let id = 0; @@ -40,9 +36,41 @@ function observable() { }; } +/** + * Creates a managed resource within the store's Effection scope. + * + * @remarks + * The `manage` function allows you to define a resource that can be passed to + * the `createStore({ tasks: [() => manage(...)] })` configuration. This resource will be automatically + * initialized and cleaned up with the store's lifecycle. The provided resource is made available through a generated context that can be accessed by any operation within the store's scope. + * + * @param name - A unique name for the resource, used to create a context. + * @param inputResource - An Effection operation that initializes the resource. + * @returns A context object with an `initialize` method for setting up the resource. + */ +export function manage( + name: string, + inputResource: Operation, +) { + const CustomContext = createContext(name); + function initialize(parentScope: Scope) { + return supervise(function* () { + const providedResource = yield* inputResource; + parentScope.set(CustomContext, providedResource); + yield* suspend(); + }); + } + + // returns to the user so they can use this resource from + // anywhere this context is available + return { ...CustomContext, initialize }; +} + export interface CreateStore { scope?: Scope; - schemas: FxSchema[]; + schema?: FxSchema; + schemas?: FxSchema[]; + tasks?: (() => Operation)[]; } export const IdContext = createContext("starfx:id", 0); @@ -82,27 +110,38 @@ export const ListenersContext = createContext>( */ export function createStore({ scope: initScope, - schemas, + schema: singleSchema, + schemas: multiSchemas, + tasks = [], }: CreateStore): FxStore { - let scope: Scope; - if (initScope) { - scope = initScope; + // ensure only schema or schemas is provided, not both or neither + if (singleSchema && multiSchemas) { + throw new Error("Provide either `schema` or `schemas`, not both."); + } + if (!singleSchema && !multiSchemas) { + throw new Error("At least one schema must be provided."); + } + + // normalize to array of schemas for easier processing + let schemas: FxSchema[]; + if (singleSchema) { + schemas = [singleSchema]; + } else if (multiSchemas) { + schemas = multiSchemas; } else { - const tuple = createScope(); - scope = tuple[0]; + throw new Error("Provide either `schema` or `schemas`."); } + const baseSchema = schemas[0]; + + const [scope] = initScope ? [initScope] : createScope(); const listeners = new Set(); scope.set(ListenersContext, listeners); const signal = createSignal(); - const watch = createReplaySignal>, void>(); scope.set(ActionContext, signal); scope.set(IdContext, id++); - // Use the first schema as the default - const schema = schemas[0]; - // Build schemas map by name for selective access const schemasMap = schemas.reduce( (acc, s) => { @@ -121,12 +160,6 @@ export function createStore({ ); let state = initialState; - schemas.forEach((s) => { - if (s.initialize) { - watch.send(s.initialize); - } - }); - function getScope() { return scope; } @@ -157,42 +190,15 @@ export function createStore({ emit({ signal, action }); } - function manage(name: string, inputResource: Operation) { - const CustomContext = createContext(name); - function* manager() { - const providedResource = yield* inputResource; - scope.set(CustomContext, providedResource); - yield* suspend(); - } - watch.send(manager); - - // returns to the user so they can use this resource from - // anywhere this context is available - return CustomContext; - } - const run = createRun(scope); - function initialize(op: () => Operation): Task { - return scope.run(function* (): Operation { - yield* scope.spawn(function* () { - for (const watched of yield* each(watch)) { - yield* scope.spawn(watched); - yield* each.next(); - } - }); - yield* op(); - }); - } - const store: FxStore = { getScope, getState, setState, subscribe, - initialize, manage, - schema, + schema: baseSchema, schemas: schemasMap, run, // instead of actions relating to store mutation, they @@ -209,5 +215,17 @@ export function createStore({ }; scope.set(StoreContext, store as FxStore); + + run(function* (): Operation { + const schemaInit = schemas + .map((s) => s.initialize) + .filter( + (init): init is () => Operation => typeof init === "function", + ); + + const group = yield* parallel([...schemaInit, ...tasks]); + yield* group; + }); + return store; } diff --git a/src/test/create-store.test.ts b/src/test/create-store.test.ts index cb4f6420..ee1fbf52 100644 --- a/src/test/create-store.test.ts +++ b/src/test/create-store.test.ts @@ -7,7 +7,7 @@ interface TestState { } test("should be able to grab values from store", async () => { - let actual; + let actual: TestState["user"] | undefined; const store = createStore({ schemas: [createSchema({ user: slice.obj({ id: "1" }) })], }); @@ -18,7 +18,7 @@ test("should be able to grab values from store", async () => { }); test("should be able to grab store from a nested call", async () => { - let actual; + let actual: TestState["user"] | undefined; const store = createStore({ schemas: [createSchema({ user: slice.obj({ id: "2" }) })], }); @@ -29,3 +29,33 @@ test("should be able to grab store from a nested call", async () => { }); expect(actual).toEqual({ id: "2" }); }); + +test("should accept a single schema option", async () => { + let actual: TestState["user"] | undefined; + const schema = createSchema({ user: slice.obj({ id: "3" }) }); + const store = createStore({ schema }); + + await store.run(function* () { + actual = yield* select((s: TestState) => s.user); + }); + + expect(actual).toEqual({ id: "3" }); + expect(store.schema).toBe(schema); +}); + +test("should reject both schema and schemas", () => { + const schema = createSchema({ user: slice.obj({ id: "4" }) }); + + expect(() => + createStore({ + schema, + schemas: [schema], + }), + ).toThrow("Provide either `schema` or `schemas`, not both."); +}); + +test("should reject missing schema configuration", () => { + expect(() => createStore({})).toThrow( + "At least one schema must be provided.", + ); +}); From 0e5a0f2aa8eb4088f04d6cc5ebbedefd1a6bfbb1 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Mon, 13 Apr 2026 15:15:37 -0500 Subject: [PATCH 20/35] types massaging --- package.json | 1 + src/action.ts | 15 +- src/matcher.ts | 32 ++-- src/mdw/store.ts | 24 ++- src/react.ts | 121 +++++++++++++-- src/store/fx.ts | 11 +- src/store/schema.ts | 85 +++++----- src/store/slice/any.ts | 7 +- src/store/slice/loaders.ts | 51 +++--- src/store/slice/num.ts | 7 +- src/store/slice/obj.ts | 7 +- src/store/slice/str.ts | 7 +- src/store/slice/table.ts | 137 ++++++++-------- src/store/store.ts | 4 +- src/store/types.ts | 39 ++++- src/test/api.test.ts | 27 ++-- src/test/batch.test.ts | 6 +- src/test/fetch.test.ts | 4 +- src/test/mdw.test.ts | 4 +- src/test/persist.test.ts | 34 ++-- src/test/react-types.test.ts | 242 ++++++++++++++++++++--------- src/test/react.test.ts | 4 +- src/test/schema.test.ts | 56 ++++--- src/test/store.test.ts | 49 ++++-- src/test/store/slice/obj.test.ts | 10 +- src/test/store/slice/table.test.ts | 52 ++----- src/test/thunk.test.ts | 24 +-- tsconfig.lib.json | 14 ++ 28 files changed, 678 insertions(+), 396 deletions(-) create mode 100644 tsconfig.lib.json diff --git a/package.json b/package.json index 677ccca9..549e1edd 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "scripts": { "test": "vitest --exclude examples", "typecheck": "tsc --noEmit", + "typecheck:lib": "tsc --noEmit --project tsconfig.lib.json", "lint": "biome check --write", "fmt": "biome check --write --linter-enabled=false", "ci": "biome ci .", diff --git a/src/action.ts b/src/action.ts index 71911d8f..3568c021 100644 --- a/src/action.ts +++ b/src/action.ts @@ -3,6 +3,7 @@ import { type Signal, SignalQueueFactory, type Stream, + type Task, call, createContext, createSignal, @@ -12,7 +13,13 @@ import { } from "effection"; import { type ActionPattern, matcher } from "./matcher.js"; import { createFilterQueue } from "./queue.js"; -import type { Action, ActionFn, ActionFnWithPayload, ActionWithPayload, AnyAction } from "./types.js"; +import type { + Action, + ActionFn, + ActionFnWithPayload, + ActionWithPayload, + AnyAction, +} from "./types.js"; /** * Shared action signal used by `put`, `useActions`, and related helpers. @@ -58,7 +65,9 @@ export function useActions(pattern: ActionPattern): Stream { [Symbol.iterator]: function* () { const actions = yield* ActionContext.expect(); const match = matcher(pattern); - yield* SignalQueueFactory.set(() => createFilterQueue(match) as any); + yield* SignalQueueFactory.set(() => + createFilterQueue((value) => match(value as AnyAction)), + ); return yield* actions; }, }; @@ -234,7 +243,7 @@ export function* takeLatest( op: (action: AnyAction) => Operation, ): Operation { const fd = useActions(pattern); - let lastTask; + let lastTask: Task | undefined; for (const action of yield* each(fd)) { if (lastTask) { diff --git a/src/matcher.ts b/src/matcher.ts index d11b7b16..a63bc715 100644 --- a/src/matcher.ts +++ b/src/matcher.ts @@ -6,7 +6,7 @@ type Predicate = ( action: Guard, ) => boolean; type StringableActionCreator = { - (...args: any[]): A; + (...args: never[]): A; toString(): string; }; type SubPattern = @@ -28,18 +28,32 @@ export type ActionPattern = | ActionSubPattern | ActionSubPattern[]; -function isThunk(fn: any): boolean { +function isThunk(fn: unknown): fn is StringableActionCreator & { + run: unknown; + use: unknown; + name: string; +} { + if (typeof fn !== "function") return false; + + const thunk = fn as { + run?: unknown; + use?: unknown; + name?: unknown; + toString?: unknown; + }; + return ( - typeof fn === "function" && - typeof fn.run === "function" && - typeof fn.use === "function" && - typeof fn.name === "string" && - typeof fn.toString === "function" + typeof thunk.run === "function" && + typeof thunk.use === "function" && + typeof thunk.name === "string" && + typeof thunk.toString === "function" ); } -function isActionCreator(fn: any): boolean { - return !!fn && fn._starfx === true; +function isActionCreator(fn: unknown): fn is StringableActionCreator { + return ( + !!fn && typeof fn === "function" && "_starfx" in fn && fn._starfx === true + ); } /** diff --git a/src/mdw/store.ts b/src/mdw/store.ts index 9ae6c545..d34cd7c0 100644 --- a/src/mdw/store.ts +++ b/src/mdw/store.ts @@ -6,14 +6,17 @@ import { select, updateStore, } from "../store/index.js"; -import type { AnyState, LoaderState, Next } from "../types.js"; +import type { LoaderState, Next } from "../types.js"; import { nameParser } from "./fetch.js"; import { actions, customKey, err, queryCtx } from "./query.js"; -export interface ApiMdwProps { +export interface ApiMdwProps< + Ctx extends ApiCtx = ApiCtx, + CacheEntity = unknown, +> { schema: { loaders: LoaderOutput; - cache: TableOutput; + cache: TableOutput; }; errorFn?: (ctx: Ctx) => string; } @@ -59,7 +62,9 @@ function isErrorLike(err: unknown): err is ErrorLike { * api.use(mdw.fetch({ baseUrl: 'https://api.example.com' })); * ``` */ -export function api(props: ApiMdwProps) { +export function api( + props: ApiMdwProps, +) { return compose([ err, actions, @@ -101,8 +106,11 @@ export function api(props: ApiMdwProps) { * }); * ``` */ -export function cache(schema: { - cache: TableOutput; +export function cache< + Ctx extends ApiCtx = ApiCtx, + CacheEntity = unknown, +>(schema: { + cache: TableOutput; }) { return function* cache(ctx: Ctx, next: Next) { ctx.cacheData = yield* select(schema.cache.selectById, { id: ctx.key }); @@ -242,10 +250,10 @@ function defaultErrorFn(ctx: Ctx) { * @param props.errorFn - Custom function to extract error message from context. * @returns A loader tracking middleware function. */ -export function loaderApi({ +export function loaderApi({ schema, errorFn = defaultErrorFn, -}: ApiMdwProps) { +}: ApiMdwProps) { return function* trackLoading(ctx: Ctx, next: Next) { try { yield* updateStore([ diff --git a/src/react.ts b/src/react.ts index 607d4f44..4a96965d 100644 --- a/src/react.ts +++ b/src/react.ts @@ -2,8 +2,8 @@ import React, { type ReactElement } from "react"; import { Provider as ReduxProvider, useDispatch, + useSelector as useReduxSelector, useStore as useReduxStore, - useSelector, } from "react-redux"; import { getIdFromAction } from "./action.js"; import type { ThunkAction } from "./query/index.js"; @@ -18,7 +18,7 @@ import type { FxMap, SliceFromSchema } from "./store/types.js"; import type { AnyState, LoaderState } from "./types.js"; import type { ActionFn, ActionFnWithPayload } from "./types.js"; -export { useDispatch, useSelector } from "react-redux"; +export { useDispatch } from "react-redux"; export type { TypedUseSelectorHook } from "react-redux"; const { @@ -32,6 +32,35 @@ const { type WithLoadersMap = FxMap & { loaders: (n: string) => LoaderOutput }; type WithCacheMap = FxMap & { cache: (n: string) => TableOutput }; +export type TypedHooks = { + useSelector: ( + selector: (state: SliceFromSchema) => Selected, + equalityFn?: (left: Selected, right: Selected) => boolean, + ) => Selected; + useStore: () => FxStore; + useSchema: () => FxSchema; + useSchemaWithLoaders: O extends WithLoadersMap ? () => FxSchema : never; + useSchemaWithCache: O extends WithCacheMap ? () => FxSchema : never; + useLoader: O extends WithLoadersMap + ? ( + action: A, + ) => LoaderState + : never; + useApi: O extends WithLoadersMap + ? (action: A) => UseApiReturn + : never; + useQuery: O extends WithLoadersMap + ?

= ThunkAction

>( + action: A, + ) => UseApiAction + : never; + useCache: O extends WithCacheMap + ?

( + action: ThunkAction, + ) => UseCacheResult> + : never; +}; + export interface UseApiProps

extends LoaderState { trigger: (p: P) => void; action: ActionFnWithPayload

; @@ -65,7 +94,58 @@ type UseApiReturn = A extends ActionFn ? UseApiAction : never; -const SchemaContext = createContext | null>(null); +const SchemaContext = createContext(null); + +export function useSelector( + // biome-ignore lint/suspicious/noExplicitAny: bare global useSelector intentionally opts out of state typing. + selector: (state: any) => Selected, + equalityFn?: (left: Selected, right: Selected) => boolean, +): Selected; +export function useSelector( + selector: (state: SliceFromSchema) => Selected, + equalityFn?: (left: Selected, right: Selected) => boolean, +): Selected; +export function useSelector( + // biome-ignore lint/suspicious/noExplicitAny: implementation signature must be broad enough to cover both typed and untyped overloads. + selector: (state: any) => unknown, + equalityFn?: (left: unknown, right: unknown) => boolean, +): unknown { + return useReduxSelector( + selector as (state: SliceFromSchema) => unknown, + equalityFn, + ); +} + +export function createTypedHooks( + _schema: FxSchema, +): TypedHooks { + const useTypedSelector: TypedHooks["useSelector"] = ( + selector, + equalityFn, + ) => useSelector>(selector, equalityFn); + + return { + useSelector: useTypedSelector, + useStore: () => useStore(), + useSchema: () => useSchema(), + useSchemaWithLoaders: (() => + useSchemaWithLoaders< + O & WithLoadersMap + >()) as TypedHooks["useSchemaWithLoaders"], + useSchemaWithCache: (() => + useSchemaWithCache< + O & WithCacheMap + >()) as TypedHooks["useSchemaWithCache"], + useLoader: ((action) => + useLoader(action)) as TypedHooks["useLoader"], + useApi: ((action) => + useApi(action)) as TypedHooks["useApi"], + useQuery: ((action) => + useQuery(action)) as TypedHooks["useQuery"], + useCache: ((action) => + useCache(action)) as TypedHooks["useCache"], + }; +} /** * React Provider to wire the `FxStore` and schema into React context. @@ -100,14 +180,22 @@ const SchemaContext = createContext | null>(null); * } * ``` */ -export function Provider(props: { +export function Provider(props: { store: FxStore; schema?: FxSchema; children?: React.ReactNode; +}): React.ReactElement; + +export function Provider(props: { + // biome-ignore lint/suspicious/noExplicitAny: Provider must accept any typed schema/store pair; hook APIs preserve the specific types. + store: FxStore; + // biome-ignore lint/suspicious/noExplicitAny: Provider must accept any typed schema/store pair; hook APIs preserve the specific types. + schema?: FxSchema; + children?: React.ReactNode; }): React.ReactElement { const { store, schema, children } = props; // Use provided schema or pull from store - const schemaValue = (schema ?? store.schema) as FxSchema; + const schemaValue = schema ?? store.schema; const inner = h(SchemaContext.Provider, { value: schemaValue }, children); return h(ReduxProvider, { store, children: inner }); } @@ -179,7 +267,7 @@ export function useLoader< const schema = useSchemaWithLoaders(); const id = getIdFromAction(action); type LoaderSelectState = Parameters[0]; - return useSelector((s: SliceFromSchema) => + return useSelector((s) => schema.loaders.selectById(s as unknown as LoaderSelectState, { id }), ); } @@ -214,9 +302,12 @@ export function useLoader< * } * ``` */ -export function useApi(action: A): UseApiReturn { +export function useApi< + O extends WithLoadersMap = WithLoadersMap, + A extends ApiActionInput = ApiActionInput, +>(action: A): UseApiReturn { const dispatch = useDispatch(); - const loader = useLoader(action); + const loader = useLoader(action); const trigger = (p?: unknown) => { if (typeof action === "function") { dispatch((action as ActionFnWithPayload)(p)); @@ -238,12 +329,10 @@ export function useApi(action: A): UseApiReturn { * @returns Loader state and trigger function. */ export function useQuery< - P = unknown, - A extends ThunkAction

= ThunkAction

, ->( - action: A, -): UseApiAction { - const api = useApi(action) as UseApiAction; + O extends WithLoadersMap = WithLoadersMap, + A extends ThunkAction = ThunkAction, +>(action: A): UseApiAction { + const api = useApi(action) as UseApiAction; useEffect(() => { api.trigger(); }, [action.payload.key]); @@ -277,7 +366,7 @@ export function useCache< const schema = useSchemaWithCache(); const id = action.payload.key; type CacheSelectState = Parameters[0]; - const data = useSelector((s: SliceFromSchema) => + const data = useSelector((s) => schema.cache.selectById(s as unknown as CacheSelectState, { id }), ); const query = useQuery(action); @@ -336,7 +425,7 @@ export function PersistGate({ }: PersistGateProps) { const schema = useSchemaWithLoaders(); type LoaderSelectState = Parameters[0]; - const ldr = useSelector((s: SliceFromSchema) => + const ldr = useSelector((s) => schema.loaders.selectById(s as unknown as LoaderSelectState, { id: PERSIST_LOADER_ID, }), diff --git a/src/store/fx.ts b/src/store/fx.ts index dd74adb7..b912736c 100644 --- a/src/store/fx.ts +++ b/src/store/fx.ts @@ -2,7 +2,12 @@ import type { Operation, Result } from "effection"; import { getIdFromAction, take } from "../action.js"; import { parallel, safe } from "../fx/index.js"; import type { ThunkAction } from "../query/index.js"; -import type { ActionFnWithPayload, AnyState, LoaderState } from "../types.js"; +import type { + ActionFn, + ActionFnWithPayload, + AnyState, + LoaderState, +} from "../types.js"; import { expectStore } from "./context.js"; import type { LoaderOutput } from "./slice/loaders.js"; import type { @@ -38,7 +43,7 @@ export function* select( export function* waitForLoader( loaders: LoaderOutput, - action: ThunkAction | ActionFnWithPayload, + action: ThunkAction | ActionFn | ActionFnWithPayload, ): Operation { const id = getIdFromAction(action); const selector = (s: Parameters[0]) => @@ -61,7 +66,7 @@ export function* waitForLoader( export function* waitForLoaders( loaders: LoaderOutput, - actions: (ThunkAction | ActionFnWithPayload)[], + actions: (ThunkAction | ActionFn | ActionFnWithPayload)[], ): Operation[]> { const ops = actions.map((action) => () => waitForLoader(loaders, action)); const group = yield* parallel(ops); diff --git a/src/store/schema.ts b/src/store/schema.ts index d13f0b62..39f40ff6 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -11,6 +11,7 @@ import type { FxMap, FxSchema, FxStore, + SchemaUpdater, SliceFromSchema, StoreUpdater, UpdaterCtx, @@ -26,10 +27,10 @@ const defaultSchema = (): O => export function buildSlices( slices: O, ): { - db: { [key in keyof O]: FactoryReturn }; + db: { [K in keyof O]: FactoryReturn }; initialState: { [key in keyof O]: FactoryInitial }; } { - const db = {} as { [key in keyof O]: FactoryReturn }; + const db = {} as { [K in keyof O]: FactoryReturn }; for (const key of Object.keys(slices) as Array) { const factory = slices[key]; if (!factory) continue; // defensive - O may allow optional entries @@ -45,18 +46,21 @@ export function buildSlices( return { db, initialState }; } -export interface CreateSchemaWithUpdaterOptions { +export interface CreateSchemaWithUpdaterOptions< + S extends AnyState, + U = StoreUpdater | StoreUpdater[], +> { /** * Unique name for this schema. Used to access the schema from the store. * @default "default" */ name?: string; - middleware?: BaseMiddleware>[]; + middleware?: BaseMiddleware>[]; /** * Factory function that creates the update middleware. * This is where you implement your state update logic (e.g., immer, plain objects, etc.) */ - updateMdw: BaseMiddleware>; + updateMdw: BaseMiddleware>; initialize?: () => Operation; } @@ -73,13 +77,14 @@ interface CreateSchemaOptions { * This preserves slice inference from the `slices` argument when callers * intentionally cast middleware. */ - middleware?: BaseMiddleware>>[] | unknown[]; + middleware?: + | BaseMiddleware< + UpdaterCtx, SchemaUpdater | SchemaUpdater[]> + >[] + | unknown[]; } -function* logMdw( - ctx: UpdaterCtx>, - next: Next, -) { +function* logMdw(ctx: UpdaterCtx, next: Next) { const signal = yield* ActionContext.expect(); const action = { type: `${API_ACTION_PREFIX}store`, @@ -90,8 +95,8 @@ function* logMdw( yield* next(); } -function* notifyChannelMdw( - _: UpdaterCtx>, +function* notifyChannelMdw( + _: UpdaterCtx, next: Next, ) { const chan = yield* StoreUpdateContext.expect(); @@ -99,8 +104,8 @@ function* notifyChannelMdw( yield* next(); } -function* notifyListenersMdw( - _: UpdaterCtx>, +function* notifyListenersMdw( + _: UpdaterCtx, next: Next, ) { const listeners = yield* ListenersContext.expect(); @@ -136,25 +141,27 @@ export function createSchemaWithUpdater( middleware = [], initialize, updateMdw, - }: CreateSchemaWithUpdaterOptions>, + }: CreateSchemaWithUpdaterOptions< + SliceFromSchema, + SchemaUpdater | SchemaUpdater[] + >, ): FxSchema { const { db, initialState } = buildSlices(slices); // Precomputed middleware will be set on first update call const composedMdw: ReturnType< - typeof compose>> - > = compose>>([ - updateMdw, - ...middleware, - logMdw, - notifyChannelMdw, - notifyListenersMdw, - ]); - - function* update( - ups: StoreUpdater> | StoreUpdater>[], - ) { - const ctx: UpdaterCtx> = { + typeof compose< + UpdaterCtx, SchemaUpdater | SchemaUpdater[]> + > + > = compose< + UpdaterCtx, SchemaUpdater | SchemaUpdater[]> + >([updateMdw, ...middleware, logMdw, notifyChannelMdw, notifyListenersMdw]); + + function* update(ups: SchemaUpdater | SchemaUpdater[]) { + const ctx: UpdaterCtx< + SliceFromSchema, + SchemaUpdater | SchemaUpdater[] + > = { updater: ups, patches: [], }; @@ -171,7 +178,7 @@ export function createSchemaWithUpdater( } function* reset(ignoreList: (string | number | symbol)[] = []) { - return yield* update((s) => { + return yield* update((s: Draft>) => { const state = s as Draft>; const stateObj = state as unknown as { [K in keyof SliceFromSchema]: SliceFromSchema[K]; @@ -221,19 +228,25 @@ export function createSchema( enablePatches(); const middleware = options.middleware as - | BaseMiddleware>>[] + | BaseMiddleware< + UpdaterCtx, SchemaUpdater | SchemaUpdater[]> + >[] | undefined; return createSchemaWithUpdater(slices ?? defaultSchema(), { name: options.name, middleware, - *updateMdw(ctx: UpdaterCtx>, next: Next) { + *updateMdw( + ctx: UpdaterCtx< + SliceFromSchema, + SchemaUpdater | SchemaUpdater[] + >, + next: Next, + ) { const store: FxStore = yield* expectStore(); - const upds: StoreUpdater>[] = Array.isArray( - ctx.updater, - ) - ? ctx.updater - : [ctx.updater]; + const upds = ( + Array.isArray(ctx.updater) ? ctx.updater : [ctx.updater] + ) as StoreUpdater>[]; const [nextState, patches, _] = produceWithPatches>( store.getState(), diff --git a/src/store/slice/any.ts b/src/store/slice/any.ts index 3d0e4b5a..071bb5ea 100644 --- a/src/store/slice/any.ts +++ b/src/store/slice/any.ts @@ -9,7 +9,7 @@ export interface AnyActions { } export interface AnySelectors { - select: (s: AnyRootState) => Immutable; + select: (s: Record) => Immutable; } // export interface AnyOutput extends BaseSchema { @@ -28,6 +28,9 @@ export interface AnyOutput initialState: V; } +const selectValue = (state: Record, name: string): T => + state[name] as T; + export function createAny({ name, initialState, @@ -45,7 +48,7 @@ export function createAny({ reset: () => (state) => { Object.assign(state, { [name]: initialState }); }, - select: (state) => state[name], + select: (state) => selectValue>(state, String(name)), } satisfies AnyOutput; } diff --git a/src/store/slice/loaders.ts b/src/store/slice/loaders.ts index bc675e4c..360a3cb4 100644 --- a/src/store/slice/loaders.ts +++ b/src/store/slice/loaders.ts @@ -84,9 +84,10 @@ export function defaultLoader(l: Partial = {}): LoaderState { loading.lastSuccess === 0, }; } + type LoaderTable = Record; type LoaderRootState = Record; -type LoaderStateView = Immutable; +type LoaderStateView = Record; type LoaderDraftState = Draft; export interface LoaderSelectors { @@ -98,16 +99,16 @@ export interface LoaderSelectors { selectByIds: (s: LoaderStateView, p: PropIds) => LoaderState[]; } -function loaderSelectors(selectTable: LoaderSelectors["selectTable"]) { - const findById = ((data, { id }) => - defaultLoader(data[id])) satisfies LoaderSelectors["findById"]; +function loaderSelectors( + selectTable: LoaderSelectors["selectTable"], +): LoaderSelectors { + const findById: LoaderSelectors["findById"] = (data, { id }) => + defaultLoader(data[id]); - const findByIds = ((data, { ids }) => - ids - .map((id) => defaultLoader(data[id])) - .filter(excludesFalse)) satisfies LoaderSelectors["findByIds"]; + const findByIds: LoaderSelectors["findByIds"] = (data, { ids }) => + ids.map((id) => defaultLoader(data[id])).filter(excludesFalse); - const selectors = { + return { findById, findByIds, selectTable, @@ -123,8 +124,6 @@ function loaderSelectors(selectTable: LoaderSelectors["selectTable"]) { (data, ids) => findByIds(data, { ids }), ), } satisfies LoaderSelectors; - - return selectors; } export interface LoaderActions { @@ -149,18 +148,20 @@ export const createLoaders = ({ name, initialState = {}, }: { - name: keyof LoaderRootState; + name: string; initialState?: LoaderTable; }): LoaderOutput => { const loaderInitialState = initialState ?? {}; - const selectors = loaderSelectors((s) => s[name]); + const selectors = loaderSelectors( + (state) => state[name] as Immutable, + ); - const output = { + return { schema: "loader", name: String(name), initialState: loaderInitialState, - start: (e) => (s) => { - const table = s[name]; + start: (e) => (state) => { + const table = state[name]; const loader = table[e.id]; table[e.id] = defaultLoaderItem({ ...loader, @@ -169,8 +170,8 @@ export const createLoaders = ({ lastRun: ts(), }); }, - success: (e) => (s) => { - const table = s[name]; + success: (e) => (state) => { + const table = state[name]; const loader = table[e.id]; table[e.id] = defaultLoaderItem({ ...loader, @@ -179,8 +180,8 @@ export const createLoaders = ({ lastSuccess: ts(), }); }, - error: (e) => (s) => { - const table = s[name]; + error: (e) => (state) => { + const table = state[name]; const loader = table[e.id]; table[e.id] = defaultLoaderItem({ ...loader, @@ -188,21 +189,19 @@ export const createLoaders = ({ status: "error", }); }, - reset: () => (s) => { - const table = s[name]; + reset: () => (state) => { + const table = state[name]; for (const key of Object.keys(table)) delete table[key]; Object.assign(table, loaderInitialState); }, - resetByIds: (ids: string[]) => (s) => { - const table = s[name]; + resetByIds: (ids: string[]) => (state) => { + const table = state[name]; ids.forEach((id) => { delete table[id]; }); }, ...selectors, } satisfies LoaderOutput; - - return output; }; /** diff --git a/src/store/slice/num.ts b/src/store/slice/num.ts index dde023b8..9accadc3 100644 --- a/src/store/slice/num.ts +++ b/src/store/slice/num.ts @@ -11,7 +11,7 @@ export interface NumActions { } export interface NumSelectors { - select: (s: Immutable) => number; + select: (s: Record) => number; } export interface NumOutput @@ -22,6 +22,9 @@ export interface NumOutput initialState: number; } +const selectValue = (state: Record, name: string): T => + state[name] as T; + export function createNum({ name, initialState = 0, @@ -49,7 +52,7 @@ export function createNum({ reset: () => (state) => { state[name] = initialState; }, - select: (state) => state[name], + select: (state) => selectValue(state, String(name)), } satisfies NumOutput; } diff --git a/src/store/slice/obj.ts b/src/store/slice/obj.ts index 01db7dbb..6b838115 100644 --- a/src/store/slice/obj.ts +++ b/src/store/slice/obj.ts @@ -14,7 +14,7 @@ export interface ObjActions { } export interface ObjSelectors { - select: (s: SliceState) => Immutable; + select: (s: Record) => Immutable; } export interface ObjOutput @@ -25,6 +25,9 @@ export interface ObjOutput initialState: V; } +const selectValue = (state: Record, name: string): T => + state[name] as T; + export function createObj({ name, initialState, @@ -52,7 +55,7 @@ export function createObj({ Object.assign(state, { [name]: { [prop.key]: prop.value } }); } }, - select: (state) => state[name], + select: (state) => selectValue>(state, String(name)), } satisfies ObjOutput; } diff --git a/src/store/slice/str.ts b/src/store/slice/str.ts index a9dc573e..757f0186 100644 --- a/src/store/slice/str.ts +++ b/src/store/slice/str.ts @@ -9,7 +9,7 @@ export interface StrActions { } export interface StrSelectors { - select: (s: Immutable) => string; + select: (s: Record) => string; } export interface StrOutput @@ -20,6 +20,9 @@ export interface StrOutput initialState: string; } +const selectValue = (state: Record, name: string): T => + state[name] as T; + export function createStr({ name, initialState = "", @@ -37,7 +40,7 @@ export function createStr({ reset: () => (state) => { state[name] = initialState; }, - select: (state) => state[name], + select: (state) => selectValue(state, String(name)), } satisfies StrOutput; } diff --git a/src/store/slice/table.ts b/src/store/slice/table.ts index a668dcf1..8ac5f3b2 100644 --- a/src/store/slice/table.ts +++ b/src/store/slice/table.ts @@ -5,7 +5,7 @@ import type { BaseSchema, SliceState } from "../types.js"; type TableData = Record; type TableRootState = Record>; -type TableState = Immutable>; +type TableSelectorState = Record; type TableDraftState = Draft>; interface PropId { @@ -27,10 +27,7 @@ const isFactory = (value: T | (() => T)): value is () => T => const isRecord = (value: unknown): value is Record => value !== null && typeof value === "object"; -export interface TableSelectors< - Entity = unknown, - Empty extends EntityOrFactory = EntityOrFactory, -> { +export interface TableSelectors { findById: ( d: Immutable>, p: PropId, @@ -40,39 +37,39 @@ export interface TableSelectors< p: PropIds, ) => Immutable[]; tableAsList: (d: Immutable>) => Immutable[]; - selectTable: (s: TableState) => Immutable>; - selectTableAsList: (state: TableState) => Immutable; - selectById: ( - s: TableState, - p: PropId, - ) => Empty extends undefined - ? Immutable | undefined - : Immutable; - selectByIds: (s: TableState, p: PropIds) => Immutable; + selectTable: (s: TableSelectorState) => Immutable>; + selectTableAsList: (state: TableSelectorState) => Immutable; + selectById: (s: TableSelectorState, p: PropId) => Immutable; + selectByIds: (s: TableSelectorState, p: PropIds) => Immutable; } -function tableSelectors< - Entity = unknown, - Empty extends EntityOrFactory | undefined = EntityOrFactory, ->( - selectTable: (s: TableState) => Immutable>, - empty: Empty, -) { - const tableAsList = ((data) => - Object.values(data).filter( - excludesFalse, - )) satisfies TableSelectors["tableAsList"]; - const findById = ((data, { id }) => - data[id]) satisfies TableSelectors["findById"]; - const findByIds = ((data, { ids }) => - ids - .map((id) => data[id]) - .filter(excludesFalse)) satisfies TableSelectors["findByIds"]; - - const selectById = ((state, { id }) => { +function tableSelectors( + selectTable: (s: TableSelectorState) => Immutable>, + empty: EntityOrFactory | undefined, +): TableSelectors { + type ResultSelectors = TableSelectors; + + const tableAsList: ResultSelectors["tableAsList"] = (data) => + Object.values(data).filter(excludesFalse); + const findById: ResultSelectors["findById"] = (data, { id }) => data[id]; + const findByIds: ResultSelectors["findByIds"] = (data, { ids }) => + ids.map((rowId) => data[rowId]).filter(excludesFalse); + + const selectByIdBase = (state: TableSelectorState, { id }: PropId) => { const data = selectTable(state); return findById(data, { id }); - }) satisfies TableSelectors["selectById"]; + }; + + const selectById: ResultSelectors["selectById"] = !empty + ? (state, { id }) => selectByIdBase(state, { id }) as Immutable + : (state, { id }) => { + if (isFactory(empty)) { + return ( + selectByIdBase(state, { id }) || (empty() as Immutable) + ); + } + return selectByIdBase(state, { id }) || (empty as Immutable); + }; return { findById, @@ -80,24 +77,16 @@ function tableSelectors< tableAsList, selectTable, selectTableAsList: createSelector(selectTable, (data) => tableAsList(data)), - selectById: !empty - ? selectById - : (state, { id }) => { - if (isFactory(empty)) { - return selectById(state, { id }) || (empty() as Immutable); - } - return selectById(state, { id }) || (empty as Immutable); - }, + selectById, selectByIds: createSelector( selectTable, (_, p: PropIds) => p.ids, (data, ids) => findByIds(data, { ids }), ), - } satisfies TableSelectors; + }; } -export interface TableActions { - // actions operate on Draft +export interface TableActions { add: (e: SliceState) => (s: TableDraftState) => void; set: (e: SliceState) => (s: TableDraftState) => void; remove: (ids: IdProp[]) => (s: TableDraftState) => void; @@ -115,9 +104,7 @@ export interface TableOutput TableActions, TableSelectors { schema: "table"; - /** runtime initial state for the table slice */ initialState: Record; - /** default/empty entity value (or factory) */ empty: Entity | undefined; } @@ -126,49 +113,51 @@ export function createTable({ empty, initialState, }: { - name: keyof TableRootState; + name: string; initialState?: Record; empty?: Entity | (() => Entity); }): TableOutput { const tableInitialState: TableData = initialState ?? {}; - const selectors = tableSelectors((s) => s[name], empty); + const selectors = tableSelectors( + (state) => state[name] as Immutable>, + empty, + ); - const output = { + return { schema: "table", - name, + name: String(name), initialState: tableInitialState, empty: empty === undefined ? undefined : isFactory(empty) ? empty() : empty, - add: (entities) => (s) => { - const state = s[name]; - Object.assign(state, entities); + add: (entities) => (state) => { + const table = state[name]; + Object.assign(table, entities); }, - set: (entities) => (s) => { - const state = s[name]; - // replace table contents in-place - for (const k of Object.keys(state)) delete state[k]; - Object.assign(state, entities); + set: (entities) => (state) => { + const table = state[name]; + for (const key of Object.keys(table)) delete table[key]; + Object.assign(table, entities); }, - remove: (ids) => (s) => { - const state = s[name]; - for (const id of ids) delete state[id]; + remove: (ids) => (state) => { + const table = state[name]; + for (const id of ids) delete table[id]; }, - patch: (entities) => (s) => { - const state = s[name]; + patch: (entities) => (state) => { + const table = state[name]; for (const id of Object.keys(entities)) { - const existing = state[id]; + const existing = table[id]; const patch = entities[id]; if (existing && typeof existing === "object") { Object.assign(existing, patch); } } }, - merge: (entities) => (s) => { - const state = s[name]; + merge: (entities) => (state) => { + const table = state[name]; for (const id of Object.keys(entities)) { const src = entities[id]; if (!src) continue; const srcRec: Record = isRecord(src) ? src : {}; - const current = state[id]; + const current = table[id]; const tgtRec: Record = isRecord(current) ? current : {}; @@ -181,18 +170,16 @@ export function createTable({ tgtRec[prop] = val; } } - Object.assign(state, { [id]: tgtRec }); + Object.assign(table, { [id]: tgtRec as Entity }); } }, - reset: () => (s) => { - const state = s[name]; - for (const k of Object.keys(state)) delete state[k]; - Object.assign(state, tableInitialState); + reset: () => (state) => { + const table = state[name]; + for (const key of Object.keys(table)) delete table[key]; + Object.assign(table, tableInitialState); }, ...selectors, } satisfies TableOutput; - - return output; } /** diff --git a/src/store/store.ts b/src/store/store.ts index ab67d82c..55e70b13 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -8,6 +8,8 @@ import { } from "effection"; import { produce } from "immer"; import { ActionContext, emit } from "../action.js"; +import { parallel } from "../fx/parallel.js"; +import { supervise } from "../fx/supervisor.js"; import type { AnyAction } from "../types.js"; import { StoreContext } from "./context.js"; import { createRun } from "./run.js"; @@ -18,8 +20,6 @@ import type { Listener, SliceFromSchema, } from "./types.js"; -import { parallel } from "../fx/parallel.js"; -import { supervise } from "../fx/supervisor.js"; const stubMsg = "This is merely a stub, not implemented"; let id = 0; diff --git a/src/store/types.ts b/src/store/types.ts index 4f43facd..8103784b 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -29,8 +29,11 @@ export type Listener = () => void; /** * Context passed to store update middleware. */ -export interface UpdaterCtx extends BaseCtx { - updater: StoreUpdater | StoreUpdater[]; +export interface UpdaterCtx< + S extends AnyState, + U = StoreUpdater | StoreUpdater[], +> extends BaseCtx { + updater: U; patches: Patch[]; } @@ -80,6 +83,25 @@ export type SliceFromSchema = { [K in keyof O]: FactoryInitial; }; +type SliceActionUpdater = { + [K in keyof T]: T[K] extends (...args: never[]) => infer R + ? R extends StoreUpdater + ? R + : never + : never; +}[keyof T]; + +type BuiltInSchemaUpdater = + | SliceActionUpdater + | SliceActionUpdater>; + +export type SchemaUpdater = + | StoreUpdater> + | BuiltInSchemaUpdater + | { + [K in keyof O]: SliceActionUpdater>>; + }[keyof O]; + /** * Generated schema type mapping slice factories to runtime slice helpers. * @@ -87,17 +109,21 @@ export type SliceFromSchema = { * Extends generated helpers with schema lifecycle/update APIs. */ export type FxSchema = { - [key in keyof O]: ReturnType>; + [K in keyof O]: FactoryReturn>; } & { name: string; initialize?: () => Operation; update: ( - u: StoreUpdater> | StoreUpdater>[], - ) => Operation>>; + u: SchemaUpdater | SchemaUpdater[], + ) => Operation< + UpdaterCtx, SchemaUpdater | SchemaUpdater[]> + >; initialState: SliceFromSchema; reset: = keyof SliceFromSchema>( ignoreList?: K[], - ) => Operation>>; + ) => Operation< + UpdaterCtx, SchemaUpdater | SchemaUpdater[]> + >; }; /** @@ -122,7 +148,6 @@ export interface FxStore { resource: Operation, ) => Context; run: ReturnType; - initialize: (op: () => Operation) => Task; // part of redux store API dispatch: (a: AnyAction | AnyAction[]) => unknown; // part of redux store API diff --git a/src/test/api.test.ts b/src/test/api.test.ts index b2bc0e94..91b5d3da 100644 --- a/src/test/api.test.ts +++ b/src/test/api.test.ts @@ -38,7 +38,7 @@ const testStore = () => { loaders: slice.loaders(), cache: slice.table({ empty: {} }), }); - const store = createStore({ schemas: [schema] }); + const store = createStore({ schema }); return { schema, store }; }; @@ -103,7 +103,7 @@ test("POST", async () => { const schema = createSchema({ users: slice.table(), }); - const store = createStore({ schemas: [schema] }); + const store = createStore({ schema }); store.run(query.register); store.dispatch(createUser({ email: mockUser.email })); @@ -268,10 +268,13 @@ test("run() from a normal saga", async () => { const payload = { name: "/users/:id [GET]", options: { id: "1" } }; expect(extractedResults.actionType).toEqual(`${API_ACTION_PREFIX}${action1}`); - expect((extractedResults.actionPayload as any).name).toEqual(payload.name); - expect((extractedResults.actionPayload as any).options).toEqual( - payload.options, - ); + expect( + (extractedResults.actionPayload as unknown as { name: string }).name, + ).toEqual(payload.name); + expect( + (extractedResults.actionPayload as unknown as { options: { id: string } }) + .options, + ).toEqual(payload.options); expect(extractedResults.name).toEqual("/users/:id [GET]"); expect(extractedResults.payload).toEqual({ id: "1" }); expect(acc).toEqual("ab"); @@ -368,6 +371,7 @@ test("two identical endpoints", () => { expect(actual).toEqual(["/health", "/health"]); }); +// biome-ignore lint/suspicious/noExplicitAny: test helper should mirror ApiCtx defaults interface TestCtx

extends ApiCtx { something: boolean; } @@ -442,6 +446,7 @@ test("ensure ability to cast `ctx` in function definition", () => { expect(acc).toEqual(["1", "wow"]); }); +// biome-ignore lint/suspicious/noExplicitAny: test helper should mirror ApiCtx defaults type FetchUserSecondCtx = TestCtx; // this is strictly for testing types @@ -476,7 +481,7 @@ test("ensure ability to cast `ctx` in function definition with no props", () => }); test("should bubble up error", () => { - let error: any = null; + let error: unknown = null; const { store } = testStore(); const api = createApi(); api.use(function* (_, next) { @@ -493,14 +498,18 @@ test("should bubble up error", () => { "/users/8", { supervisor: takeEvery }, function* (ctx, _) { - (ctx.loader as any).meta = { key: ctx.payload.thisKeyDoesNotExist }; + if (!ctx.loader) { + ctx.loader = {}; + } + ctx.loader.meta = { key: ctx.payload.thisKeyDoesNotExist }; throw new Error("GENERATING AN ERROR"); }, ); store.run(api.register); store.dispatch(fetchUser()); - expect(error.message).toBe( + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( "Cannot read properties of undefined (reading 'thisKeyDoesNotExist')", ); }); diff --git a/src/test/batch.test.ts b/src/test/batch.test.ts index ba49f375..089c08fc 100644 --- a/src/test/batch.test.ts +++ b/src/test/batch.test.ts @@ -17,15 +17,13 @@ test("should batch notify subscribers based on mdw", async () => { middleware: [createBatchMdw(queueMicrotask)], }, ); - const store = createStore({ - schemas: [schema], - }); + const store = createStore({ schema }); let counter = 0; store.subscribe(() => { counter += 1; }); await store.run(function* () { - const group: any = yield* parallel([ + const group = yield* parallel([ () => schema.update(schema.cache.add({ "1": "one" })), () => schema.update(schema.cache.add({ "2": "two" })), () => schema.update(schema.cache.add({ "3": "three" })), diff --git a/src/test/fetch.test.ts b/src/test/fetch.test.ts index 213332df..8f92a9db 100644 --- a/src/test/fetch.test.ts +++ b/src/test/fetch.test.ts @@ -17,7 +17,7 @@ const testStore = () => { loaders: slice.loaders(), cache: slice.table({ empty: {} }), }); - const store = createStore({ schemas: [schema] }); + const store = createStore({ schema }); return { schema, store }; }; @@ -35,7 +35,7 @@ test("should be able to fetch a resource and save automatically", async () => { api.use(mdw.headers); api.use(mdw.fetch({ baseUrl })); - const actual: any[] = []; + const actual: unknown[] = []; const fetchUsers = api.get( "/users", { supervisor: takeEvery }, diff --git a/src/test/mdw.test.ts b/src/test/mdw.test.ts index 637b68e3..1e32554f 100644 --- a/src/test/mdw.test.ts +++ b/src/test/mdw.test.ts @@ -29,7 +29,7 @@ const emptyUser: User = { id: "", name: "", email: "" }; const mockUser: User = { id: "1", name: "test", email: "test@test.com" }; const mockUser2: User = { id: "2", name: "two", email: "two@test.com" }; -const jsonBlob = (data: any) => { +const jsonBlob = (data: unknown) => { return JSON.stringify(data); }; @@ -39,7 +39,7 @@ const testStore = () => { loaders: slice.loaders(), cache: slice.table({ empty: {} }), }); - const store = createStore({ schemas: [schema] }); + const store = createStore({ schema }); return { schema, store }; }; diff --git a/src/test/persist.test.ts b/src/test/persist.test.ts index 6ddb4e72..c9307965 100644 --- a/src/test/persist.test.ts +++ b/src/test/persist.test.ts @@ -47,7 +47,7 @@ test("can persist to storage adapters", async () => { }, { middleware: [mdw] }, ); - const store = createStore({ schemas: [schema] }); + const store = createStore({ schema }); await store.run(function* (): Operation { yield* persistor.rehydrate(); @@ -103,7 +103,7 @@ test("rehydrates state", async () => { }, { middleware: [mdw] }, ); - const store = createStore({ schemas: [schema] }); + const store = createStore({ schema }); await store.run(function* (): Operation { yield* persistor.rehydrate(); @@ -159,7 +159,7 @@ test("persists inbound state using transform 'in' function", async () => { }, { middleware: [mdw] }, ); - const store = createStore({ schemas: [schema] }); + const store = createStore({ schema }); await store.run(function* (): Operation { yield* persistor.rehydrate(); @@ -231,7 +231,7 @@ test("persists inbound state using tranform in (2)", async () => { { middleware: [mdw] }, ); type State = typeof schema.initialState; - const store = createStore({ schemas: [schema] }); + const store = createStore({ schema }); await store.run(function* (): Operation { yield* persistor.rehydrate(); @@ -283,10 +283,10 @@ test("persists a filtered nested part of a slice", async () => { if (state.loaders) { const maxLastRun: Record = {}; - const entryWithMaxLastRun: Record> = {}; + const entryWithMaxLastRun: Record = {}; for (const entryKey in state.loaders) { - const entry = state.loaders[entryKey] as LoaderItemState; + const entry = state.loaders[entryKey] as LoaderItemState; const sliceName = entryKey.split("[")[0].trim(); if (sliceName.includes("A") || sliceName.includes("C")) { if (!maxLastRun[sliceName] || entry.lastRun > maxLastRun[sliceName]) { @@ -318,7 +318,7 @@ test("persists a filtered nested part of a slice", async () => { { middleware: [mdw] }, ); type State = typeof schema.initialState; - const store = createStore({ schemas: [schema] }); + const store = createStore({ schema }); await store.run(function* (): Operation { yield* persistor.rehydrate(); @@ -394,7 +394,7 @@ test("handles the empty state correctly", async () => { }); const mdw = persistStoreMdw(persistor); - const store = createStore({ schemas: [schema] }); + const store = createStore({ schema }); await store.run(function* (): Operation { yield* persistor.rehydrate(); @@ -439,7 +439,7 @@ test("in absence of the inbound transformer, persists as it is", async () => { }, { middleware: [mdw] }, ); - const store = createStore({ schemas: [schema] }); + const store = createStore({ schema }); await store.run(function* (): Operation { yield* persistor.rehydrate(); @@ -492,7 +492,7 @@ test("handles errors gracefully, defaluts to identity function", async () => { transform, }); const mdw = persistStoreMdw(persistor); - const store = createStore({ schemas: [schema] }); + const store = createStore({ schema }); const err = console.error; console.error = () => {}; @@ -550,7 +550,7 @@ test("allowdList is filtered out after the inbound transformer is applied", asy }, { middleware: [mdw] }, ); - const store = createStore({ schemas: [schema] }); + const store = createStore({ schema }); await store.run(function* (): Operation { yield* persistor.rehydrate(); @@ -606,7 +606,7 @@ test("the inbound transformer can be redifined during runtime", async () => { { middleware: [mdw] }, ); type State = typeof schema.initialState; - const store = createStore({ schemas: [schema] }); + const store = createStore({ schema }); await store.run(function* (): Operation { yield* persistor.rehydrate(); @@ -665,7 +665,7 @@ test("persists state using transform 'out' function", async () => { }); const mdw = persistStoreMdw(persistor); - const store = createStore({ schemas: [schema] }); + const store = createStore({ schema }); await store.run(function* (): Operation { yield* persistor.rehydrate(); @@ -729,7 +729,7 @@ test("persists outbound state using tranform setOutTransformer", async () => { { middleware: [mdw] }, ); type State = typeof schema.initialState; - const store = createStore({ schemas: [schema] }); + const store = createStore({ schema }); await store.run(function* (): Operation { yield* persistor.rehydrate(); @@ -783,7 +783,7 @@ test("persists outbound a filtered nested part of a slice", async () => { }); const mdw = persistStoreMdw(persistor); - const store = createStore({ schemas: [schema] }); + const store = createStore({ schema }); await store.run(function* (): Operation { yield* persistor.rehydrate(); @@ -829,7 +829,7 @@ test("the outbound transformer can be reset during runtime", async () => { const transform = createTransform(); transform.out = revertToken; - const persistor = createPersistor({ + const persistor = createPersistor({ adapter, allowlist: ["token"], transform, @@ -846,7 +846,7 @@ test("the outbound transformer can be reset during runtime", async () => { { middleware: [mdw] }, ); type State = typeof schema.initialState; - const store = createStore({ schemas: [schema] }); + const store = createStore({ schema }); await store.run(function* (): Operation { yield* persistor.rehydrate(); diff --git a/src/test/react-types.test.ts b/src/test/react-types.test.ts index ebdfe993..4fa6367a 100644 --- a/src/test/react-types.test.ts +++ b/src/test/react-types.test.ts @@ -1,9 +1,11 @@ import { describe, expectTypeOf, test } from "vitest"; +import type { ThunkAction } from "../query/index.js"; import { type UseApiAction, type UseApiProps, type UseApiSimpleProps, type UseCacheResult, + createTypedHooks, useApi, useCache, useLoader, @@ -11,17 +13,22 @@ import { useSchema, useSchemaWithCache, useSchemaWithLoaders, + useSelector, useStore, } from "../react.js"; import { createSchema, createStore, slice } from "../store/index.js"; import type { - FxSchema, - FxStore, - SliceFromSchema, -} from "../store/types.js"; -import type { LoaderOutput, ObjOutput, TableOutput } from "../store/slice/index.js"; -import type { ActionFn, ActionFnWithPayload, AnyState, LoaderState } from "../types.js"; -import type { ThunkAction } from "../query/index.js"; + LoaderOutput, + ObjOutput, + TableOutput, +} from "../store/slice/index.js"; +import type { FxSchema, FxStore } from "../store/types.js"; +import type { + ActionFn, + ActionFnWithPayload, + AnyState, + LoaderState, +} from "../types.js"; interface User { id: string; @@ -42,85 +49,178 @@ declare const saveUserAction: ActionFnWithPayload; declare const refreshUsersAction: ActionFn; describe("react hook types", () => { - test("typed schema/store creation works with react-facing types", () => { - const schema = createSchema({ - cache: slice.table(), - loaders: slice.loaders(), - metadata: slice.obj({}), - users: slice.table(), + describe("global hooks without schema hints", () => { + test("typed schema/store creation works with react-facing types", () => { + const schema = createSchema({ + cache: slice.table(), + loaders: slice.loaders(), + metadata: slice.obj({}), + users: slice.table(), + }); + const store = createStore({ schema }); + + expectTypeOf(schema).toExtend>(); + expectTypeOf(store).toExtend>(); }); - const store = createStore({ schemas: [schema] }); - expectTypeOf(schema).toExtend>(); - expectTypeOf(store).toExtend>(); - }); + test("bare useSelector works with schema selectors", () => { + const schema = createSchema({ + cache: slice.table(), + loaders: slice.loaders(), + metadata: slice.obj({}), + users: slice.table(), + }); + const useUsers = () => useSelector(schema.users.selectTableAsList); + + expectTypeOf(useUsers).returns.toEqualTypeOf< + ReturnType + >(); + }); - test("useSchema returns typed schema", () => { - const useTypedSchema = () => useSchema(); - expectTypeOf(useTypedSchema).returns.toEqualTypeOf>(); - }); + test("useApi infers thunk action result", () => { + const useThunkApi = () => useApi(fetchUsersAction); + expectTypeOf(useThunkApi).returns.toExtend< + UseApiAction + >(); + }); - test("useSchemaWithLoaders returns typed schema", () => { - type WithLoaders = AppMap & { loaders: (name: string) => LoaderOutput }; - const useTypedSchema = () => useSchemaWithLoaders(); - expectTypeOf(useTypedSchema).returns.toEqualTypeOf>(); - }); + test("useApi infers payload action function result", () => { + const usePayloadApi = () => useApi(saveUserAction); + expectTypeOf(usePayloadApi).returns.toExtend>(); + }); - test("useSchemaWithCache returns typed schema", () => { - type WithCache = AppMap & { cache: (name: string) => TableOutput }; - const useTypedSchema = () => useSchemaWithCache(); - expectTypeOf(useTypedSchema).returns.toEqualTypeOf>(); - }); + test("useApi infers simple action function result", () => { + const useSimpleApi = () => useApi(refreshUsersAction); + expectTypeOf(useSimpleApi).returns.toExtend(); + }); - test("useStore returns typed store", () => { - const useTypedStore = () => useStore(); - expectTypeOf(useTypedStore).returns.toEqualTypeOf>(); + test("useQuery returns thunk api shape", () => { + const useThunkQuery = () => useQuery(fetchUsersAction); + expectTypeOf(useThunkQuery).returns.toExtend< + UseApiAction + >(); + }); }); - test("useLoader accepts thunk actions and returns loader state", () => { - const useThunkLoader = () => useLoader(fetchUsersAction); - expectTypeOf(useThunkLoader).returns.toEqualTypeOf(); - }); + describe("schema-bound typed hooks", () => { + test("useSelector exposes schema state to userland", () => { + const schema = createSchema({ + cache: slice.table(), + loaders: slice.loaders(), + metadata: slice.obj({}), + users: slice.table(), + }); + const hooks = createTypedHooks(schema); + const useUsers = () => hooks.useSelector((state) => state.users); + const useMetadata = () => + hooks.useSelector((state) => state.metadata.example); + + expectTypeOf(useUsers).returns.toEqualTypeOf< + Record + >(); + expectTypeOf(useMetadata).returns.toEqualTypeOf(); + }); - test("useApi infers thunk action result", () => { - const useThunkApi = () => useApi(fetchUsersAction); - expectTypeOf(useThunkApi).returns.toExtend< - UseApiAction - >(); + test("createTypedHooks infers all hook state from schema input", () => { + const schema = createSchema({ + cache: slice.table(), + loaders: slice.loaders(), + metadata: slice.obj({}), + users: slice.table(), + }); + const hooks = createTypedHooks(schema); + const useUsers = () => hooks.useSelector((state) => state.users); + const useSchemaState = () => hooks.useSchema(); + const useTypedLoader = () => hooks.useLoader(fetchUsersAction); + + expectTypeOf(useUsers).returns.toEqualTypeOf< + Record + >(); + expectTypeOf(useSchemaState).returns.toEqualTypeOf(); + expectTypeOf(useTypedLoader).returns.toEqualTypeOf(); + }); }); - test("useApi infers payload action function result", () => { - const usePayloadApi = () => useApi(saveUserAction); - expectTypeOf(usePayloadApi).returns.toExtend>(); - }); + describe("direct hooks with explicit schema generics", () => { + test("useSchema returns typed schema", () => { + const useTypedSchema = () => useSchema(); + expectTypeOf(useTypedSchema).returns.toEqualTypeOf>(); + }); - test("useApi infers simple action function result", () => { - const useSimpleApi = () => useApi(refreshUsersAction); - expectTypeOf(useSimpleApi).returns.toExtend(); - }); + test("useSchemaWithLoaders returns typed schema", () => { + type WithLoaders = AppMap & { loaders: (name: string) => LoaderOutput }; + const useTypedSchema = () => useSchemaWithLoaders(); + expectTypeOf(useTypedSchema).returns.toEqualTypeOf< + FxSchema + >(); + }); - test("useQuery returns thunk api shape", () => { - const useThunkQuery = () => useQuery(fetchUsersAction); - expectTypeOf(useThunkQuery).returns.toExtend< - UseApiAction - >(); - }); + test("useSchemaWithCache returns typed schema", () => { + type WithCache = AppMap & { + cache: (name: string) => TableOutput; + }; + const useTypedSchema = () => useSchemaWithCache(); + expectTypeOf(useTypedSchema).returns.toEqualTypeOf>(); + }); - test("useCache returns cached thunk result type", () => { - const useThunkCache = () => useCache(fetchUsersAction); - expectTypeOf(useThunkCache).returns.toExtend< - UseCacheResult - >(); - }); + test("useStore returns typed store", () => { + const useTypedStore = () => useStore(); + expectTypeOf(useTypedStore).returns.toEqualTypeOf>(); + }); + + test("useSelector supports explicit state and selected generics", () => { + const useUsers = () => + useSelector>( + (state) => state.users, + ); + + expectTypeOf(useUsers).returns.toEqualTypeOf< + Record + >(); + }); + + test("hooks support explicit schema hints when used directly", () => { + const useTypedApi = () => + useApi(fetchUsersAction); + const useTypedQuery = () => + useQuery(fetchUsersAction); + const useTypedCache = () => + useCache(fetchUsersAction); + + expectTypeOf(useTypedApi).returns.toExtend< + UseApiAction + >(); + expectTypeOf(useTypedQuery).returns.toExtend< + UseApiAction + >(); + expectTypeOf(useTypedCache).returns.toExtend< + UseCacheResult + >(); + }); + + test("useLoader accepts thunk actions and returns loader state", () => { + const useThunkLoader = () => + useLoader(fetchUsersAction); + expectTypeOf(useThunkLoader).returns.toEqualTypeOf(); + }); + + test("useCache returns cached thunk result type", () => { + const useThunkCache = () => + useCache(fetchUsersAction); + expectTypeOf(useThunkCache).returns.toExtend< + UseCacheResult + >(); + }); - test("useSchema supports custom slice selectors", () => { - const useMetadataSelect = () => { - const schema = useSchema(); - return schema.metadata.select; - }; + test("useSchema supports custom slice selectors", () => { + const useMetadataSelect = () => { + const schema = useSchema(); + return schema.metadata.select; + }; - expectTypeOf(useMetadataSelect).returns.toExtend< - ObjOutput["select"] - >(); + expectTypeOf(useMetadataSelect).returns.toExtend< + ObjOutput["select"] + >(); + }); }); }); diff --git a/src/test/react.test.ts b/src/test/react.test.ts index c2c94c24..053181d7 100644 --- a/src/test/react.test.ts +++ b/src/test/react.test.ts @@ -9,8 +9,8 @@ test("react types", () => { cache: slice.table(), loaders: slice.loaders(), }); - const store = createStore({ schemas: [schema] }); - React.createElement(Provider, { + const store = createStore({ schema }); + Provider({ schema, store, children: React.createElement("div"), diff --git a/src/test/schema.test.ts b/src/test/schema.test.ts index 1bfd5b1f..c776512c 100644 --- a/src/test/schema.test.ts +++ b/src/test/schema.test.ts @@ -13,24 +13,27 @@ const emptyUser = { id: "", name: "" }; test("default schema", async () => { const schema = createSchema(); - const store = createStore({ schemas: [schema] }); + const store = createStore({ schema }); + const { cache, loaders } = schema; + + if (!cache || !loaders) { + throw new Error("default schema should include cache and loaders"); + } + expect(store.getState()).toEqual({ cache: {}, loaders: {}, }); - await store.initialize(function* () { - yield* schema.update(schema.loaders.start({ id: "1" })); - yield* schema.update(schema.cache.add({ "1": true })); + await store.run(function* () { + yield* schema.update(loaders.start({ id: "1" })); + yield* schema.update(cache.add({ "1": { ready: true } })); }); - expect(schema.cache.selectTable(store.getState())).toEqual({ - "1": true, + expect(cache.selectTable(store.getState())).toEqual({ + "1": { ready: true }, }); - expect( - schema.loaders.selectById(store.getState(), { id: "1" }).status, - "loading", - ); + expect(loaders.selectById(store.getState(), { id: "1" }).status, "loading"); }); test("general types and functionality", async () => { @@ -58,33 +61,37 @@ test("general types and functionality", async () => { cache: {}, loaders: {}, }); + type State = ReturnType; const userMap = db.users.selectTable(store.getState()); expect(userMap).toEqual({ "1": { id: "1", name: "wow" } }); - await store.initialize(function* () { + await store.run(function* () { yield* db.update([ db.users.add({ "2": { id: "2", name: "bob" } }), db.users.patch({ "1": { name: "zzz" } }), ]); - const users = yield* select(db.users.selectTable); + const users = yield* select((state: State) => db.users.selectTable(state)); expect(users).toEqual({ "1": { id: "1", name: "zzz" }, "2": { id: "2", name: "bob" }, }); yield* db.update(db.counter.increment()); - const counter = yield* select(db.counter.select); + const counter = yield* select((state: State) => db.counter.select(state)); expect(counter).toBe(1); yield* db.update(db.currentUser.update({ key: "name", value: "vvv" })); - const curUser = yield* select(db.currentUser.select); + const curUser = yield* select((state: State) => + db.currentUser.select(state), + ); expect(curUser).toEqual({ id: "", name: "vvv" }); yield* db.update(db.loaders.start({ id: "fetch-users" })); - const fetchLoader = yield* select(db.loaders.selectById, { - id: "fetch-users", - }); + const fetchLoader = yield* select( + (state: State, id: string) => db.loaders.selectById(state, { id }), + "fetch-users", + ); expect(fetchLoader.id).toBe("fetch-users"); expect(fetchLoader.status).toBe("loading"); expect(fetchLoader.lastRun).not.toBe(0); @@ -99,19 +106,26 @@ test("can work with a nested object", async () => { loaders: slice.loaders(), }); const store = createStore({ schemas: [db] }); - await store.initialize(function* () { + type State = ReturnType; + await store.run(function* () { yield* db.update(db.currentUser.update({ key: "name", value: "vvv" })); - const curUser = yield* select(db.currentUser.select); + const curUser = yield* select((state: State) => + db.currentUser.select(state), + ); expect(curUser).toEqual({ id: "", name: "vvv", roles: [] }); yield* db.update(db.currentUser.update({ key: "roles", value: ["admin"] })); - const curUser2 = yield* select(db.currentUser.select); + const curUser2 = yield* select((state: State) => + db.currentUser.select(state), + ); expect(curUser2).toEqual({ id: "", name: "vvv", roles: ["admin"] }); yield* db.update( db.currentUser.update({ key: "roles", value: ["admin", "users"] }), ); - const curUser3 = yield* select(db.currentUser.select); + const curUser3 = yield* select((state: State) => + db.currentUser.select(state), + ); expect(curUser3).toEqual({ id: "", name: "vvv", diff --git a/src/test/store.test.ts b/src/test/store.test.ts index d2359f93..1e2d078a 100644 --- a/src/test/store.test.ts +++ b/src/test/store.test.ts @@ -14,6 +14,7 @@ import { StoreUpdateContext, createSchema, createStore, + manage, slice, updateStore, } from "../store/index.js"; @@ -79,7 +80,7 @@ test("update store and receives update from channel `StoreUpdateContext`", async const schema = testSchema({ users: { 1: { id: "1", name: "testing" }, 2: { id: "2", name: "wow" } }, }); - const testStore = createStore({ scope, schemas: [schema] }); + const testStore = createStore({ scope, schema }); // biome-ignore lint/suspicious/noExplicitAny: test-only let store: any; await scope.run(function* (): Operation[]> { @@ -93,7 +94,7 @@ test("update store and receives update from channel `StoreUpdateContext`", async function* () { // TODO we may need to consider how to handle this, is it a breaking change? yield* sleep(0); - yield* updateStore(updateUser({ id: "1", name: "eric" })); + yield* updateStore(updateUser({ id: "1", name: "eric" })); }, ]); return yield* result; @@ -111,7 +112,7 @@ test("update store and receives update from `subscribe()`", async () => { const schema = testSchema({ users: { 1: { id: "1", name: "testing" }, 2: { id: "2", name: "wow" } }, }); - const store = createStore({ schemas: [schema] }); + const store = createStore({ schema }); store.subscribe(() => { expect(store.getState()).toEqual({ @@ -123,7 +124,7 @@ test("update store and receives update from `subscribe()`", async () => { }); await store.run(function* () { - yield* updateStore(updateUser({ id: "1", name: "eric" })); + yield* updateStore(updateUser({ id: "1", name: "eric" })); }); }); @@ -132,13 +133,13 @@ test("emit Action and update store", async () => { const schema = testSchema({ users: { 1: { id: "1", name: "testing" }, 2: { id: "2", name: "wow" } }, }); - const store = createStore({ schemas: [schema] }); + const store = createStore({ schema }); await store.run(function* (): Operation { const result = yield* parallel([ function* (): Operation { const action = yield* take("UPDATE_USER"); - yield* updateStore(updateUser(action.payload)); + yield* updateStore(updateUser(action.payload)); }, function* () { // TODO we may need to consider how to handle this, is it a breaking change? @@ -162,7 +163,7 @@ test("resets store", async () => { const schema = testSchema({ users: { 1: { id: "1", name: "testing" }, 2: { id: "2", name: "wow" } }, }); - const store = createStore({ schemas: [schema] }); + const store = createStore({ schema }); await store.run(function* () { yield* schema.update((s: State) => { @@ -215,16 +216,25 @@ describe(".manage", () => { const thunk = createThunks(); thunk.use(thunk.routes()); - const store = createStore({ schemas: [createSchema()] }); - const TestContext = store.manage("test:context", guessAge()); - store.initialize(thunk.register); + const [scope] = createScope(); + const TestContext = manage("test:context", guessAge()); + const store = createStore({ + scope, + schemas: [createSchema()], + tasks: [TestContext.initialize(scope), thunk.register], + }); let acc = "bla"; const action = thunk.create("/users", function* (payload, next) { const c = yield* TestContext.get(); if (c) acc += "b"; next(); }); - store.dispatch(action()); + + await store.run(function* (): Operation { + yield* sleep(0); + store.dispatch(action()); + yield* sleep(0); + }); expect(acc).toBe("blab"); }); @@ -234,9 +244,13 @@ describe(".manage", () => { const thunk = createThunks(); thunk.use(thunk.routes()); - const store = createStore({ schemas: [createSchema()] }); - const TestContext = store.manage("test:context", guessAge()); - store.initialize(thunk.register); + const [scope] = createScope(); + const TestContext = manage("test:context", guessAge()); + const store = createStore({ + scope, + schemas: [createSchema()], + tasks: [TestContext.initialize(scope), thunk.register], + }); let guess = 0; let acc = 0; const action = thunk.create("/users", function* (payload, next) { @@ -245,7 +259,12 @@ describe(".manage", () => { acc += c.cumulative ?? 0; next(); }); - store.dispatch(action()); + + await store.run(function* (): Operation { + yield* sleep(0); + store.dispatch(action()); + yield* sleep(0); + }); expect(guess).toBeGreaterThan(0); expect(acc).toEqual(guess); diff --git a/src/test/store/slice/obj.test.ts b/src/test/store/slice/obj.test.ts index df5b26d7..d24cc530 100644 --- a/src/test/store/slice/obj.test.ts +++ b/src/test/store/slice/obj.test.ts @@ -28,11 +28,9 @@ test("sets up an obj", async () => { const schema = createSchema({ [NAME]: () => slice, }); - const store = createStore({ - schemas: [schema], - }); + const store = createStore({ schema }); - await store.initialize(function* () { + await store.run(function* () { yield* updateStore( slice.set({ username: "bob", @@ -50,7 +48,7 @@ test("sets up an obj", async () => { roles: ["admin", "user"], }); - await store.initialize(function* () { + await store.run(function* () { yield* updateStore(slice.update({ key: "username", value: "alice" })); }); @@ -61,7 +59,7 @@ test("sets up an obj", async () => { roles: ["admin", "user"], }); - await store.initialize(function* () { + await store.run(function* () { yield* updateStore( slice.update({ key: "roles", value: ["admin", "superuser"] }), ); diff --git a/src/test/store/slice/table.test.ts b/src/test/store/slice/table.test.ts index ad4405be..1bbc4019 100644 --- a/src/test/store/slice/table.test.ts +++ b/src/test/store/slice/table.test.ts @@ -24,11 +24,9 @@ test("sets up a table", async () => { const schema = createSchema({ [NAME]: () => tableSlice, }); - const store = createStore({ - schemas: [schema], - }); + const store = createStore({ schema }); - await store.initialize(function* () { + await store.run(function* () { yield* updateStore(tableSlice.set({ [first.id]: first })); }); expect(store.getState()[NAME]).toEqual({ [first.id]: first }); @@ -38,11 +36,9 @@ test("adds a row", async () => { const schema = createSchema({ [NAME]: () => tableSlice, }); - const store = createStore({ - schemas: [schema], - }); + const store = createStore({ schema }); - await store.initialize(function* () { + await store.run(function* () { yield* updateStore(tableSlice.set({ [second.id]: second })); }); expect(store.getState()[NAME]).toEqual({ 2: second }); @@ -52,12 +48,10 @@ test("removes a row", async () => { const schema = createSchema({ [NAME]: () => tableSlice, }); - const store = createStore({ - schemas: [schema], - }); + const store = createStore({ schema }); // Pre-populate the store - await store.initialize(function* () { + await store.run(function* () { yield* updateStore( tableSlice.set({ [first.id]: first, [second.id]: second }), ); @@ -71,10 +65,8 @@ test("updates a row", async () => { const schema = createSchema({ [NAME]: () => tableSlice, }); - const store = createStore({ - schemas: [schema], - }); - await store.initialize(function* () { + const store = createStore({ schema }); + await store.run(function* () { const updated = { id: second.id, user: "BB" }; yield* updateStore(tableSlice.set({ [second.id]: second })); yield* updateStore(tableSlice.patch({ [updated.id]: updated })); @@ -88,10 +80,8 @@ test("gets a row", async () => { const schema = createSchema({ [NAME]: () => tableSlice, }); - const store = createStore({ - schemas: [schema], - }); - await store.initialize(function* () { + const store = createStore({ schema }); + await store.run(function* () { yield* updateStore( tableSlice.add({ [first.id]: first, @@ -109,9 +99,7 @@ test("when the record doesnt exist, it returns empty record", () => { const schema = createSchema({ [NAME]: () => tableSlice, }); - const store = createStore({ - schemas: [schema], - }); + const store = createStore({ schema }); const row = tableSlice.selectById(store.getState(), { id: "2" }); expect(row).toEqual(empty); @@ -121,11 +109,9 @@ test("gets all rows", async () => { const schema = createSchema({ [NAME]: () => tableSlice, }); - const store = createStore({ - schemas: [schema], - }); + const store = createStore({ schema }); const data = { [first.id]: first, [second.id]: second, [third.id]: third }; - await store.initialize(function* () { + await store.run(function* () { yield* updateStore(tableSlice.add(data)); }); expect(store.getState()[NAME]).toEqual(data); @@ -137,12 +123,10 @@ test("with empty", async () => { const schema = createSchema({ users: () => tbl, }); - const store = createStore({ - schemas: [schema], - }); + const store = createStore({ schema }); expect(tbl.empty).toEqual(first); - await store.initialize(function* () { + await store.run(function* () { yield* updateStore(tbl.set({ [first.id]: first })); }); expect(tbl.selectTable(store.getState())).toEqual({ @@ -158,12 +142,10 @@ test("with no empty", async () => { const schema = createSchema({ users: () => tbl, }); - const store = createStore({ - schemas: [schema], - }); + const store = createStore({ schema }); expect(tbl.empty).toEqual(undefined); - await store.initialize(function* () { + await store.run(function* () { yield* updateStore(tbl.set({ [first.id]: first })); }); expect(tbl.selectTable(store.getState())).toEqual({ diff --git a/src/test/thunk.test.ts b/src/test/thunk.test.ts index a047f628..5361362e 100644 --- a/src/test/thunk.test.ts +++ b/src/test/thunk.test.ts @@ -16,27 +16,13 @@ import { } from "../store/index.js"; import { describe, expect, test } from "../test.js"; -import type { - CreateAction, - CreateActionWithPayload, - Next, - Operation, - ThunkCtx, -} from "../index.js"; -import type { IfAny } from "../query/types.js"; +import type { Next, Operation, ThunkCtx } from "../index.js"; +// biome-ignore lint/suspicious/noExplicitAny: matches ThunkCtx default required by createThunks() interface RoboCtx, P = any> extends ThunkCtx

{ url: string; request: { method: string; body?: Record }; response: D; - name: string; - key: string; - action: any; - actionFn: IfAny< - P, - CreateAction, any>, - CreateActionWithPayload, P, any> - >; } interface User { @@ -159,7 +145,7 @@ test("when create a query fetch pipeline - execute all middleware and save to re users: slice.table({ empty: { id: "", name: "", email: "" } }), tickets: slice.table({ empty: { id: "", name: "" } }), }); - const store = createStore({ schemas: [schema] }); + const store = createStore({ schema }); store.run(api.register); store.dispatch(fetchUsers()); @@ -203,7 +189,7 @@ test("when providing a generator the to api.create function - should call that g users: slice.table({ empty: { id: "", name: "", email: "" } }), tickets: slice.table({ empty: { id: "", name: "" } }), }); - const store = createStore({ schemas: [schema] }); + const store = createStore({ schema }); store.run(api.register); store.dispatch(fetchTickets()); @@ -217,7 +203,7 @@ test("when providing a generator the to api.create function - should call that g test("error handling", () => { expect.assertions(1); - let called; + let called = false; const api = createThunks(); api.use(api.routes()); api.use(function* upstream(_, next) { diff --git a/tsconfig.lib.json b/tsconfig.lib.json new file mode 100644 index 00000000..a7f83780 --- /dev/null +++ b/tsconfig.lib.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "module": "Node16", + "moduleResolution": "node16", + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist/types", + "removeComments": false + }, + "include": ["src"], + "exclude": ["examples/**"] +} From 5d7fe3f0d85a072eb4cba6f604630cf28de013b5 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Tue, 14 Apr 2026 00:48:20 -0500 Subject: [PATCH 21/35] add started to parallel --- src/fx/parallel.ts | 7 ++++++- src/query/thunk.ts | 8 +++++++- src/store/store.ts | 12 ++++++++++++ src/test/parallel.test.ts | 39 ++++++++++++++++++++++++++++++++++++++- src/test/store.test.ts | 4 ---- 5 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/fx/parallel.ts b/src/fx/parallel.ts index a94e3959..5488b201 100644 --- a/src/fx/parallel.ts +++ b/src/fx/parallel.ts @@ -1,10 +1,11 @@ import type { Channel, Operation, Result, Task } from "effection"; -import { createChannel, resource, spawn } from "effection"; +import { createChannel, resource, spawn, withResolvers } from "effection"; import { safe } from "./safe.js"; export interface ParallelRet extends Operation[]> { sequence: Channel, void>; immediate: Channel, void>; + started: Operation; } /** @@ -88,6 +89,7 @@ export function parallel( const sequence = createChannel>(); const immediate = createChannel>(); const results: Result[] = []; + const started = withResolvers(); return resource>(function* (provide) { const task = yield* spawn(function* () { @@ -102,6 +104,8 @@ export function parallel( ); } + started.resolve(); + for (const tsk of tasks) { const res = yield* tsk; results.push(res); @@ -115,6 +119,7 @@ export function parallel( yield* provide({ sequence, immediate, + started: started.operation, *[Symbol.iterator]() { yield* task; return results; diff --git a/src/query/thunk.ts b/src/query/thunk.ts index 40a6c597..ae80d844 100644 --- a/src/query/thunk.ts +++ b/src/query/thunk.ts @@ -53,7 +53,13 @@ export interface ThunksApi { use: (fn: Middleware) => void; /** Returns a middleware function that routes to action-specific middleware. */ routes: () => Middleware; - /** Register the thunks with the current store scope. */ + /** + * Register the thunks with the current store scope. + * + * This is a long-lived listener task. Callers who dispatch immediately after + * starting registration must coordinate any required startup readiness + * themselves. + */ register: () => Operation; /** Reset any dynamically bound middleware for created actions. */ reset: () => void; diff --git a/src/store/store.ts b/src/store/store.ts index 55e70b13..4f8be400 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -70,6 +70,15 @@ export interface CreateStore { scope?: Scope; schema?: FxSchema; schemas?: FxSchema[]; + /** + * Long-lived startup operations to run inside the store scope. + * + * These tasks are lifecycle-managed by the store, but `createStore()` does + * not guarantee that arbitrary custom tasks have reached a caller-defined + * ready state before the first dispatch. If a custom task needs stronger + * startup coordination, it should expose and manage that readiness explicitly + * using existing primitives. + */ tasks?: (() => Operation)[]; } @@ -95,6 +104,9 @@ export const ListenersContext = createContext>( * @param options - Store configuration object. * @param options.scope - Optional Effection scope to use. * @param options.schemas - Schema list used to compose initial state. + * @param options.tasks - Long-lived startup operations to run in the + * store scope. Arbitrary custom tasks are started with the store, but they are + * not awaited to a caller-defined ready state. * @returns A fully configured store instance. * * @example diff --git a/src/test/parallel.test.ts b/src/test/parallel.test.ts index 850ded77..0eeb400f 100644 --- a/src/test/parallel.test.ts +++ b/src/test/parallel.test.ts @@ -87,7 +87,7 @@ test("should return all the result in an array, preserving order", async () => { }); test("should return empty array", async () => { - let actual; + let actual: Result[] = []; await run(function* (): Operation { const results = yield* parallel([]); actual = yield* results; @@ -95,6 +95,43 @@ test("should return empty array", async () => { expect(actual).toEqual([]); }); +test("should resolve started after all operations are spawned", async () => { + const result = await run(function* () { + let completed = 0; + const group = yield* parallel([ + function* () { + yield* sleep(20); + completed += 1; + return "first"; + }, + function* () { + yield* sleep(20); + completed += 1; + return "second"; + }, + ]); + + yield* group.started; + const startedBeforeCompletion = completed === 0; + + yield* group; + + return startedBeforeCompletion; + }); + + expect(result).toBe(true); +}); + +test("should resolve started for an empty operation list", async () => { + const result = await run(function* () { + const group = yield* parallel([]); + yield* group.started; + return "ready"; + }); + + expect(result).toBe("ready"); +}); + test("should resolve all async items", async () => { const two = defer(); diff --git a/src/test/store.test.ts b/src/test/store.test.ts index 1e2d078a..27586242 100644 --- a/src/test/store.test.ts +++ b/src/test/store.test.ts @@ -231,9 +231,7 @@ describe(".manage", () => { }); await store.run(function* (): Operation { - yield* sleep(0); store.dispatch(action()); - yield* sleep(0); }); expect(acc).toBe("blab"); @@ -261,9 +259,7 @@ describe(".manage", () => { }); await store.run(function* (): Operation { - yield* sleep(0); store.dispatch(action()); - yield* sleep(0); }); expect(guess).toBeGreaterThan(0); From d13d5a5aba180b01fc9e856bcf85a35761fb1677 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Tue, 14 Apr 2026 02:08:59 -0500 Subject: [PATCH 22/35] examples and lints --- examples/basic/src/main.tsx | 26 +++++++------ examples/basic/tsconfig.json | 7 ++++ examples/tests-rtl/src/api.ts | 8 +++- examples/tests-rtl/src/app.tsx | 6 +-- examples/tests-rtl/src/store.ts | 6 +-- examples/tests-rtl/tests/app.test.tsx | 1 - examples/tests-rtl/tsconfig.json | 11 ++++-- examples/vite-react/src/App.tsx | 6 +-- examples/vite-react/src/api.ts | 5 ++- examples/vite-react/src/main.tsx | 19 ++++----- examples/vite-react/tsconfig.json | 7 ++++ examples/yjs/src/App.tsx | 6 +-- examples/yjs/src/main.tsx | 20 +++++----- examples/yjs/src/store/schema.ts | 55 ++++++++++++--------------- examples/yjs/src/thunks.ts | 18 +++++++-- examples/yjs/tsconfig.json | 7 ++++ package.json | 1 + src/mdw/store.ts | 2 +- src/store/slice/num.ts | 2 +- src/store/slice/str.ts | 2 +- src/store/slice/table.ts | 2 +- src/store/types.ts | 4 +- 22 files changed, 126 insertions(+), 95 deletions(-) diff --git a/examples/basic/src/main.tsx b/examples/basic/src/main.tsx index 23dad0e4..0cd9ae03 100644 --- a/examples/basic/src/main.tsx +++ b/examples/basic/src/main.tsx @@ -1,11 +1,21 @@ import ReactDOM from "react-dom/client"; -import { createApi, createSchema, createStore, mdw, timer } from "starfx"; +import { + createApi, + createSchema, + createStore, + mdw, + slice, + timer, +} from "starfx"; import { Provider, useCache } from "starfx/react"; -const schema = createSchema(); -const store = createStore({ schemas: [schema] }); - const api = createApi(); +const schema = createSchema({ + cache: slice.table(), + loaders: slice.loaders(), +}); +const store = createStore({ schema, tasks: [api.register] }); + // mdw = middleware api.use(mdw.api({ schema })); api.use(api.routes()); @@ -17,14 +27,8 @@ const fetchRepo = api.get( api.cache() ); -store.run(api.register); - function App() { - return ( - - - - ); + return ; } function Example() { diff --git a/examples/basic/tsconfig.json b/examples/basic/tsconfig.json index c81ef9f3..4608b94f 100644 --- a/examples/basic/tsconfig.json +++ b/examples/basic/tsconfig.json @@ -1,5 +1,8 @@ { "compilerOptions": { + "baseUrl": ".", + "forceConsistentCasingInFileNames": true, + "ignoreDeprecations": "5.0", "target": "ESNext", "lib": ["DOM", "DOM.Iterable", "ESNext"], "module": "ESNext", @@ -12,6 +15,10 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", + "paths": { + "starfx": ["../../src/index.ts"], + "starfx/react": ["../../src/react.ts"] + }, /* Linting */ "strict": true, diff --git a/examples/tests-rtl/src/api.ts b/examples/tests-rtl/src/api.ts index c9abe627..6ee90fb5 100644 --- a/examples/tests-rtl/src/api.ts +++ b/examples/tests-rtl/src/api.ts @@ -1,6 +1,8 @@ import { createApi, createSchema, mdw, slice } from "starfx"; +import { createTypedHooks } from "starfx/react"; const emptyUser = { id: "", name: "" }; +type User = typeof emptyUser; export const schema = createSchema({ users: slice.table({ empty: emptyUser }), cache: slice.table(), @@ -19,10 +21,14 @@ export const fetchUsers = api.get("/users", function* (ctx, next) { return; } - const users = ctx.json.value.reduce((acc, user) => { + const users = (ctx.json.value as User[]).reduce>((acc, user) => { acc[user.id] = user; return acc; }, {}); yield* schema.update(schema.users.add(users)); }); + + +const { useSelector } = createTypedHooks(schema); +export { useSelector }; diff --git a/examples/tests-rtl/src/app.tsx b/examples/tests-rtl/src/app.tsx index a7efae1e..c81764bb 100644 --- a/examples/tests-rtl/src/app.tsx +++ b/examples/tests-rtl/src/app.tsx @@ -1,7 +1,7 @@ -import { useDispatch, useSelector } from "starfx/react"; -import { fetchUsers, schema } from "./api"; +import { useDispatch } from "starfx/react"; +import { fetchUsers, schema, useSelector } from "./api"; -export function App({ id }) { +export function App({ id }: { id: string }) { const dispatch = useDispatch(); const user = useSelector((s) => schema.users.selectById(s, { id })); const userList = useSelector(schema.users.selectTableAsList); diff --git a/examples/tests-rtl/src/store.ts b/examples/tests-rtl/src/store.ts index 3f3cebf5..f241679f 100644 --- a/examples/tests-rtl/src/store.ts +++ b/examples/tests-rtl/src/store.ts @@ -2,11 +2,11 @@ import { api, schema } from "./api"; import { createStore } from "starfx"; export function setupStore({ initialState = {} } = {}) { + void initialState; const store = createStore({ - schemas: [schema], + schema, + tasks: [api.register], }); - store.run(api.register); - return store; } diff --git a/examples/tests-rtl/tests/app.test.tsx b/examples/tests-rtl/tests/app.test.tsx index 482fb2bc..2dba4b4c 100644 --- a/examples/tests-rtl/tests/app.test.tsx +++ b/examples/tests-rtl/tests/app.test.tsx @@ -1,4 +1,3 @@ -import React from "react"; import "@testing-library/jest-dom"; import { expect, test } from "@jest/globals"; import { fireEvent, render, screen, waitFor } from "./utils"; diff --git a/examples/tests-rtl/tsconfig.json b/examples/tests-rtl/tsconfig.json index bc68887f..abf953c2 100644 --- a/examples/tests-rtl/tsconfig.json +++ b/examples/tests-rtl/tsconfig.json @@ -2,7 +2,8 @@ "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "baseUrl": ".", - "types": ["vitest/globals"], + "forceConsistentCasingInFileNames": true, + "ignoreDeprecations": "5.0", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, @@ -17,7 +18,11 @@ "isolatedModules": true, "noEmit": true, "noUnusedLocals": true, - "jsx": "react-jsx" + "jsx": "react-jsx", + "paths": { + "starfx": ["../../src/index.ts"], + "starfx/react": ["../../src/react.ts"] + } }, - "include": ["./src"] + "include": ["./src", "./tests"] } diff --git a/examples/vite-react/src/App.tsx b/examples/vite-react/src/App.tsx index 65e95dbb..c8d3145e 100644 --- a/examples/vite-react/src/App.tsx +++ b/examples/vite-react/src/App.tsx @@ -1,12 +1,8 @@ import { - TypedUseSelectorHook, useDispatch, - useSelector as useSel, } from "starfx/react"; import "./App.css"; -import { AppState, fetchUsers, schema } from "./api.ts"; - -const useSelector: TypedUseSelectorHook = useSel; +import { fetchUsers, schema, useSelector } from "./api.ts"; function App({ id }: { id: string }) { const dispatch = useDispatch(); diff --git a/examples/vite-react/src/api.ts b/examples/vite-react/src/api.ts index 72827643..616445e8 100644 --- a/examples/vite-react/src/api.ts +++ b/examples/vite-react/src/api.ts @@ -1,5 +1,6 @@ import { createApi, createSchema, mdw, slice } from "starfx"; import { guessAge } from "./age-guess"; +import { createTypedHooks } from "starfx/react"; interface User { id: string; @@ -13,7 +14,6 @@ export const schema = createSchema({ cache: slice.table(), loaders: slice.loaders(), }); -export type AppState = typeof schema.initialState; export const api = createApi(); api.use(mdw.api({ schema })); @@ -49,3 +49,6 @@ export const fetchUsers = api.get[]>( yield* schema.update(schema.users.add(users)); } ); + +export const { useSelector } = createTypedHooks(schema); + diff --git a/examples/vite-react/src/main.tsx b/examples/vite-react/src/main.tsx index f03d277d..f92b6b8c 100644 --- a/examples/vite-react/src/main.tsx +++ b/examples/vite-react/src/main.tsx @@ -9,20 +9,10 @@ import "./index.css"; init(); function init() { - const store = createStore({ schemas: [schema] }); + const store = createStore({ schema, tasks: [logger, api.register] }); // makes `fx` available in devtools (window as any).fx = store; - store.run([ - function* logger() { - while (true) { - const action = yield* take("*"); - console.log("action", action); - } - }, - api.register, - ]); - ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( @@ -31,3 +21,10 @@ function init() { ); } + +function* logger() { + while (true) { + const action = yield* take("*"); + console.log("action", action); + } +} diff --git a/examples/vite-react/tsconfig.json b/examples/vite-react/tsconfig.json index c81ef9f3..4608b94f 100644 --- a/examples/vite-react/tsconfig.json +++ b/examples/vite-react/tsconfig.json @@ -1,5 +1,8 @@ { "compilerOptions": { + "baseUrl": ".", + "forceConsistentCasingInFileNames": true, + "ignoreDeprecations": "5.0", "target": "ESNext", "lib": ["DOM", "DOM.Iterable", "ESNext"], "module": "ESNext", @@ -12,6 +15,10 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", + "paths": { + "starfx": ["../../src/index.ts"], + "starfx/react": ["../../src/react.ts"] + }, /* Linting */ "strict": true, diff --git a/examples/yjs/src/App.tsx b/examples/yjs/src/App.tsx index 9bb484c3..75c808e5 100644 --- a/examples/yjs/src/App.tsx +++ b/examples/yjs/src/App.tsx @@ -1,12 +1,8 @@ import { - TypedUseSelectorHook, useDispatch, - useSelector as useSel, } from "starfx/react"; import "./App.css"; -import { AppState, createFolder, schema } from "./thunks.js"; - -const useSelector: TypedUseSelectorHook = useSel; +import { createFolder, useSelector } from "./thunks.js"; function App({ id }: { id: string }) { const dispatch = useDispatch(); diff --git a/examples/yjs/src/main.tsx b/examples/yjs/src/main.tsx index dadff68f..659bc174 100644 --- a/examples/yjs/src/main.tsx +++ b/examples/yjs/src/main.tsx @@ -10,21 +10,12 @@ init(); function init() { const store = createStore({ - schemas: [schema], + schema, + tasks: [logger, thunks.register], }); // makes `fx` available in devtools (window as any).fx = store; - store.initialize([ - function* logger() { - while (true) { - const action = yield* take("*"); - console.log("action", action); - } - }, - thunks.register, - ]); - ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( @@ -33,3 +24,10 @@ function init() { ); } + +function* logger() { + while (true) { + const action = yield* take("*"); + console.log("action", action); + } +} diff --git a/examples/yjs/src/store/schema.ts b/examples/yjs/src/store/schema.ts index 61db5462..233ac892 100644 --- a/examples/yjs/src/store/schema.ts +++ b/examples/yjs/src/store/schema.ts @@ -1,10 +1,9 @@ import { type FxMap, type FxSchema, - type Next, - type UpdaterCtx, - type FxStore, createSchemaWithUpdater, + expectStore, + type SliceFromSchema, } from "starfx"; import * as Y from "yjs"; @@ -14,8 +13,7 @@ import * as Y from "yjs"; */ export function createYjsSchema< O extends FxMap, - S extends { [key in keyof O]: ReturnType["initialState"] }, ->(slices: O): FxSchema { +>(slices: O): FxSchema { console.log("Creating Y.Doc"); const ydoc = new Y.Doc({ autoLoad: true }); const root = ydoc.getMap(); @@ -25,34 +23,29 @@ export function createYjsSchema< data.set("items", new Y.Array()); return createSchemaWithUpdater(slices, { - createUpdateMdw: (store: FxStore) => { - // Set up observer to sync Y.Doc changes to store - root.observeDeep( - (events: Y.YEvent[], transaction: Y.Transaction) => { - console.log("Y.Doc changed", { events, transaction }); - store.setState(root.toJSON() as S); - }, - ); + initialize: function* () { + const store = yield* expectStore(); - // Initialize store with current Y.Doc state - store.setState(root.toJSON() as S); + root.observeDeep((_events: Y.YEvent[], _transaction: Y.Transaction) => { + store.setState(root.toJSON() as SliceFromSchema); + }); - return function* updateMdw(ctx: UpdaterCtx, next: Next) { - ydoc.transact(() => { - const updaters = Array.isArray(ctx.updater) - ? ctx.updater - : [ctx.updater]; - for (const updater of updaters) { - updater(root as any); - } - }); - console.log({ updater: ctx.updater }); - store.setState(root.toJSON() as S); - yield* next(); - }; + store.setState(root.toJSON() as SliceFromSchema); + }, + *updateMdw(ctx, next) { + const store = yield* expectStore(); + + ydoc.transact(() => { + const updaters = Array.isArray(ctx.updater) + ? ctx.updater + : [ctx.updater]; + for (const updater of updaters) { + (updater as (root: Y.Map) => void)(root); + } + }); + + store.setState(root.toJSON() as SliceFromSchema); + yield* next(); }, }); } - -// For backwards compatibility, also export as createSchema -export { createYjsSchema as createSchema }; diff --git a/examples/yjs/src/thunks.ts b/examples/yjs/src/thunks.ts index 8e0c2016..fe3c7e6c 100644 --- a/examples/yjs/src/thunks.ts +++ b/examples/yjs/src/thunks.ts @@ -1,5 +1,6 @@ import { createThunks, mdw } from "starfx"; import { createSchema } from "./store/schema.js"; +import { createTypedHooks } from "starfx/react"; export const schema = createSchema({}); export type AppState = typeof schema.initialState; @@ -9,14 +10,22 @@ export const thunks = createThunks(); thunks.use(mdw.err); // where all the thunks get called in the middleware stack thunks.use(thunks.routes()); -thunks.use(function* (ctx, next) { +thunks.use(function* (_ctx, next) { console.log("last mdw in the stack"); yield* next(); }); +type YjsRoot = { + get(key: "data"): { + get(key: "items"): { + push(items: Array<{ id: string; name: string; children: unknown[] }>): void; + }; + }; +}; + export const createFolder = thunks.create("/users", function* (ctx, next) { console.log("Creating folder", ctx); - yield* schema.update((root) => { + yield* schema.update(((root: YjsRoot) => { const yarray = root.get("data").get("items"); yarray.push([ { @@ -25,7 +34,10 @@ export const createFolder = thunks.create("/users", function* (ctx, next) { children: [], }, ]); - }); + }) as never); yield* next(); }); + +export const { useSelector } = createTypedHooks(schema); + diff --git a/examples/yjs/tsconfig.json b/examples/yjs/tsconfig.json index c81ef9f3..4608b94f 100644 --- a/examples/yjs/tsconfig.json +++ b/examples/yjs/tsconfig.json @@ -1,5 +1,8 @@ { "compilerOptions": { + "baseUrl": ".", + "forceConsistentCasingInFileNames": true, + "ignoreDeprecations": "5.0", "target": "ESNext", "lib": ["DOM", "DOM.Iterable", "ESNext"], "module": "ESNext", @@ -12,6 +15,10 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", + "paths": { + "starfx": ["../../src/index.ts"], + "starfx/react": ["../../src/react.ts"] + }, /* Linting */ "strict": true, diff --git a/package.json b/package.json index 549e1edd..316bd1d4 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "test": "vitest --exclude examples", "typecheck": "tsc --noEmit", "typecheck:lib": "tsc --noEmit --project tsconfig.lib.json", + "typecheck:examples": "tsc --noEmit -p examples/basic/tsconfig.json && tsc --noEmit -p examples/vite-react/tsconfig.json && tsc --noEmit -p examples/yjs/tsconfig.json && tsc --noEmit -p examples/tests-rtl/tsconfig.json", "lint": "biome check --write", "fmt": "biome check --write --linter-enabled=false", "ci": "biome ci .", diff --git a/src/mdw/store.ts b/src/mdw/store.ts index d34cd7c0..58f077d1 100644 --- a/src/mdw/store.ts +++ b/src/mdw/store.ts @@ -6,7 +6,7 @@ import { select, updateStore, } from "../store/index.js"; -import type { LoaderState, Next } from "../types.js"; +import type { Next } from "../types.js"; import { nameParser } from "./fetch.js"; import { actions, customKey, err, queryCtx } from "./query.js"; diff --git a/src/store/slice/num.ts b/src/store/slice/num.ts index 9accadc3..6b3afe1c 100644 --- a/src/store/slice/num.ts +++ b/src/store/slice/num.ts @@ -1,4 +1,4 @@ -import type { Draft, Immutable } from "immer"; +import type { Draft } from "immer"; import type { BaseSchema, SliceState } from "../types.js"; type NumRootState = SliceState; diff --git a/src/store/slice/str.ts b/src/store/slice/str.ts index 757f0186..dbb867e4 100644 --- a/src/store/slice/str.ts +++ b/src/store/slice/str.ts @@ -1,4 +1,4 @@ -import type { Draft, Immutable } from "immer"; +import type { Draft } from "immer"; import type { BaseSchema, SliceState } from "../types.js"; type StrRootState = SliceState; diff --git a/src/store/slice/table.ts b/src/store/slice/table.ts index 8ac5f3b2..f954dda1 100644 --- a/src/store/slice/table.ts +++ b/src/store/slice/table.ts @@ -86,7 +86,7 @@ function tableSelectors( }; } -export interface TableActions { +export interface TableActions { add: (e: SliceState) => (s: TableDraftState) => void; set: (e: SliceState) => (s: TableDraftState) => void; remove: (ids: IdProp[]) => (s: TableDraftState) => void; diff --git a/src/store/types.ts b/src/store/types.ts index 8103784b..28986682 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -1,6 +1,6 @@ -import type { Context, Operation, Scope, Task } from "effection"; +import type { Context, Operation, Scope } from "effection"; import type { Draft, Immutable, Patch } from "immer"; -import type { BaseCtx, BaseMiddleware } from "../index.js"; +import type { BaseCtx } from "../index.js"; import type { AnyAction, AnyState } from "../types.js"; import type { createRun } from "./run.js"; import type { LoaderOutput } from "./slice/loaders.js"; From 962022b9efb58e847de677a010dfcdc126994b5f Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Tue, 14 Apr 2026 10:59:34 -0500 Subject: [PATCH 23/35] switch to vitest in rtl test example --- examples/tests-rtl/babel.config.js | 7 ------ examples/tests-rtl/jest.config.js | 11 --------- examples/tests-rtl/package.json | 14 +++++------ examples/tests-rtl/src/api.ts | 13 +++++----- examples/tests-rtl/tests/app.test.tsx | 3 +-- examples/tests-rtl/tests/setup.ts | 8 ++++++- examples/tests-rtl/tsconfig.json | 3 +-- examples/tests-rtl/vitest.config.ts | 34 +++++++++++++++++++++++++++ examples/vite-react/src/age-guess.ts | 3 +++ examples/yjs/src/App.tsx | 1 + examples/yjs/src/store/schema.ts | 2 ++ examples/yjs/src/yjs.d.ts | 23 ++++++++++++++++++ 12 files changed, 85 insertions(+), 37 deletions(-) delete mode 100644 examples/tests-rtl/babel.config.js delete mode 100644 examples/tests-rtl/jest.config.js create mode 100644 examples/tests-rtl/vitest.config.ts create mode 100644 examples/yjs/src/yjs.d.ts diff --git a/examples/tests-rtl/babel.config.js b/examples/tests-rtl/babel.config.js deleted file mode 100644 index 85db31a9..00000000 --- a/examples/tests-rtl/babel.config.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = { - presets: [ - "@babel/preset-env", - ["@babel/preset-react", { runtime: "automatic" }], - "@babel/preset-typescript", - ], -}; diff --git a/examples/tests-rtl/jest.config.js b/examples/tests-rtl/jest.config.js deleted file mode 100644 index 4fbd949f..00000000 --- a/examples/tests-rtl/jest.config.js +++ /dev/null @@ -1,11 +0,0 @@ -export default { - testEnvironment: "jsdom", - transform: { - "^.+\\.(t|j)sx?$": "babel-jest", - }, - setupFilesAfterEnv: ["/tests/setup.ts"], - moduleNameMapper: { - "^react$": "/node_modules/react", - "^react-dom$": "/node_modules/react-dom", - }, -}; diff --git a/examples/tests-rtl/package.json b/examples/tests-rtl/package.json index 35c5141c..dd85bf2a 100644 --- a/examples/tests-rtl/package.json +++ b/examples/tests-rtl/package.json @@ -1,23 +1,21 @@ { "name": "tests-rtl", "scripts": { - "test": "jest" + "test": "vitest run" }, "dependencies": { "starfx": "file:../.." }, "devDependencies": { - "@babel/core": "^7.28.0", - "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", - "@babel/preset-typescript": "^7.27.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "babel-jest": "^30.0.4", - "jest": "^30.0.4", - "jest-environment-jsdom": "^30.0.4", + "jsdom": "^26.1.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-redux": "^9.1.2", + "vitest": "^4.0.7", "whatwg-fetch": "^3.6.20" } } diff --git a/examples/tests-rtl/src/api.ts b/examples/tests-rtl/src/api.ts index 6ee90fb5..76377afc 100644 --- a/examples/tests-rtl/src/api.ts +++ b/examples/tests-rtl/src/api.ts @@ -21,14 +21,15 @@ export const fetchUsers = api.get("/users", function* (ctx, next) { return; } - const users = (ctx.json.value as User[]).reduce>((acc, user) => { - acc[user.id] = user; - return acc; - }, {}); + const users = (ctx.json.value as User[]).reduce>( + (acc, user) => { + acc[user.id] = user; + return acc; + }, + {}, + ); yield* schema.update(schema.users.add(users)); }); - - const { useSelector } = createTypedHooks(schema); export { useSelector }; diff --git a/examples/tests-rtl/tests/app.test.tsx b/examples/tests-rtl/tests/app.test.tsx index 2dba4b4c..7fffd977 100644 --- a/examples/tests-rtl/tests/app.test.tsx +++ b/examples/tests-rtl/tests/app.test.tsx @@ -1,5 +1,4 @@ -import "@testing-library/jest-dom"; -import { expect, test } from "@jest/globals"; +import { expect, test } from "vitest"; import { fireEvent, render, screen, waitFor } from "./utils"; import { fetchUsers } from "../src/api"; import { App } from "../src/app"; diff --git a/examples/tests-rtl/tests/setup.ts b/examples/tests-rtl/tests/setup.ts index fe88db1e..15de1274 100644 --- a/examples/tests-rtl/tests/setup.ts +++ b/examples/tests-rtl/tests/setup.ts @@ -1,5 +1,11 @@ -import "@testing-library/jest-dom"; +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; // jsdom doesn't have the fetch API which we need for Response() // so polyfilling it here for every file // see https://github.com/jsdom/jsdom/issues/1724 import "whatwg-fetch"; + +afterEach(() => { + cleanup(); +}); diff --git a/examples/tests-rtl/tsconfig.json b/examples/tests-rtl/tsconfig.json index abf953c2..4ec4bea1 100644 --- a/examples/tests-rtl/tsconfig.json +++ b/examples/tests-rtl/tsconfig.json @@ -10,7 +10,6 @@ "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, - "forceConsistentCasingInFileNames": true, "target": "esnext", "module": "esnext", "moduleResolution": "bundler", @@ -24,5 +23,5 @@ "starfx/react": ["../../src/react.ts"] } }, - "include": ["./src", "./tests"] + "include": ["./src"] } diff --git a/examples/tests-rtl/vitest.config.ts b/examples/tests-rtl/vitest.config.ts new file mode 100644 index 00000000..122643d9 --- /dev/null +++ b/examples/tests-rtl/vitest.config.ts @@ -0,0 +1,34 @@ +import { resolve } from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: [ + { + find: "starfx/react", + replacement: resolve(__dirname, "./node_modules/starfx/dist/esm/react.js"), + }, + { + find: "starfx", + replacement: resolve(__dirname, "./vitest.starfx.ts"), + }, + { + find: "react", + replacement: resolve(__dirname, "./node_modules/react"), + }, + { + find: "react-dom", + replacement: resolve(__dirname, "./node_modules/react-dom"), + }, + { + find: "react-redux", + replacement: resolve(__dirname, "./node_modules/react-redux"), + }, + ], + dedupe: ["react", "react-dom", "react-redux"], + }, + test: { + environment: "jsdom", + setupFiles: ["./tests/setup.ts"], + }, +}); \ No newline at end of file diff --git a/examples/vite-react/src/age-guess.ts b/examples/vite-react/src/age-guess.ts index 5124c24e..c745d3dd 100644 --- a/examples/vite-react/src/age-guess.ts +++ b/examples/vite-react/src/age-guess.ts @@ -1,4 +1,5 @@ import { type Operation, resource } from "effection"; +import { manage } from "starfx"; export function guessAge(): Operation<{ guess: number; accumulated: number }> { console.log("started"); @@ -25,3 +26,5 @@ export function guessAge(): Operation<{ guess: number; accumulated: number }> { } }); } + +export const GlobalGuesser = manage("global-guesser", guessAge()); diff --git a/examples/yjs/src/App.tsx b/examples/yjs/src/App.tsx index 75c808e5..c51a88fc 100644 --- a/examples/yjs/src/App.tsx +++ b/examples/yjs/src/App.tsx @@ -5,6 +5,7 @@ import "./App.css"; import { createFolder, useSelector } from "./thunks.js"; function App({ id }: { id: string }) { + void id; const dispatch = useDispatch(); const state = useSelector((s) => s); console.log("state", state); diff --git a/examples/yjs/src/store/schema.ts b/examples/yjs/src/store/schema.ts index 233ac892..c571497e 100644 --- a/examples/yjs/src/store/schema.ts +++ b/examples/yjs/src/store/schema.ts @@ -49,3 +49,5 @@ export function createYjsSchema< }, }); } + +export { createYjsSchema as createSchema }; diff --git a/examples/yjs/src/yjs.d.ts b/examples/yjs/src/yjs.d.ts new file mode 100644 index 00000000..528625df --- /dev/null +++ b/examples/yjs/src/yjs.d.ts @@ -0,0 +1,23 @@ +declare module "yjs" { + export type Transaction = unknown; + export type YEvent = { target: T }; + + export class Doc { + constructor(options?: { autoLoad?: boolean }); + getMap(name?: string): Map; + transact(fn: () => void): void; + } + + export class Map { + set(key: string, value: unknown): void; + get(key: string): V; + toJSON(): T; + observeDeep( + observer: (events: YEvent[], transaction: Transaction) => void, + ): void; + } + + export class Array { + push(items: T[]): void; + } +} \ No newline at end of file From e1199617569081d92efcec5de86a80452c1f18c0 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Tue, 14 Apr 2026 11:52:14 -0500 Subject: [PATCH 24/35] remove resource from direct store integration --- examples/vite-react/src/main.tsx | 3 ++- src/store/index.ts | 1 + src/store/resource.ts | 31 +++++++++++++++++++++++++++++++ src/store/store.ts | 31 ------------------------------- src/store/types.ts | 4 ---- src/test/store.test.ts | 12 ++++++------ 6 files changed, 40 insertions(+), 42 deletions(-) create mode 100644 src/store/resource.ts diff --git a/examples/vite-react/src/main.tsx b/examples/vite-react/src/main.tsx index f92b6b8c..248cefc6 100644 --- a/examples/vite-react/src/main.tsx +++ b/examples/vite-react/src/main.tsx @@ -5,11 +5,12 @@ import { Provider } from "starfx/react"; import { api, schema } from "./api.ts"; import App from "./App.tsx"; import "./index.css"; +import { GlobalGuesser } from "./age-guess.ts"; init(); function init() { - const store = createStore({ schema, tasks: [logger, api.register] }); + const store = createStore({ schema, tasks: [logger, api.register, GlobalGuesser.initialize] }); // makes `fx` available in devtools (window as any).fx = store; diff --git a/src/store/index.ts b/src/store/index.ts index 527075d9..3d777a79 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,6 +1,7 @@ export * from "./context.js"; export * from "./fx.js"; export * from "./store.js"; +export * from "./resource.js"; export * from "./types.js"; export { createSelector } from "reselect"; export * from "./slice/index.js"; diff --git a/src/store/resource.ts b/src/store/resource.ts new file mode 100644 index 00000000..7976b8b1 --- /dev/null +++ b/src/store/resource.ts @@ -0,0 +1,31 @@ +import { type Operation, createContext, suspend } from "effection"; +import { supervise } from "../fx/supervisor.js"; +import { StoreContext } from "./context.js"; + +/** + * Creates a managed resource within the store's Effection scope. + * + * @remarks + * The `registerResource` function creates a named context that can be used + * by operations running inside the store scope. The returned context exposes + * an `.initialize` method, which is intended for `createStore({ tasks: [...] })`. + * + * @param name - A unique name for the resource, used to create a context. + * @param inputResource - An Effection operation that initializes the resource. + * @returns A context object with an `initialize` method for setting up the resource. + */ +export function registerResource( + name: string, + inputResource: Operation, +) { + const CustomContext = createContext(name); + const initialize = supervise(function* () { + const store = yield* StoreContext.expect(); + const parentScope = store.getScope(); + const providedResource = yield* inputResource; + parentScope.set(CustomContext, providedResource); + yield* suspend(); + }); + + return { ...CustomContext, initialize }; +} diff --git a/src/store/store.ts b/src/store/store.ts index 4f8be400..3f5fde39 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -36,36 +36,6 @@ function observable() { }; } -/** - * Creates a managed resource within the store's Effection scope. - * - * @remarks - * The `manage` function allows you to define a resource that can be passed to - * the `createStore({ tasks: [() => manage(...)] })` configuration. This resource will be automatically - * initialized and cleaned up with the store's lifecycle. The provided resource is made available through a generated context that can be accessed by any operation within the store's scope. - * - * @param name - A unique name for the resource, used to create a context. - * @param inputResource - An Effection operation that initializes the resource. - * @returns A context object with an `initialize` method for setting up the resource. - */ -export function manage( - name: string, - inputResource: Operation, -) { - const CustomContext = createContext(name); - function initialize(parentScope: Scope) { - return supervise(function* () { - const providedResource = yield* inputResource; - parentScope.set(CustomContext, providedResource); - yield* suspend(); - }); - } - - // returns to the user so they can use this resource from - // anywhere this context is available - return { ...CustomContext, initialize }; -} - export interface CreateStore { scope?: Scope; schema?: FxSchema; @@ -209,7 +179,6 @@ export function createStore({ getState, setState, subscribe, - manage, schema: baseSchema, schemas: schemasMap, run, diff --git a/src/store/types.ts b/src/store/types.ts index 28986682..197576eb 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -143,10 +143,6 @@ export interface FxStore { schema: FxSchema; // all schemas by name schemas: Record>; - manage: ( - name: string, - resource: Operation, - ) => Context; run: ReturnType; // part of redux store API dispatch: (a: AnyAction | AnyAction[]) => unknown; diff --git a/src/test/store.test.ts b/src/test/store.test.ts index 27586242..cdd64ea2 100644 --- a/src/test/store.test.ts +++ b/src/test/store.test.ts @@ -14,7 +14,7 @@ import { StoreUpdateContext, createSchema, createStore, - manage, + registerResource, slice, updateStore, } from "../store/index.js"; @@ -190,7 +190,7 @@ test("resets store", async () => { }); }); -describe(".manage", () => { +describe(".registerResource", () => { function guessAge(): Operation<{ guess: number; cumulative: null | number }> { return resource(function* (provide) { let cumulative = 0 as null | number; @@ -217,11 +217,11 @@ describe(".manage", () => { const thunk = createThunks(); thunk.use(thunk.routes()); const [scope] = createScope(); - const TestContext = manage("test:context", guessAge()); + const TestContext = registerResource("test:context", guessAge()); const store = createStore({ scope, schemas: [createSchema()], - tasks: [TestContext.initialize(scope), thunk.register], + tasks: [TestContext.initialize, thunk.register], }); let acc = "bla"; const action = thunk.create("/users", function* (payload, next) { @@ -243,11 +243,11 @@ describe(".manage", () => { const thunk = createThunks(); thunk.use(thunk.routes()); const [scope] = createScope(); - const TestContext = manage("test:context", guessAge()); + const TestContext = registerResource("test:context", guessAge()); const store = createStore({ scope, schemas: [createSchema()], - tasks: [TestContext.initialize(scope), thunk.register], + tasks: [TestContext.initialize, thunk.register], }); let guess = 0; let acc = 0; From cbbff1ce7c5b185d83afafa51e95eed64ea6abe6 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Tue, 14 Apr 2026 14:14:41 -0500 Subject: [PATCH 25/35] lint --- examples/vite-react/src/age-guess.ts | 4 ++-- src/store/store.ts | 2 -- src/store/types.ts | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/examples/vite-react/src/age-guess.ts b/examples/vite-react/src/age-guess.ts index c745d3dd..78b5f8a6 100644 --- a/examples/vite-react/src/age-guess.ts +++ b/examples/vite-react/src/age-guess.ts @@ -1,5 +1,5 @@ import { type Operation, resource } from "effection"; -import { manage } from "starfx"; +import { registerResource } from "starfx"; export function guessAge(): Operation<{ guess: number; accumulated: number }> { console.log("started"); @@ -27,4 +27,4 @@ export function guessAge(): Operation<{ guess: number; accumulated: number }> { }); } -export const GlobalGuesser = manage("global-guesser", guessAge()); +export const GlobalGuesser = registerResource("global-guesser", guessAge()); diff --git a/src/store/store.ts b/src/store/store.ts index 3f5fde39..94d65c5d 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -4,12 +4,10 @@ import { createContext, createScope, createSignal, - suspend, } from "effection"; import { produce } from "immer"; import { ActionContext, emit } from "../action.js"; import { parallel } from "../fx/parallel.js"; -import { supervise } from "../fx/supervisor.js"; import type { AnyAction } from "../types.js"; import { StoreContext } from "./context.js"; import { createRun } from "./run.js"; diff --git a/src/store/types.ts b/src/store/types.ts index 197576eb..c4fc3942 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -1,4 +1,4 @@ -import type { Context, Operation, Scope } from "effection"; +import type { Operation, Scope } from "effection"; import type { Draft, Immutable, Patch } from "immer"; import type { BaseCtx } from "../index.js"; import type { AnyAction, AnyState } from "../types.js"; From 190e53629e81d7c0a6b8ce8817fcbc1ae7f20568 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Tue, 14 Apr 2026 14:38:32 -0500 Subject: [PATCH 26/35] remove vitest example alias --- examples/tests-rtl/vitest.config.ts | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/examples/tests-rtl/vitest.config.ts b/examples/tests-rtl/vitest.config.ts index 122643d9..777854c3 100644 --- a/examples/tests-rtl/vitest.config.ts +++ b/examples/tests-rtl/vitest.config.ts @@ -1,32 +1,6 @@ -import { resolve } from "node:path"; import { defineConfig } from "vitest/config"; export default defineConfig({ - resolve: { - alias: [ - { - find: "starfx/react", - replacement: resolve(__dirname, "./node_modules/starfx/dist/esm/react.js"), - }, - { - find: "starfx", - replacement: resolve(__dirname, "./vitest.starfx.ts"), - }, - { - find: "react", - replacement: resolve(__dirname, "./node_modules/react"), - }, - { - find: "react-dom", - replacement: resolve(__dirname, "./node_modules/react-dom"), - }, - { - find: "react-redux", - replacement: resolve(__dirname, "./node_modules/react-redux"), - }, - ], - dedupe: ["react", "react-dom", "react-redux"], - }, test: { environment: "jsdom", setupFiles: ["./tests/setup.ts"], From 5d6918aa8b477628853283f6d511baa21c99702f Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Tue, 14 Apr 2026 15:19:24 -0500 Subject: [PATCH 27/35] dedupe --- .github/workflows/examples.yml | 2 ++ examples/tests-rtl/vitest.config.ts | 3 +++ package.json | 1 + src/store/schema.ts | 3 ++- src/store/types.ts | 2 +- 5 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index 4aca903b..2b738b24 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -17,9 +17,11 @@ jobs: fail-fast: false matrix: example: + - ./examples/basic - ./examples/vite-react - ./examples/parcel-react - ./examples/tests-rtl + - ./examples/yjs steps: - name: checkout diff --git a/examples/tests-rtl/vitest.config.ts b/examples/tests-rtl/vitest.config.ts index 777854c3..718f2b6a 100644 --- a/examples/tests-rtl/vitest.config.ts +++ b/examples/tests-rtl/vitest.config.ts @@ -1,6 +1,9 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + dedupe: ["react", "react-dom", "react-redux"], + }, test: { environment: "jsdom", setupFiles: ["./tests/setup.ts"], diff --git a/package.json b/package.json index 316bd1d4..e235bc07 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "module": "./dist/esm/index.js", "scripts": { "test": "vitest --exclude examples", + "test:examples": "npm --prefix examples/tests-rtl test", "typecheck": "tsc --noEmit", "typecheck:lib": "tsc --noEmit --project tsconfig.lib.json", "typecheck:examples": "tsc --noEmit -p examples/basic/tsconfig.json && tsc --noEmit -p examples/vite-react/tsconfig.json && tsc --noEmit -p examples/yjs/tsconfig.json && tsc --noEmit -p examples/tests-rtl/tsconfig.json", diff --git a/src/store/schema.ts b/src/store/schema.ts index 39f40ff6..14f62e5f 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -2,9 +2,10 @@ import { type Operation, lift } from "effection"; import { type Draft, enablePatches, produceWithPatches } from "immer"; import { API_ACTION_PREFIX, ActionContext, emit } from "../action.js"; import { type BaseMiddleware, compose } from "../compose.js"; -import { type AnyState, ListenersContext, type Next } from "../index.js"; +import type { AnyState, Next } from "../types.js"; import { StoreUpdateContext, expectStore } from "./context.js"; import { slice } from "./slice/index.js"; +import { ListenersContext } from "./store.js"; import type { FactoryInitial, FactoryReturn, diff --git a/src/store/types.ts b/src/store/types.ts index c4fc3942..bd7140ef 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -1,6 +1,6 @@ import type { Operation, Scope } from "effection"; import type { Draft, Immutable, Patch } from "immer"; -import type { BaseCtx } from "../index.js"; +import type { BaseCtx } from "../compose.js"; import type { AnyAction, AnyState } from "../types.js"; import type { createRun } from "./run.js"; import type { LoaderOutput } from "./slice/loaders.js"; From 82a5cd48c345c983c6214d99a71f0bdb48359a4c Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Thu, 16 Apr 2026 14:11:36 -0500 Subject: [PATCH 28/35] store runs produce --- .gitignore | 1 + src/store/schema.ts | 11 ++--------- src/store/store.ts | 20 ++++++++++++-------- src/store/types.ts | 6 ++++-- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index 71b945c7..0a5e1b54 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ docs/public/* !docs/public/.gitkeep dist/ src/package.json +.playwright* diff --git a/src/store/schema.ts b/src/store/schema.ts index 14f62e5f..439a4191 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -1,5 +1,5 @@ import { type Operation, lift } from "effection"; -import { type Draft, enablePatches, produceWithPatches } from "immer"; +import { type Draft, enablePatches } from "immer"; import { API_ACTION_PREFIX, ActionContext, emit } from "../action.js"; import { type BaseMiddleware, compose } from "../compose.js"; import type { AnyState, Next } from "../types.js"; @@ -249,16 +249,9 @@ export function createSchema( Array.isArray(ctx.updater) ? ctx.updater : [ctx.updater] ) as StoreUpdater>[]; - const [nextState, patches, _] = produceWithPatches>( - store.getState(), - (draft: Draft>) => { - upds.forEach((updater) => updater(draft)); - }, - ); + const [_nextState, patches, _inversePatches] = store.setState(upds); ctx.patches = patches; - store.setState(nextState); - yield* next(); }, }); diff --git a/src/store/store.ts b/src/store/store.ts index 94d65c5d..33fbaa4f 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -5,7 +5,7 @@ import { createScope, createSignal, } from "effection"; -import { produce } from "immer"; +import { type Draft, produceWithPatches } from "immer"; import { ActionContext, emit } from "../action.js"; import { parallel } from "../fx/parallel.js"; import type { AnyAction } from "../types.js"; @@ -17,6 +17,7 @@ import type { FxStore, Listener, SliceFromSchema, + StoreUpdater, } from "./types.js"; const stubMsg = "This is merely a stub, not implemented"; @@ -148,13 +149,16 @@ export function createStore({ return state; } - function setState(newState: SliceFromSchema) { - // enables merging multiple states from - // different schemas without overwriting the whole state - // TODO but this means double produce on the default single schema case - state = produce(state, (draft) => { - Object.assign(draft, newState); - }); + function setState(upds: StoreUpdater>[]) { + const nextState = produceWithPatches( + state, + (draft: Draft>) => { + upds.forEach((updater) => updater(draft)); + }, + ); + + state = nextState[0]; + return nextState; } function getInitialState() { diff --git a/src/store/types.ts b/src/store/types.ts index bd7140ef..55284d66 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -1,5 +1,5 @@ import type { Operation, Scope } from "effection"; -import type { Draft, Immutable, Patch } from "immer"; +import type { Draft, Immutable, Patch, produceWithPatches } from "immer"; import type { BaseCtx } from "../compose.js"; import type { AnyAction, AnyState } from "../types.js"; import type { createRun } from "./run.js"; @@ -136,7 +136,9 @@ export interface FxStore { getScope: () => Scope; // part of redux store API getState: () => SliceFromSchema; - setState: (s: SliceFromSchema) => void; + setState: ( + upds: StoreUpdater>[], + ) => ReturnType; // part of redux store API subscribe: (fn: Listener) => () => void; // the default schema for this store From a54b1bd8367a703ec25c589e0bb676f84af838c7 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Fri, 17 Apr 2026 15:30:02 -0500 Subject: [PATCH 29/35] fix yjs example --- examples/yjs/src/App.tsx | 16 +++--- examples/yjs/src/store/schema.ts | 93 ++++++++++++++++++++++++++++---- examples/yjs/src/thunks.ts | 9 +++- examples/yjs/src/yjs.d.ts | 23 -------- src/store/schema.ts | 4 +- src/store/store.ts | 3 +- 6 files changed, 101 insertions(+), 47 deletions(-) delete mode 100644 examples/yjs/src/yjs.d.ts diff --git a/examples/yjs/src/App.tsx b/examples/yjs/src/App.tsx index c51a88fc..854352f2 100644 --- a/examples/yjs/src/App.tsx +++ b/examples/yjs/src/App.tsx @@ -1,20 +1,22 @@ -import { - useDispatch, -} from "starfx/react"; +import { useDispatch } from "starfx/react"; import "./App.css"; import { createFolder, useSelector } from "./thunks.js"; function App({ id }: { id: string }) { void id; const dispatch = useDispatch(); - const state = useSelector((s) => s); - console.log("state", state); + const items = useSelector( + (state: { data?: { items?: Array<{ id: string }> } }) => + state.data?.items ?? [], + ); + console.log("items", items); // const user = useSelector((s) => schema.users.selectById(s, { id })); // const userList = useSelector(schema.users.selectTableAsList); return (

-
hi there, user.name
- +
hi there, make a folder perhaps?
+ +
folders: {items.length}
{/* {userList.map((u) => { return (
diff --git a/examples/yjs/src/store/schema.ts b/examples/yjs/src/store/schema.ts index c571497e..dbf145df 100644 --- a/examples/yjs/src/store/schema.ts +++ b/examples/yjs/src/store/schema.ts @@ -1,19 +1,55 @@ import { type FxMap, type FxSchema, + type StoreUpdater, createSchemaWithUpdater, expectStore, type SliceFromSchema, + createSignal, + each, } from "starfx"; +import type { Draft } from "immer"; import * as Y from "yjs"; +const SNAPSHOT = Symbol("yjs:snapshot"); + +type SnapshotUpdater> = StoreUpdater & { + [SNAPSHOT]: true; +}; + +function createSnapshotUpdater( + snapshot: SliceFromSchema, +): SnapshotUpdater> { + return Object.assign( + (draft: Draft>) => { + const nextState = snapshot as Record; + const draftState = draft as Record; + + for (const key of Object.keys(draftState)) { + if (!(key in nextState)) { + delete draftState[key]; + } + } + + Object.assign(draftState, nextState); + }, + { [SNAPSHOT]: true as const }, + ); +} + +function isSnapshotUpdater( + updater: unknown, +): updater is SnapshotUpdater> { + return ( + typeof updater === "function" && Reflect.get(updater, SNAPSHOT) === true + ); +} + /** * Creates a Yjs-backed schema where state updates are synchronized via Y.Doc. * This demonstrates using createSchemaWithUpdater for custom state management. */ -export function createYjsSchema< - O extends FxMap, ->(slices: O): FxSchema { +export function createYjsSchema(slices: O): FxSchema { console.log("Creating Y.Doc"); const ydoc = new Y.Doc({ autoLoad: true }); const root = ydoc.getMap(); @@ -23,28 +59,63 @@ export function createYjsSchema< data.set("items", new Y.Array()); return createSchemaWithUpdater(slices, { + name: "yjs", initialize: function* () { const store = yield* expectStore(); + const schema = store.schemas["yjs"]; + let observation = createSignal<{ + events: Y.YEvent[]; + transaction: any// Y.Transaction is just unknown? + }>(); - root.observeDeep((_events: Y.YEvent[], _transaction: Y.Transaction) => { - store.setState(root.toJSON() as SliceFromSchema); - }); + root.observeDeep( + (events: Y.YEvent[], transaction: Y.Transaction) => { + if (typeof transaction === "object" && transaction !== null && "local" in transaction && !transaction.local) { + console.log("Y.Doc changed, sending update", events, transaction); + observation.send({ events, transaction }); + } + }, + ); + + yield* schema.update( + createSnapshotUpdater(root.toJSON() as SliceFromSchema), + ); - store.setState(root.toJSON() as SliceFromSchema); + for (const { events, transaction } of yield* each(observation)) { + console.log( + "Y.Doc changed, updating schema state", + events, + transaction, + ); + yield* schema.update( + createSnapshotUpdater(root.toJSON() as SliceFromSchema), + ); + } }, *updateMdw(ctx, next) { const store = yield* expectStore(); + const updaters = Array.isArray(ctx.updater) ? ctx.updater : [ctx.updater]; + + const snapshotUpdaters = updaters.filter((updater) => + isSnapshotUpdater(updater), + ); + + if (snapshotUpdaters.length > 0) { + store.setState(snapshotUpdaters); + yield* next(); + return; + } ydoc.transact(() => { - const updaters = Array.isArray(ctx.updater) - ? ctx.updater - : [ctx.updater]; for (const updater of updaters) { (updater as (root: Y.Map) => void)(root); } }); - store.setState(root.toJSON() as SliceFromSchema); + store.setState([ + createSnapshotUpdater(root.toJSON() as SliceFromSchema), + ]); + yield* next(); }, }); diff --git a/examples/yjs/src/thunks.ts b/examples/yjs/src/thunks.ts index fe3c7e6c..6c1aacad 100644 --- a/examples/yjs/src/thunks.ts +++ b/examples/yjs/src/thunks.ts @@ -2,6 +2,10 @@ import { createThunks, mdw } from "starfx"; import { createSchema } from "./store/schema.js"; import { createTypedHooks } from "starfx/react"; +// we could make special slices to help in handling Yjs updates, +// but this is enough to bootstrap it for the example. +// Internally we pass the updater the `ydoc` root, so we can +// directly manipulate Yjs data structures. export const schema = createSchema({}); export type AppState = typeof schema.initialState; @@ -27,10 +31,11 @@ export const createFolder = thunks.create("/users", function* (ctx, next) { console.log("Creating folder", ctx); yield* schema.update(((root: YjsRoot) => { const yarray = root.get("data").get("items"); + const dt = Date.now().toString(); yarray.push([ { - id: Date.now().toString(), - name: "New folder", + id: dt, + name: "New folder from " + dt, children: [], }, ]); diff --git a/examples/yjs/src/yjs.d.ts b/examples/yjs/src/yjs.d.ts deleted file mode 100644 index 528625df..00000000 --- a/examples/yjs/src/yjs.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -declare module "yjs" { - export type Transaction = unknown; - export type YEvent = { target: T }; - - export class Doc { - constructor(options?: { autoLoad?: boolean }); - getMap(name?: string): Map; - transact(fn: () => void): void; - } - - export class Map { - set(key: string, value: unknown): void; - get(key: string): V; - toJSON(): T; - observeDeep( - observer: (events: YEvent[], transaction: Transaction) => void, - ): void; - } - - export class Array { - push(items: T[]): void; - } -} \ No newline at end of file diff --git a/src/store/schema.ts b/src/store/schema.ts index 439a4191..0e46184c 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -1,5 +1,5 @@ import { type Operation, lift } from "effection"; -import { type Draft, enablePatches } from "immer"; +import type { Draft } from "immer"; import { API_ACTION_PREFIX, ActionContext, emit } from "../action.js"; import { type BaseMiddleware, compose } from "../compose.js"; import type { AnyState, Next } from "../types.js"; @@ -226,8 +226,6 @@ export function createSchema( slices?: O, options: CreateSchemaOptions = {}, ): FxSchema { - enablePatches(); - const middleware = options.middleware as | BaseMiddleware< UpdaterCtx, SchemaUpdater | SchemaUpdater[]> diff --git a/src/store/store.ts b/src/store/store.ts index 33fbaa4f..33dcb225 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -5,7 +5,7 @@ import { createScope, createSignal, } from "effection"; -import { type Draft, produceWithPatches } from "immer"; +import { type Draft, enablePatches, produceWithPatches } from "immer"; import { ActionContext, emit } from "../action.js"; import { parallel } from "../fx/parallel.js"; import type { AnyAction } from "../types.js"; @@ -132,6 +132,7 @@ export function createStore({ {} as Record>, ); + enablePatches(); // Build initial state from all schemas const initialState = schemas.reduce( (acc, schema) => { From e7f7581231510d9c8e9e87a1731b6f1fab7827e4 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Tue, 5 May 2026 23:33:20 -0500 Subject: [PATCH 30/35] multi-schema as keyed object --- examples/yjs/src/store/schema.ts | 113 +++++++++++++++++-------------- examples/yjs/src/thunks.ts | 1 - src/store/context.ts | 7 +- src/store/fx.ts | 2 +- src/store/schema.ts | 17 +---- src/store/store.ts | 101 +++++++++++++++++---------- src/store/types.ts | 45 ++++++++++-- src/test/api.test.ts | 16 ++--- src/test/create-store.test.ts | 92 +++++++++++++++++++++---- src/test/matcher.test.ts | 10 +-- src/test/mdw.test.ts | 2 +- src/test/put.test.ts | 8 +-- src/test/schema.test.ts | 4 +- src/test/store.test.ts | 4 +- src/test/take-helper.test.ts | 6 +- src/test/take.test.ts | 4 +- src/test/thunk.test.ts | 40 +++++------ 17 files changed, 305 insertions(+), 167 deletions(-) diff --git a/examples/yjs/src/store/schema.ts b/examples/yjs/src/store/schema.ts index dbf145df..19afcdac 100644 --- a/examples/yjs/src/store/schema.ts +++ b/examples/yjs/src/store/schema.ts @@ -1,7 +1,13 @@ import { + baseMiddlewares, + compose, type FxMap, type FxSchema, + type FxStore, + type Next, + type SchemaUpdater, type StoreUpdater, + type UpdaterCtx, createSchemaWithUpdater, expectStore, type SliceFromSchema, @@ -11,38 +17,58 @@ import { import type { Draft } from "immer"; import * as Y from "yjs"; -const SNAPSHOT = Symbol("yjs:snapshot"); - -type SnapshotUpdater> = StoreUpdater & { - [SNAPSHOT]: true; -}; - function createSnapshotUpdater( snapshot: SliceFromSchema, -): SnapshotUpdater> { - return Object.assign( - (draft: Draft>) => { - const nextState = snapshot as Record; - const draftState = draft as Record; - - for (const key of Object.keys(draftState)) { - if (!(key in nextState)) { - delete draftState[key]; - } +): StoreUpdater> { + return (draft: Draft>) => { + const nextState = snapshot as Record; + const draftState = draft as Record; + + for (const key of Object.keys(draftState)) { + if (!(key in nextState)) { + delete draftState[key]; } + } - Object.assign(draftState, nextState); - }, - { [SNAPSHOT]: true as const }, - ); + Object.assign(draftState, nextState); + }; } -function isSnapshotUpdater( - updater: unknown, -): updater is SnapshotUpdater> { - return ( - typeof updater === "function" && Reflect.get(updater, SNAPSHOT) === true - ); +function* reconcileSnapshot( + store: FxStore, + snapshot: SliceFromSchema, +) { + const updater = createSnapshotUpdater(snapshot); + const ctx: UpdaterCtx< + SliceFromSchema, + SchemaUpdater | SchemaUpdater[] + > = { + updater, + patches: [], + }; + + const applySnapshot = function*( + innerCtx: UpdaterCtx< + SliceFromSchema, + SchemaUpdater | SchemaUpdater[] + >, + next: Next, + ) { + const [_nextState, patches] = store.setState([ + updater as StoreUpdater>, + ]); + innerCtx.patches = patches; + yield* next(); + }; + + const runSnapshotMdw = compose< + UpdaterCtx, SchemaUpdater | SchemaUpdater[]> + >([ + applySnapshot, + ...baseMiddlewares, + ]); + + yield* runSnapshotMdw(ctx); } /** @@ -59,17 +85,20 @@ export function createYjsSchema(slices: O): FxSchema { data.set("items", new Y.Array()); return createSchemaWithUpdater(slices, { - name: "yjs", - initialize: function* () { + *initialize() { const store = yield* expectStore(); - const schema = store.schemas["yjs"]; - let observation = createSignal<{ - events: Y.YEvent[]; - transaction: any// Y.Transaction is just unknown? + const observation = createSignal<{ + events: Y.YEvent>[]; + transaction: Y.Transaction; }>(); root.observeDeep( - (events: Y.YEvent[], transaction: Y.Transaction) => { + ( + events: Y.YEvent>[], + transaction: Y.Transaction, + ) => { + // Only forward remote Yjs transactions; local ones already flow + // through updateMdw and do not need a second reconciliation pass. if (typeof transaction === "object" && transaction !== null && "local" in transaction && !transaction.local) { console.log("Y.Doc changed, sending update", events, transaction); observation.send({ events, transaction }); @@ -77,9 +106,7 @@ export function createYjsSchema(slices: O): FxSchema { }, ); - yield* schema.update( - createSnapshotUpdater(root.toJSON() as SliceFromSchema), - ); + yield* reconcileSnapshot(store, root.toJSON() as SliceFromSchema); for (const { events, transaction } of yield* each(observation)) { console.log( @@ -87,25 +114,13 @@ export function createYjsSchema(slices: O): FxSchema { events, transaction, ); - yield* schema.update( - createSnapshotUpdater(root.toJSON() as SliceFromSchema), - ); + yield* reconcileSnapshot(store, root.toJSON() as SliceFromSchema); } }, *updateMdw(ctx, next) { const store = yield* expectStore(); const updaters = Array.isArray(ctx.updater) ? ctx.updater : [ctx.updater]; - const snapshotUpdaters = updaters.filter((updater) => - isSnapshotUpdater(updater), - ); - - if (snapshotUpdaters.length > 0) { - store.setState(snapshotUpdaters); - yield* next(); - return; - } - ydoc.transact(() => { for (const updater of updaters) { (updater as (root: Y.Map) => void)(root); diff --git a/examples/yjs/src/thunks.ts b/examples/yjs/src/thunks.ts index 6c1aacad..d8cdf9b6 100644 --- a/examples/yjs/src/thunks.ts +++ b/examples/yjs/src/thunks.ts @@ -45,4 +45,3 @@ export const createFolder = thunks.create("/users", function* (ctx, next) { }); export const { useSelector } = createTypedHooks(schema); - diff --git a/src/store/context.ts b/src/store/context.ts index 62dad21a..6c1c0ada 100644 --- a/src/store/context.ts +++ b/src/store/context.ts @@ -1,5 +1,5 @@ import { type Channel, createChannel, createContext } from "effection"; -import type { FxMap, FxStore } from "./types.js"; +import type { FxMap, FxSchema, FxStore, StoreSchemaRegistry } from "./types.js"; /** * Channel used to notify that the store update sequence completed. @@ -17,7 +17,10 @@ export const StoreUpdateContext = createContext>( * * Use `expectStore()` within operations to access the store instance. */ -export const StoreContext = createContext>("starfx:store"); +export const StoreContext = + createContext>>>( + "starfx:store", + ); export function* expectStore() { return (yield* StoreContext.expect()) as FxStore; diff --git a/src/store/fx.ts b/src/store/fx.ts index b912736c..e98d9fcc 100644 --- a/src/store/fx.ts +++ b/src/store/fx.ts @@ -19,7 +19,7 @@ import type { /** * Updates the store using the default schema's update method. - * For multiple schemas, use `store.schemas[name].update()` directly. + * For additional schemas, use `store.schemas[key].update()` directly. */ export function* updateStore( updater: StoreUpdater | StoreUpdater[], diff --git a/src/store/schema.ts b/src/store/schema.ts index 0e46184c..29780761 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -51,11 +51,6 @@ export interface CreateSchemaWithUpdaterOptions< S extends AnyState, U = StoreUpdater | StoreUpdater[], > { - /** - * Unique name for this schema. Used to access the schema from the store. - * @default "default" - */ - name?: string; middleware?: BaseMiddleware>[]; /** * Factory function that creates the update middleware. @@ -66,11 +61,6 @@ export interface CreateSchemaWithUpdaterOptions< } interface CreateSchemaOptions { - /** - * Unique name for this schema. Used to access the schema from the store. - * @default "default" - */ - name?: string; /** * Escape hatch for middleware values that are not typed to this schema. * @@ -114,6 +104,8 @@ function* notifyListenersMdw( yield* next(); } +export const baseMiddlewares = [logMdw, notifyChannelMdw, notifyListenersMdw]; + /** * Core schema factory that takes a custom update middleware creator. * Use this to create schema implementations with different state update mechanisms. @@ -138,7 +130,6 @@ function* notifyListenersMdw( export function createSchemaWithUpdater( slices: O, { - name = "default", middleware = [], initialize, updateMdw, @@ -156,7 +147,7 @@ export function createSchemaWithUpdater( > > = compose< UpdaterCtx, SchemaUpdater | SchemaUpdater[]> - >([updateMdw, ...middleware, logMdw, notifyChannelMdw, notifyListenersMdw]); + >([updateMdw, ...middleware, ...baseMiddlewares]); function* update(ups: SchemaUpdater | SchemaUpdater[]) { const ctx: UpdaterCtx< @@ -201,7 +192,6 @@ export function createSchemaWithUpdater( } const schema = db as FxSchema; - schema.name = name; schema.update = update; schema.initialize = initialize; schema.initialState = initialState as SliceFromSchema; @@ -233,7 +223,6 @@ export function createSchema( | undefined; return createSchemaWithUpdater(slices ?? defaultSchema(), { - name: options.name, middleware, *updateMdw( ctx: UpdaterCtx< diff --git a/src/store/store.ts b/src/store/store.ts index 33dcb225..cf2fe406 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -11,11 +11,15 @@ import { parallel } from "../fx/parallel.js"; import type { AnyAction } from "../types.js"; import { StoreContext } from "./context.js"; import { createRun } from "./run.js"; +import { DEFAULT_SCHEMA_KEY } from "./types.js"; import type { + AnyFxSchema, FxMap, FxSchema, FxStore, Listener, + MergeSchemaRegistryMaps, + StoreSchemaRegistry, SliceFromSchema, StoreUpdater, } from "./types.js"; @@ -37,8 +41,7 @@ function observable() { export interface CreateStore { scope?: Scope; - schema?: FxSchema; - schemas?: FxSchema[]; + schema: FxSchema; /** * Long-lived startup operations to run inside the store scope. * @@ -51,6 +54,12 @@ export interface CreateStore { tasks?: (() => Operation)[]; } +export interface CreateStoreMulti { + scope?: Scope; + schema: TSchemas; + tasks?: (() => Operation)[]; +} + export const IdContext = createContext("starfx:id", 0); export const ListenersContext = createContext>( "starfx:store:listeners", @@ -72,7 +81,8 @@ export const ListenersContext = createContext>( * @typeParam O - Slice factory map used to build schema/state shape. * @param options - Store configuration object. * @param options.scope - Optional Effection scope to use. - * @param options.schemas - Schema list used to compose initial state. + * @param options.schema - Single schema or a keyed schema registry whose + * `default` entry defines `store.schema`. * @param options.tasks - Long-lived startup operations to run in the * store scope. Arbitrary custom tasks are started with the store, but they are * not awaited to a caller-defined ready state. @@ -86,33 +96,38 @@ export const ListenersContext = createContext>( * loaders: slice.loaders(), * }); * - * const store = createStore({ schemas: [schema] }); + * const store = createStore({ schema }); * ``` */ -export function createStore({ +export function createStore( + options: CreateStore, +): FxStore; +export function createStore( + options: CreateStoreMulti, +): FxStore, TSchemas>; +export function createStore({ scope: initScope, - schema: singleSchema, - schemas: multiSchemas, + schema: schemaInput, tasks = [], -}: CreateStore): FxStore { - // ensure only schema or schemas is provided, not both or neither - if (singleSchema && multiSchemas) { - throw new Error("Provide either `schema` or `schemas`, not both."); - } - if (!singleSchema && !multiSchemas) { +}: CreateStore | CreateStoreMulti): FxStore< + FxMap, + StoreSchemaRegistry> +> { + if (!schemaInput) { throw new Error("At least one schema must be provided."); } - // normalize to array of schemas for easier processing - let schemas: FxSchema[]; - if (singleSchema) { - schemas = [singleSchema]; - } else if (multiSchemas) { - schemas = multiSchemas; - } else { - throw new Error("Provide either `schema` or `schemas`."); + if (!isFxSchema(schemaInput) && !isSchemaRegistry(schemaInput)) { + throw new Error("A schema registry must include `default`"); } - const baseSchema = schemas[0]; + + const schemasMap: StoreSchemaRegistry> = isSchemaRegistry( + schemaInput, + ) + ? (schemaInput as StoreSchemaRegistry>) + : { [DEFAULT_SCHEMA_KEY]: schemaInput as FxSchema }; + const schemas = Object.values(schemasMap); + const baseSchema = schemasMap[DEFAULT_SCHEMA_KEY]; const [scope] = initScope ? [initScope] : createScope(); @@ -123,22 +138,13 @@ export function createStore({ scope.set(ActionContext, signal); scope.set(IdContext, id++); - // Build schemas map by name for selective access - const schemasMap = schemas.reduce( - (acc, s) => { - acc[s.name] = s; - return acc; - }, - {} as Record>, - ); - enablePatches(); // Build initial state from all schemas const initialState = schemas.reduce( (acc, schema) => { return Object.assign(acc, schema.initialState); }, - {} as SliceFromSchema, + {} as SliceFromSchema, ); let state = initialState; @@ -150,10 +156,10 @@ export function createStore({ return state; } - function setState(upds: StoreUpdater>[]) { + function setState(upds: StoreUpdater>[]) { const nextState = produceWithPatches( state, - (draft: Draft>) => { + (draft: Draft>) => { upds.forEach((updater) => updater(draft)); }, ); @@ -177,7 +183,7 @@ export function createStore({ const run = createRun(scope); - const store: FxStore = { + const store: FxStore>> = { getScope, getState, setState, @@ -190,7 +196,10 @@ export function createStore({ dispatch, // stubs so `react-redux` is happy replaceReducer( - _nextReducer: (s: SliceFromSchema, a: AnyAction) => SliceFromSchema, + _nextReducer: ( + s: SliceFromSchema, + a: AnyAction, + ) => SliceFromSchema, ): void { throw new Error(stubMsg); }, @@ -213,3 +222,23 @@ export function createStore({ return store; } + +function isSchemaRegistry( + schema: AnyFxSchema | StoreSchemaRegistry, +): schema is StoreSchemaRegistry { + return ( + typeof schema === "object" && + schema !== null && + DEFAULT_SCHEMA_KEY in schema && + isFxSchema(schema[DEFAULT_SCHEMA_KEY]) + ); +} + +function isFxSchema(value: unknown): value is AnyFxSchema { + return ( + typeof value === "object" && + value !== null && + "update" in value && + "initialState" in value + ); +} diff --git a/src/store/types.ts b/src/store/types.ts index 55284d66..211c457b 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -72,6 +72,39 @@ export interface FxMap { [key: string]: ((name: string) => BaseSchema) | undefined; } +// biome-ignore lint/suspicious/noExplicitAny: used only to represent an arbitrary schema instance in store composition helpers. +export type AnyFxSchema = FxSchema; + +export const DEFAULT_SCHEMA_KEY = "default"; + +export type DefaultSchemaKey = typeof DEFAULT_SCHEMA_KEY; + +export type SchemaRegistry = Record; + +export type StoreSchemaRegistry = + Record & SchemaRegistry; + +export type SchemaMapOf = TSchema extends FxSchema + ? O + : never; + +type UnionToIntersection = ( + T extends unknown + ? (value: T) => void + : never +) extends (value: infer I) => void + ? I + : never; + +type Simplify = { [K in keyof T]: T[K] } & {}; + +export type MergeSchemaRegistryMaps = + UnionToIntersection< + SchemaMapOf + > extends infer O extends FxMap + ? Simplify + : never; + // Helper types to extract the factory return type and its initialState export type FactoryReturn = T extends (name: string) => infer R ? R : never; export type FactoryInitial = FactoryReturn< @@ -111,7 +144,6 @@ export type SchemaUpdater = export type FxSchema = { [K in keyof O]: FactoryReturn>; } & { - name: string; initialize?: () => Operation; update: ( u: SchemaUpdater | SchemaUpdater[], @@ -132,7 +164,10 @@ export type FxSchema = { * @remarks * Compatible with react-redux store expectations for interop. */ -export interface FxStore { +export interface FxStore< + O extends FxMap, + TSchemas extends StoreSchemaRegistry = StoreSchemaRegistry>, +> { getScope: () => Scope; // part of redux store API getState: () => SliceFromSchema; @@ -142,9 +177,9 @@ export interface FxStore { // part of redux store API subscribe: (fn: Listener) => () => void; // the default schema for this store - schema: FxSchema; - // all schemas by name - schemas: Record>; + schema: TSchemas[DefaultSchemaKey]; + // all schemas keyed by their registry entry + schemas: TSchemas; run: ReturnType; // part of redux store API dispatch: (a: AnyAction | AnyAction[]) => unknown; diff --git a/src/test/api.test.ts b/src/test/api.test.ts index 91b5d3da..53dff69e 100644 --- a/src/test/api.test.ts +++ b/src/test/api.test.ts @@ -166,7 +166,7 @@ test("POST with uri", () => { }, ); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); store.run(query.register); store.dispatch(createUser({ email: mockUser.email })); }); @@ -187,7 +187,7 @@ test("middleware - with request fn", () => { { supervisor: takeEvery }, query.request({ method: "POST" }), ); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); store.run(query.register); store.dispatch(createUser()); }); @@ -216,7 +216,7 @@ test("run() on endpoint action - should run the effect", () => { }, ); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.dispatch(action2()); }); @@ -260,7 +260,7 @@ test("run() from a normal saga", async () => { yield* takeEvery(action2, onAction); } - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); store.run(() => keepAlive([api.register, watchAction])); store.dispatch(action2()); @@ -402,7 +402,7 @@ test("ensure types for get() endpoint", () => { }, ); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.dispatch(action1({ id: "1" })); @@ -440,7 +440,7 @@ test("ensure ability to cast `ctx` in function definition", () => { }, ); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.dispatch(action1({ id: "1" })); expect(acc).toEqual(["1", "wow"]); @@ -474,7 +474,7 @@ test("ensure ability to cast `ctx` in function definition with no props", () => }, ); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.dispatch(action1()); expect(acc).toEqual(["wow"]); @@ -542,7 +542,7 @@ test("useCache - derive api success from endpoint", () => { }, ); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); store.run(api.register); function _App() { diff --git a/src/test/create-store.test.ts b/src/test/create-store.test.ts index ee1fbf52..5b937459 100644 --- a/src/test/create-store.test.ts +++ b/src/test/create-store.test.ts @@ -1,4 +1,5 @@ import { call } from "../index.js"; +import { expectTypeOf } from "vitest"; import { createSchema, createStore, select, slice } from "../store/index.js"; import { expect, test } from "../test.js"; @@ -6,10 +7,18 @@ interface TestState { user: { id: string }; } +interface BaseState { + users: Record; +} + +interface MetadataState { + metadata: Record; +} + test("should be able to grab values from store", async () => { let actual: TestState["user"] | undefined; const store = createStore({ - schemas: [createSchema({ user: slice.obj({ id: "1" }) })], + schema: createSchema({ user: slice.obj({ id: "1" }) }), }); await store.run(function* () { actual = yield* select((s: TestState) => s.user); @@ -20,7 +29,7 @@ test("should be able to grab values from store", async () => { test("should be able to grab store from a nested call", async () => { let actual: TestState["user"] | undefined; const store = createStore({ - schemas: [createSchema({ user: slice.obj({ id: "2" }) })], + schema: createSchema({ user: slice.obj({ id: "2" }) }), }); await store.run(function* () { actual = yield* call(function* () { @@ -43,19 +52,78 @@ test("should accept a single schema option", async () => { expect(store.schema).toBe(schema); }); -test("should reject both schema and schemas", () => { - const schema = createSchema({ user: slice.obj({ id: "4" }) }); +test("should reject schema registries without default", () => { + const metadata = createSchema({ + metadata: slice.obj>({}), + }); expect(() => - createStore({ - schema, - schemas: [schema], - }), - ).toThrow("Provide either `schema` or `schemas`, not both."); + createStore( + // biome-ignore lint/suspicious/noExplicitAny: runtime validation test intentionally passes an invalid config shape. + { schema: { metadata } } as any, + ), + ).toThrow("A schema registry must include `default`."); }); test("should reject missing schema configuration", () => { - expect(() => createStore({})).toThrow( - "At least one schema must be provided.", - ); + expect(() => + // biome-ignore lint/suspicious/noExplicitAny: runtime validation test intentionally passes an invalid config shape. + createStore({} as any), + ).toThrow("At least one schema must be provided."); +}); + +test("should keep base schema typing while merging store state across schemas", () => { + const baseSchema = createSchema({ + users: slice.table<{ id: string; name: string }>(), + }); + const metadataSchema = createSchema({ + metadata: slice.obj>({}), + }); + const store = createStore({ + schema: { default: baseSchema, metadata: metadataSchema }, + }); + + const readBaseUsers = () => store.schema.users.selectTableAsList; + expectTypeOf(readBaseUsers).toBeFunction(); + + // @ts-expect-error base schema should not expose slices from later schemas + const readMetadataFromBaseSchema = () => store.schema.metadata.select; + void readMetadataFromBaseSchema; + + const state = store.getState(); + expectTypeOf(state.users).toEqualTypeOf(); + expectTypeOf(state.metadata).toEqualTypeOf(); +}); + +test("should require default in schema registries", () => { + const metadataSchema = createSchema({ + metadata: slice.obj>({}), + }); + const schema = { metadata: metadataSchema }; + + // @ts-expect-error schema registries must include a default schema + const createStoreWithoutDefault = () => createStore({ schema }); + void createStoreWithoutDefault; +}); + +test("should keep multi-schema typing for schema registry variables", () => { + const baseSchema = createSchema({ + users: slice.table<{ id: string; name: string }>(), + }); + const metadataSchema = createSchema({ + metadata: slice.obj>({}), + }); + const schema = { default: baseSchema, metadata: metadataSchema }; + const store = createStore({ schema }); + + const readBaseUsers = () => store.schema.users.selectTableAsList; + expectTypeOf(readBaseUsers).toBeFunction(); + + // @ts-expect-error base schema should not expose slices from later schemas + const readMetadataFromBaseSchema = () => store.schema.metadata.select; + void readMetadataFromBaseSchema; + + const state = store.getState(); + expectTypeOf(state.users).toEqualTypeOf(); + expectTypeOf(state.metadata).toEqualTypeOf(); }); diff --git a/src/test/matcher.test.ts b/src/test/matcher.test.ts index 862eabda..4a5819a0 100644 --- a/src/test/matcher.test.ts +++ b/src/test/matcher.test.ts @@ -17,7 +17,7 @@ test("true", () => { test("createAction should not match all actions", async () => { expect.assertions(1); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); const matchedActions: string[] = []; const testAction = createAction("test/action"); @@ -52,7 +52,7 @@ test("matcher should correctly identify createAction functions", () => { test("typed createAction should work with takeLatest without type casting", async () => { expect.assertions(1); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); const matchedActions: string[] = []; //typed action creator - this should work without 'as any' @@ -88,7 +88,7 @@ test("should correctly identify starfx thunk as a thunk", async () => { thunks.use(thunks.routes()); const store = createStore({ - schemas: [createSchema()], + schema: createSchema(), }); store.run(thunks.register); @@ -118,7 +118,7 @@ test("matcher should correctly identify thunk functions", async () => { thunks.use(thunks.routes()); const store = createStore({ - schemas: [createSchema()], + schema: createSchema(), }); store.run(thunks.register); @@ -141,7 +141,7 @@ test("matcher should correctly identify thunk functions", async () => { test("some bug: createAction incorrectly matching all actions", async () => { expect.assertions(1); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); const matchedActions: string[] = []; const testAction = createAction<{ MenuOpened: any }>("ACTION"); diff --git a/src/test/mdw.test.ts b/src/test/mdw.test.ts index 1e32554f..870d451b 100644 --- a/src/test/mdw.test.ts +++ b/src/test/mdw.test.ts @@ -517,7 +517,7 @@ test("errorHandler", () => { ); const store = createStore({ - schemas: [createSchema()], + schema: createSchema(), }); store.run(query.register); store.dispatch(fetchUsers()); diff --git a/src/test/put.test.ts b/src/test/put.test.ts index 2e1e2ab4..347ee264 100644 --- a/src/test/put.test.ts +++ b/src/test/put.test.ts @@ -26,7 +26,7 @@ test("should send actions through channel", async () => { yield* task; } - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); await store.run(() => genFn("arg")); const expected = ["arg", "2"]; @@ -59,7 +59,7 @@ test("should handle nested puts", async () => { yield* sleep(0); } - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); await store.run(() => root()); // TODO, was this backwards? we are using `take("a")` in `genB`, so it will wait for `genA` to finish @@ -74,7 +74,7 @@ test("should not cause stack overflow when puts are emitted while dispatching sa } } - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); await store.run(root); expect(true).toBe(true); }); @@ -101,7 +101,7 @@ test("should not miss `put` that was emitted directly after creating a task (cau yield* tsk; } - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); await store.run(root); const expected = ["didn't get missed"]; expect(actual).toEqual(expected); diff --git a/src/test/schema.test.ts b/src/test/schema.test.ts index c776512c..b0d211cf 100644 --- a/src/test/schema.test.ts +++ b/src/test/schema.test.ts @@ -50,7 +50,7 @@ test("general types and functionality", async () => { cache: slice.table({ empty: {} }), loaders: slice.loaders(), }); - const store = createStore({ schemas: [db] }); + const store = createStore({ schema: db }); expect(store.getState()).toEqual({ users: { "1": { id: "1", name: "wow" } }, @@ -105,7 +105,7 @@ test("can work with a nested object", async () => { cache: slice.table({ empty: {} }), loaders: slice.loaders(), }); - const store = createStore({ schemas: [db] }); + const store = createStore({ schema: db }); type State = ReturnType; await store.run(function* () { yield* db.update(db.currentUser.update({ key: "name", value: "vvv" })); diff --git a/src/test/store.test.ts b/src/test/store.test.ts index cdd64ea2..6d79b6b4 100644 --- a/src/test/store.test.ts +++ b/src/test/store.test.ts @@ -220,7 +220,7 @@ describe(".registerResource", () => { const TestContext = registerResource("test:context", guessAge()); const store = createStore({ scope, - schemas: [createSchema()], + schema: createSchema(), tasks: [TestContext.initialize, thunk.register], }); let acc = "bla"; @@ -246,7 +246,7 @@ describe(".registerResource", () => { const TestContext = registerResource("test:context", guessAge()); const store = createStore({ scope, - schemas: [createSchema()], + schema: createSchema(), tasks: [TestContext.initialize, thunk.register], }); let guess = 0; diff --git a/src/test/take-helper.test.ts b/src/test/take-helper.test.ts index f2a739b3..e533ccbb 100644 --- a/src/test/take-helper.test.ts +++ b/src/test/take-helper.test.ts @@ -19,7 +19,7 @@ test("should cancel previous tasks and only use latest", async () => { yield* take("CANCEL_WATCHER"); yield* task.halt(); } - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); const task = store.run(root); store.dispatch({ type: "ACTION", payload: "1" }); @@ -48,7 +48,7 @@ test("should keep first action and discard the rest", async () => { yield* sleep(150); yield* task.halt(); } - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); const task = store.run(root); store.dispatch({ type: "ACTION", payload: "1" }); @@ -78,7 +78,7 @@ test("should receive all actions", async () => { actual.push([arg1, arg2, action.payload]); } - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); const task = store.run(root); for (let i = 1; i <= loop / 2; i += 1) { diff --git a/src/test/take.test.ts b/src/test/take.test.ts index 83fc1c56..ca98e7d2 100644 --- a/src/test/take.test.ts +++ b/src/test/take.test.ts @@ -22,7 +22,7 @@ test("a put should complete before more `take` are added and then consumed autom actual.push(yield* take("action-1")); } - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); await store.run(root); expect(actual).toEqual([ @@ -94,7 +94,7 @@ test("take from default channel", async () => { yield* takes; // wait for the takes to complete } - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); await store.run(genFn); const expected = [ diff --git a/src/test/thunk.test.ts b/src/test/thunk.test.ts index 5361362e..d1a48852 100644 --- a/src/test/thunk.test.ts +++ b/src/test/thunk.test.ts @@ -219,7 +219,7 @@ test("error handling", () => { const action = api.create("/error", { supervisor: takeEvery }); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.dispatch(action()); expect(called).toBe(true); @@ -245,7 +245,7 @@ test("error handling inside create", () => { } }, ); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.dispatch(action()); expect(called).toBe(true); @@ -274,7 +274,7 @@ test("error inside endpoint mdw", () => { ); const store = createStore({ - schemas: [createSchema()], + schema: createSchema(), }); store.run(query.register); store.dispatch(fetchUsers()); @@ -307,7 +307,7 @@ test("create fn is an array", () => { }, ]); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.dispatch(action()); }); @@ -339,7 +339,7 @@ test("run() on endpoint action - should run the effect", () => { }, ); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.dispatch(action2()); expect(acc).toBe("ab"); @@ -380,7 +380,7 @@ test("run() on endpoint action with payload - should run the effect", () => { }, ); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.dispatch(action2()); expect(acc).toBe("ab"); @@ -427,7 +427,7 @@ test("middleware order of execution", async () => { }, ); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.dispatch(action()); @@ -461,7 +461,7 @@ test("retry with actionFn", async () => { } }); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.dispatch(action()); @@ -496,7 +496,7 @@ test("retry with actionFn with payload", async () => { }, ); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.dispatch(action({ page: 1 })); @@ -531,7 +531,7 @@ test("should only call thunk once", () => { }, ); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.dispatch(action2()); expect(acc).toBe("a"); @@ -541,7 +541,7 @@ test("should be able to create thunk after `register()`", () => { expect.assertions(1); const api = createThunks(); api.use(api.routes()); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); store.run(api.register); let acc = ""; @@ -561,7 +561,7 @@ test("should warn when calling thunk before registered", () => { }; const api = createThunks(); api.use(api.routes()); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); const action = api.create("/users"); store.dispatch(action()); @@ -573,7 +573,7 @@ test("it should call the api once even if we register it twice", () => { expect.assertions(1); const api = createThunks(); api.use(api.routes()); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.run(api.register); @@ -593,7 +593,7 @@ test("should call the API only once, even if registered multiple times, with mul const api2 = createThunks(); api2.use(api2.routes()); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); store.run(api1.register); store.run(api1.register); @@ -624,7 +624,7 @@ test("should unregister the thunk when the registration function exits", async ( const api1 = createThunks(); api1.use(api1.routes()); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); const task = store.run(api1.register); await task.halt(); store.run(api1.register); @@ -642,8 +642,8 @@ test("should allow multiple stores to register a thunk", () => { expect.assertions(1); const api1 = createThunks(); api1.use(api1.routes()); - const storeA = createStore({ schemas: [createSchema()] }); - const storeB = createStore({ schemas: [createSchema()] }); + const storeA = createStore({ schema: createSchema() }); + const storeB = createStore({ schema: createSchema() }); storeA.run(api1.register); storeB.run(api1.register); let acc = ""; @@ -683,7 +683,7 @@ describe(".manage", () => { const thunk = createThunks(); thunk.use(thunk.routes()); const TestContext = thunk.manage("test:context", guessAge()); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); store.run(thunk.register); let acc = ""; const action = thunk.create("/users", function* (payload, next) { @@ -701,7 +701,7 @@ describe(".manage", () => { const thunk = createThunks(); thunk.use(thunk.routes()); const TestContext = thunk.manage("test:context", guessAge()); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); store.run(thunk.register); let acc = ""; const action = thunk.create("/users", function* (payload, next) { @@ -720,7 +720,7 @@ describe(".manage", () => { const thunk = createThunks(); thunk.use(thunk.routes()); const TestContext = thunk.manage("test:context", guessAge()); - const store = createStore({ schemas: [createSchema()] }); + const store = createStore({ schema: createSchema() }); store.run(thunk.register); let guess = 0; let acc = 0; From cef07db16bb8b87fda7211f61a07aa010f12c230 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Wed, 6 May 2026 00:42:36 -0500 Subject: [PATCH 31/35] linted --- src/store/store.ts | 2 +- src/test/create-store.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/store/store.ts b/src/store/store.ts index cf2fe406..c15d28ba 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -19,8 +19,8 @@ import type { FxStore, Listener, MergeSchemaRegistryMaps, - StoreSchemaRegistry, SliceFromSchema, + StoreSchemaRegistry, StoreUpdater, } from "./types.js"; const stubMsg = "This is merely a stub, not implemented"; diff --git a/src/test/create-store.test.ts b/src/test/create-store.test.ts index 5b937459..37c18537 100644 --- a/src/test/create-store.test.ts +++ b/src/test/create-store.test.ts @@ -1,5 +1,5 @@ -import { call } from "../index.js"; import { expectTypeOf } from "vitest"; +import { call } from "../index.js"; import { createSchema, createStore, select, slice } from "../store/index.js"; import { expect, test } from "../test.js"; From f1c98cf1d36588728047ce52caac64343b8daa2e Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Wed, 6 May 2026 00:53:13 -0500 Subject: [PATCH 32/35] patience tested --- src/test/create-store.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/create-store.test.ts b/src/test/create-store.test.ts index 37c18537..8e99dfce 100644 --- a/src/test/create-store.test.ts +++ b/src/test/create-store.test.ts @@ -62,7 +62,7 @@ test("should reject schema registries without default", () => { // biome-ignore lint/suspicious/noExplicitAny: runtime validation test intentionally passes an invalid config shape. { schema: { metadata } } as any, ), - ).toThrow("A schema registry must include `default`."); + ).toThrow("A schema registry must include `default`"); }); test("should reject missing schema configuration", () => { From 6e59366d064e299ae26d1a0dccfc2f2a4c4f63ad Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Sat, 9 May 2026 01:23:07 -0500 Subject: [PATCH 33/35] Provider handles multi-schema store --- src/react.ts | 11 ++++++++--- src/test/react-types.test.ts | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/react.ts b/src/react.ts index 4a96965d..3f99c813 100644 --- a/src/react.ts +++ b/src/react.ts @@ -8,9 +8,11 @@ import { import { getIdFromAction } from "./action.js"; import type { ThunkAction } from "./query/index.js"; import { + type DefaultSchemaKey, type FxSchema, type FxStore, PERSIST_LOADER_ID, + type StoreSchemaRegistry, } from "./store/index.js"; import type { LoaderOutput } from "./store/slice/loaders.js"; import type { TableOutput } from "./store/slice/table.js"; @@ -180,9 +182,12 @@ export function createTypedHooks( * } * ``` */ -export function Provider(props: { - store: FxStore; - schema?: FxSchema; +export function Provider< + O extends FxMap, + TSchemas extends StoreSchemaRegistry = StoreSchemaRegistry>, +>(props: { + store: FxStore; + schema?: TSchemas[DefaultSchemaKey]; children?: React.ReactNode; }): React.ReactElement; diff --git a/src/test/react-types.test.ts b/src/test/react-types.test.ts index 4fa6367a..6afb0eca 100644 --- a/src/test/react-types.test.ts +++ b/src/test/react-types.test.ts @@ -1,6 +1,8 @@ import { describe, expectTypeOf, test } from "vitest"; +import type { ReactElement } from "react"; import type { ThunkAction } from "../query/index.js"; import { + Provider, type UseApiAction, type UseApiProps, type UseApiSimpleProps, @@ -139,6 +141,22 @@ describe("react hook types", () => { expectTypeOf(useSchemaState).returns.toEqualTypeOf(); expectTypeOf(useTypedLoader).returns.toEqualTypeOf(); }); + + test("Provider accepts stores with merged root state and narrower default schema", () => { + const baseSchema = createSchema({ + users: slice.table(), + }); + const metadataSchema = createSchema({ + metadata: slice.obj({}), + }); + const store = createStore({ + schema: { default: baseSchema, metadata: metadataSchema }, + }); + + const renderProvider = () => Provider({ store, children: null }); + + expectTypeOf(renderProvider).returns.toEqualTypeOf(); + }); }); describe("direct hooks with explicit schema generics", () => { From 83fcfb94a03abe8a03b300902c2b18de43ac5bda Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Fri, 15 May 2026 13:53:24 -0500 Subject: [PATCH 34/35] cache helper, rework base schema slice type --- docs/posts/endpoints.md | 2 +- docs/posts/schema.md | 2 +- docs/posts/store.md | 2 +- examples/yjs/src/store/schema.ts | 12 ++--- src/react.ts | 40 ++++++++++------ src/store/context.ts | 81 ++++++++++++++++++++++++++++---- src/store/fx.ts | 11 +++-- src/store/schema.ts | 13 ++--- src/store/slice/index.ts | 7 +-- src/store/slice/table.ts | 21 ++++++++- src/store/store.ts | 38 ++++++++------- src/store/types.ts | 32 ++++++++----- src/test/create-store.test.ts | 28 ++++++++++- src/test/react-types.test.ts | 10 ++-- 14 files changed, 216 insertions(+), 83 deletions(-) diff --git a/docs/posts/endpoints.md b/docs/posts/endpoints.md index 604874c6..51912dac 100644 --- a/docs/posts/endpoints.md +++ b/docs/posts/endpoints.md @@ -332,7 +332,7 @@ function deserializeComment(com: any): Comment { } const [schema, initialState] = createSchema({ - cache: slice.table(), + cache: slice.cache(), loaders: slice.loaders(), token: slice.str(), articles: slice.table
(), diff --git a/docs/posts/schema.md b/docs/posts/schema.md index 16b3b41f..aa061ffd 100644 --- a/docs/posts/schema.md +++ b/docs/posts/schema.md @@ -32,7 +32,7 @@ a place for `starfx` and third-party functionality to hold their state. import { createSchema, slice } from "starfx"; const [schema, initialState] = createSchema({ - cache: slice.table(), + cache: slice.cache(), loaders: slice.loaders(), }); ``` diff --git a/docs/posts/store.md b/docs/posts/store.md index 9e4fcce2..0143f5a8 100644 --- a/docs/posts/store.md +++ b/docs/posts/store.md @@ -56,7 +56,7 @@ interface User { // app-wide database for ui, api data, or anything that needs reactivity const [schema, initialState] = createSchema({ - cache: slice.table(), + cache: slice.cache(), loaders: slice.loaders(), users: slice.table(), }); diff --git a/examples/yjs/src/store/schema.ts b/examples/yjs/src/store/schema.ts index 19afcdac..01fe2a10 100644 --- a/examples/yjs/src/store/schema.ts +++ b/examples/yjs/src/store/schema.ts @@ -1,10 +1,10 @@ import { baseMiddlewares, compose, - type FxMap, type FxSchema, type FxStore, type Next, + type SchemaMap, type SchemaUpdater, type StoreUpdater, type UpdaterCtx, @@ -17,7 +17,7 @@ import { import type { Draft } from "immer"; import * as Y from "yjs"; -function createSnapshotUpdater( +function createSnapshotUpdater( snapshot: SliceFromSchema, ): StoreUpdater> { return (draft: Draft>) => { @@ -34,7 +34,7 @@ function createSnapshotUpdater( }; } -function* reconcileSnapshot( +function* reconcileSnapshot( store: FxStore, snapshot: SliceFromSchema, ) { @@ -75,7 +75,7 @@ function* reconcileSnapshot( * Creates a Yjs-backed schema where state updates are synchronized via Y.Doc. * This demonstrates using createSchemaWithUpdater for custom state management. */ -export function createYjsSchema(slices: O): FxSchema { +export function createYjsSchema(slices: O): FxSchema { console.log("Creating Y.Doc"); const ydoc = new Y.Doc({ autoLoad: true }); const root = ydoc.getMap(); @@ -86,7 +86,7 @@ export function createYjsSchema(slices: O): FxSchema { return createSchemaWithUpdater(slices, { *initialize() { - const store = yield* expectStore(); + const store = (yield* expectStore>()) as FxStore; const observation = createSignal<{ events: Y.YEvent>[]; transaction: Y.Transaction; @@ -118,7 +118,7 @@ export function createYjsSchema(slices: O): FxSchema { } }, *updateMdw(ctx, next) { - const store = yield* expectStore(); + const store = (yield* expectStore>()) as FxStore; const updaters = Array.isArray(ctx.updater) ? ctx.updater : [ctx.updater]; ydoc.transact(() => { diff --git a/src/react.ts b/src/react.ts index 3f99c813..676b97b6 100644 --- a/src/react.ts +++ b/src/react.ts @@ -1,3 +1,4 @@ +import type { Operation } from "effection"; import React, { type ReactElement } from "react"; import { Provider as ReduxProvider, @@ -8,6 +9,7 @@ import { import { getIdFromAction } from "./action.js"; import type { ThunkAction } from "./query/index.js"; import { + type AnyFxSchema, type DefaultSchemaKey, type FxSchema, type FxStore, @@ -16,7 +18,7 @@ import { } from "./store/index.js"; import type { LoaderOutput } from "./store/slice/loaders.js"; import type { TableOutput } from "./store/slice/table.js"; -import type { FxMap, SliceFromSchema } from "./store/types.js"; +import type { SchemaMap, SliceFromSchema } from "./store/types.js"; import type { AnyState, LoaderState } from "./types.js"; import type { ActionFn, ActionFnWithPayload } from "./types.js"; @@ -31,10 +33,10 @@ const { createElement: h, } = React; -type WithLoadersMap = FxMap & { loaders: (n: string) => LoaderOutput }; -type WithCacheMap = FxMap & { cache: (n: string) => TableOutput }; +type WithLoadersMap = SchemaMap & { loaders: (n: string) => LoaderOutput }; +type WithCacheMap = SchemaMap & { cache: (n: string) => TableOutput }; -export type TypedHooks = { +export type TypedHooks = { useSelector: ( selector: (state: SliceFromSchema) => Selected, equalityFn?: (left: Selected, right: Selected) => boolean, @@ -96,6 +98,12 @@ type UseApiReturn = A extends ActionFn ? UseApiAction : never; +type StoreContextValue = ReturnType< + typeof import("./store/context.js").StoreContext.expect +> extends Operation + ? T + : never; + const SchemaContext = createContext(null); export function useSelector( @@ -103,7 +111,7 @@ export function useSelector( selector: (state: any) => Selected, equalityFn?: (left: Selected, right: Selected) => boolean, ): Selected; -export function useSelector( +export function useSelector( selector: (state: SliceFromSchema) => Selected, equalityFn?: (left: Selected, right: Selected) => boolean, ): Selected; @@ -113,12 +121,12 @@ export function useSelector( equalityFn?: (left: unknown, right: unknown) => boolean, ): unknown { return useReduxSelector( - selector as (state: SliceFromSchema) => unknown, + selector as (state: SliceFromSchema) => unknown, equalityFn, ); } -export function createTypedHooks( +export function createTypedHooks( _schema: FxSchema, ): TypedHooks { const useTypedSelector: TypedHooks["useSelector"] = ( @@ -183,7 +191,7 @@ export function createTypedHooks( * ``` */ export function Provider< - O extends FxMap, + O extends SchemaMap, TSchemas extends StoreSchemaRegistry = StoreSchemaRegistry>, >(props: { store: FxStore; @@ -192,20 +200,22 @@ export function Provider< }): React.ReactElement; export function Provider(props: { - // biome-ignore lint/suspicious/noExplicitAny: Provider must accept any typed schema/store pair; hook APIs preserve the specific types. - store: FxStore; - // biome-ignore lint/suspicious/noExplicitAny: Provider must accept any typed schema/store pair; hook APIs preserve the specific types. - schema?: FxSchema; + store: unknown; + schema?: unknown; children?: React.ReactNode; }): React.ReactElement { - const { store, schema, children } = props; + const { store, schema, children } = props as { + store: StoreContextValue; + schema?: AnyFxSchema; + children?: React.ReactNode; + }; // Use provided schema or pull from store const schemaValue = schema ?? store.schema; const inner = h(SchemaContext.Provider, { value: schemaValue }, children); return h(ReduxProvider, { store, children: inner }); } -export function useSchema(): FxSchema { +export function useSchema(): FxSchema { const ctx = useContext(SchemaContext); if (!ctx) throw new Error("No Schema available in context"); return ctx as FxSchema; @@ -229,7 +239,7 @@ export function useSchemaWithCache< return ctx as FxSchema; } -export function useStore() { +export function useStore() { return useReduxStore() as FxStore; } diff --git a/src/store/context.ts b/src/store/context.ts index 6c1c0ada..4a39dc9e 100644 --- a/src/store/context.ts +++ b/src/store/context.ts @@ -1,5 +1,38 @@ -import { type Channel, createChannel, createContext } from "effection"; -import type { FxMap, FxSchema, FxStore, StoreSchemaRegistry } from "./types.js"; +import { + type Channel, + type Operation, + createChannel, + createContext, +} from "effection"; +import type { AnyAction, AnyState } from "../types.js"; +import type { + AnyFxSchema, + FxSchema, + FxStore, + MergeSchemaRegistryMaps, + SchemaMap, + SchemaMapOf, + StoreSchemaRegistry, + StoreUpdater, +} from "./types.js"; + +type StoreContextValue = Omit< + FxStore>, + "getState" | "setState" | "replaceReducer" | "getInitialState" +> & { + getState: () => AnyState; + setState: (upds: StoreUpdater[]) => unknown; + replaceReducer: (r: (s: AnyState, a: AnyAction) => AnyState) => void; + getInitialState: () => AnyState; +}; + +type StoreTypeHint = AnyFxSchema | StoreSchemaRegistry; + +type StoreFromTypeHint = [T] extends [StoreSchemaRegistry] + ? FxStore, T> + : [T] extends [FxSchema] + ? FxStore, StoreSchemaRegistry> + : StoreContextValue; /** * Channel used to notify that the store update sequence completed. @@ -17,11 +50,43 @@ export const StoreUpdateContext = createContext>( * * Use `expectStore()` within operations to access the store instance. */ -export const StoreContext = - createContext>>>( - "starfx:store", - ); +export const StoreContext = createContext("starfx:store"); -export function* expectStore() { - return (yield* StoreContext.expect()) as FxStore; +/** + * Retrieves the active store from Effection context with an optional type hint. + * + * @remarks + * `StoreContext` is a single global context, so a bare `StoreContext.expect()` can + * only return a broad store type. Use `expectStore()` when you want a + * more specific store shape for `getState()`, `schema`, or `schemas`. + * + * Pass the schema you already used to create the store: + * - a single schema, e.g. `expectStore()` + * - a schema registry, e.g. `expectStore()` + * + * @typeParam T - A type hint describing the store shape. + * @returns The active store from `StoreContext`, narrowed by the provided type hint. + * + * @example + * ```ts + * const schema = createSchema({ users: slice.table() }); + * const store = yield* expectStore(); + * store.schema.users.selectTableAsList; + * ``` + * + * @example + * ```ts + * const schemas = { default: baseSchema, metadata: metadataSchema }; + * const store = yield* expectStore(); + * const state = store.getState(); + * state.metadata; + * ``` + * + */ +export function expectStore(): Operation< + StoreFromTypeHint +>; +export function expectStore(): Operation; +export function* expectStore() { + return (yield* StoreContext.expect()) as StoreFromTypeHint; } diff --git a/src/store/fx.ts b/src/store/fx.ts index e98d9fcc..1d5b1126 100644 --- a/src/store/fx.ts +++ b/src/store/fx.ts @@ -11,7 +11,8 @@ import type { import { expectStore } from "./context.js"; import type { LoaderOutput } from "./slice/loaders.js"; import type { - FxMap, + FxSchema, + SchemaMap, SliceFromSchema, StoreUpdater, UpdaterCtx, @@ -24,11 +25,11 @@ import type { export function* updateStore( updater: StoreUpdater | StoreUpdater[], ): Operation> { - const store = yield* expectStore(); + const store = yield* expectStore>(); const ctx = yield* store.schema.update( updater as - | StoreUpdater> - | StoreUpdater>[], + | StoreUpdater> + | StoreUpdater>[], ); return ctx as UpdaterCtx; } @@ -37,7 +38,7 @@ export function* select( selectorFn: (s: S, ...args: Args) => R, ...args: Args ): Operation { - const store = yield* expectStore(); + const store = yield* expectStore>(); return selectorFn(store.getState() as S, ...args); } diff --git a/src/store/schema.ts b/src/store/schema.ts index 29780761..0cf22233 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -12,6 +12,7 @@ import type { FxMap, FxSchema, FxStore, + SchemaMap, SchemaUpdater, SliceFromSchema, StoreUpdater, @@ -19,13 +20,13 @@ import type { } from "./types.js"; const defaultSchema = (): O => - ({ cache: slice.table(), loaders: slice.loaders() }) as O; + ({ cache: slice.cache(), loaders: slice.loaders() }) as O; /** * Builds the slice map and initial state from a slices configuration. * This is a helper for creating custom schema implementations. */ -export function buildSlices( +export function buildSlices( slices: O, ): { db: { [K in keyof O]: FactoryReturn }; @@ -60,7 +61,7 @@ export interface CreateSchemaWithUpdaterOptions< initialize?: () => Operation; } -interface CreateSchemaOptions { +interface CreateSchemaOptions { /** * Escape hatch for middleware values that are not typed to this schema. * @@ -127,7 +128,7 @@ export const baseMiddlewares = [logMdw, notifyChannelMdw, notifyListenersMdw]; * }); * ``` */ -export function createSchemaWithUpdater( +export function createSchemaWithUpdater( slices: O, { middleware = [], @@ -212,7 +213,7 @@ export function createSchemaWithUpdater( * @param options - Schema options including `name` and custom middleware. * @returns A configured schema with `update`, `reset`, and generated slices. */ -export function createSchema( +export function createSchema( slices?: O, options: CreateSchemaOptions = {}, ): FxSchema { @@ -231,7 +232,7 @@ export function createSchema( >, next: Next, ) { - const store: FxStore = yield* expectStore(); + const store = (yield* expectStore>()) as FxStore; const upds = ( Array.isArray(ctx.updater) ? ctx.updater : [ctx.updater] ) as StoreUpdater>[]; diff --git a/src/store/slice/index.ts b/src/store/slice/index.ts index 30b241c3..6562bc07 100644 --- a/src/store/slice/index.ts +++ b/src/store/slice/index.ts @@ -8,19 +8,16 @@ import { import { type NumOutput, num } from "./num.js"; import { type ObjOutput, obj } from "./obj.js"; import { type StrOutput, str } from "./str.js"; -import { type TableOutput, table } from "./table.js"; +import { type TableOutput, cache, table } from "./table.js"; export const slice = { str, num, table, + cache, any, obj, loaders, - /** - * @deprecated Use `slice.loaders` instead - */ - loader: loaders, }; export { defaultLoader, defaultLoaderItem }; export type { diff --git a/src/store/slice/table.ts b/src/store/slice/table.ts index f954dda1..9462f5ab 100644 --- a/src/store/slice/table.ts +++ b/src/store/slice/table.ts @@ -1,6 +1,6 @@ import type { Draft, Immutable } from "immer"; import { createSelector } from "reselect"; -import type { IdProp } from "../../types.js"; +import type { AnyState, IdProp } from "../../types.js"; import type { BaseSchema, SliceState } from "../types.js"; type TableData = Record; @@ -183,7 +183,7 @@ export function createTable({ } /** - * Public table slice API used in `createSchema` definitions. + * Built-in table slice API used in `createSchema` definitions. * * @remarks * The table slice mimics a normalized entity table with `id -> entity` storage. @@ -227,3 +227,20 @@ export function table( const { initialState, empty } = options; return (name: string) => createTable({ name, empty, initialState }); } + +/** + * Built-in cache slice API used by starfx query helpers. + * + * @remarks + * This is equivalent to `slice.table()`, but makes the built-in + * cache convention explicit and preserves the broad cache entity type expected + * by cache-aware helpers. + */ +export function cache( + options: { + initialState?: Record; + empty?: AnyState | (() => AnyState); + } = {}, +): (n: string) => TableOutput { + return table(options); +} diff --git a/src/store/store.ts b/src/store/store.ts index c15d28ba..61c06d1d 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -14,15 +14,21 @@ import { createRun } from "./run.js"; import { DEFAULT_SCHEMA_KEY } from "./types.js"; import type { AnyFxSchema, - FxMap, FxSchema, FxStore, Listener, MergeSchemaRegistryMaps, + SchemaMap, SliceFromSchema, StoreSchemaRegistry, StoreUpdater, } from "./types.js"; + +type StoreContextValue = ReturnType< + typeof StoreContext.expect +> extends Operation + ? T + : never; const stubMsg = "This is merely a stub, not implemented"; let id = 0; @@ -39,7 +45,7 @@ function observable() { }; } -export interface CreateStore { +export interface CreateStore { scope?: Scope; schema: FxSchema; /** @@ -99,7 +105,7 @@ export const ListenersContext = createContext>( * const store = createStore({ schema }); * ``` */ -export function createStore( +export function createStore( options: CreateStore, ): FxStore; export function createStore( @@ -109,9 +115,9 @@ export function createStore({ scope: initScope, schema: schemaInput, tasks = [], -}: CreateStore | CreateStoreMulti): FxStore< - FxMap, - StoreSchemaRegistry> +}: CreateStore | CreateStoreMulti): FxStore< + SchemaMap, + StoreSchemaRegistry> > { if (!schemaInput) { throw new Error("At least one schema must be provided."); @@ -121,11 +127,11 @@ export function createStore({ throw new Error("A schema registry must include `default`"); } - const schemasMap: StoreSchemaRegistry> = isSchemaRegistry( + const schemasMap: StoreSchemaRegistry> = isSchemaRegistry( schemaInput, ) - ? (schemaInput as StoreSchemaRegistry>) - : { [DEFAULT_SCHEMA_KEY]: schemaInput as FxSchema }; + ? (schemaInput as StoreSchemaRegistry>) + : { [DEFAULT_SCHEMA_KEY]: schemaInput as FxSchema }; const schemas = Object.values(schemasMap); const baseSchema = schemasMap[DEFAULT_SCHEMA_KEY]; @@ -144,7 +150,7 @@ export function createStore({ (acc, schema) => { return Object.assign(acc, schema.initialState); }, - {} as SliceFromSchema, + {} as SliceFromSchema, ); let state = initialState; @@ -156,10 +162,10 @@ export function createStore({ return state; } - function setState(upds: StoreUpdater>[]) { + function setState(upds: StoreUpdater>[]) { const nextState = produceWithPatches( state, - (draft: Draft>) => { + (draft: Draft>) => { upds.forEach((updater) => updater(draft)); }, ); @@ -183,7 +189,7 @@ export function createStore({ const run = createRun(scope); - const store: FxStore>> = { + const store: FxStore>> = { getScope, getState, setState, @@ -197,9 +203,9 @@ export function createStore({ // stubs so `react-redux` is happy replaceReducer( _nextReducer: ( - s: SliceFromSchema, + s: SliceFromSchema, a: AnyAction, - ) => SliceFromSchema, + ) => SliceFromSchema, ): void { throw new Error(stubMsg); }, @@ -207,7 +213,7 @@ export function createStore({ [Symbol.observable]: observable, }; - scope.set(StoreContext, store as FxStore); + scope.set(StoreContext, store as StoreContextValue); run(function* (): Operation { const schemaInit = schemas diff --git a/src/store/types.ts b/src/store/types.ts index 211c457b..594f8c0d 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -58,6 +58,21 @@ export type Output }> = { */ export type SliceState = Record>; +/** + * Neutral map of slice factories used to build schema state. + */ +export interface SchemaMap { + [key: string]: ((name: string) => BaseSchema) | undefined; +} + +/** + * Built-in slice factories used by starfx helpers. + */ +export interface BuiltinSchemaMap { + loaders?: (s: string) => LoaderOutput; + cache?: (s: string) => TableOutput; +} + /** * Map of slice factories used when creating a schema via createSchema. * @@ -65,12 +80,7 @@ export type SliceState = Record>; * Includes optional default `loaders` and `cache` slices while allowing * additional user-defined factories. */ -export interface FxMap { - // keep a typed shape for default slices while allowing user-defined slices - loaders?: (s: string) => LoaderOutput; - cache?: (s: string) => TableOutput; - [key: string]: ((name: string) => BaseSchema) | undefined; -} +export type FxMap = SchemaMap & BuiltinSchemaMap; // biome-ignore lint/suspicious/noExplicitAny: used only to represent an arbitrary schema instance in store composition helpers. export type AnyFxSchema = FxSchema; @@ -101,7 +111,7 @@ type Simplify = { [K in keyof T]: T[K] } & {}; export type MergeSchemaRegistryMaps = UnionToIntersection< SchemaMapOf - > extends infer O extends FxMap + > extends infer O extends SchemaMap ? Simplify : never; @@ -112,7 +122,7 @@ export type FactoryInitial = FactoryReturn< > extends BaseSchema ? IS : never; -export type SliceFromSchema = { +export type SliceFromSchema = { [K in keyof O]: FactoryInitial; }; @@ -128,7 +138,7 @@ type BuiltInSchemaUpdater = | SliceActionUpdater | SliceActionUpdater>; -export type SchemaUpdater = +export type SchemaUpdater = | StoreUpdater> | BuiltInSchemaUpdater | { @@ -141,7 +151,7 @@ export type SchemaUpdater = * @remarks * Extends generated helpers with schema lifecycle/update APIs. */ -export type FxSchema = { +export type FxSchema = { [K in keyof O]: FactoryReturn>; } & { initialize?: () => Operation; @@ -165,7 +175,7 @@ export type FxSchema = { * Compatible with react-redux store expectations for interop. */ export interface FxStore< - O extends FxMap, + O extends SchemaMap, TSchemas extends StoreSchemaRegistry = StoreSchemaRegistry>, > { getScope: () => Scope; diff --git a/src/test/create-store.test.ts b/src/test/create-store.test.ts index 8e99dfce..6c299258 100644 --- a/src/test/create-store.test.ts +++ b/src/test/create-store.test.ts @@ -1,6 +1,12 @@ import { expectTypeOf } from "vitest"; import { call } from "../index.js"; -import { createSchema, createStore, select, slice } from "../store/index.js"; +import { + createSchema, + createStore, + expectStore, + select, + slice, +} from "../store/index.js"; import { expect, test } from "../test.js"; interface TestState { @@ -127,3 +133,23 @@ test("should keep multi-schema typing for schema registry variables", () => { expectTypeOf(state.users).toEqualTypeOf(); expectTypeOf(state.metadata).toEqualTypeOf(); }); + +test("expectStore should preserve merged state for schema registries", () => { + const baseSchema = createSchema({ + users: slice.table<{ id: string; name: string }>(), + }); + const metadataSchema = createSchema({ + metadata: slice.obj>({}), + }); + const schema = { default: baseSchema, metadata: metadataSchema }; + + function* operation() { + const runtimeStore = yield* expectStore(); + const state = runtimeStore.getState(); + + expectTypeOf(state.users).toEqualTypeOf(); + expectTypeOf(state.metadata).toEqualTypeOf(); + } + + void operation; +}); diff --git a/src/test/react-types.test.ts b/src/test/react-types.test.ts index 6afb0eca..133fb6ab 100644 --- a/src/test/react-types.test.ts +++ b/src/test/react-types.test.ts @@ -1,5 +1,5 @@ -import { describe, expectTypeOf, test } from "vitest"; import type { ReactElement } from "react"; +import { describe, expectTypeOf, test } from "vitest"; import type { ThunkAction } from "../query/index.js"; import { Provider, @@ -54,7 +54,7 @@ describe("react hook types", () => { describe("global hooks without schema hints", () => { test("typed schema/store creation works with react-facing types", () => { const schema = createSchema({ - cache: slice.table(), + cache: slice.table(), loaders: slice.loaders(), metadata: slice.obj({}), users: slice.table(), @@ -67,7 +67,7 @@ describe("react hook types", () => { test("bare useSelector works with schema selectors", () => { const schema = createSchema({ - cache: slice.table(), + cache: slice.cache(), loaders: slice.loaders(), metadata: slice.obj({}), users: slice.table(), @@ -107,7 +107,7 @@ describe("react hook types", () => { describe("schema-bound typed hooks", () => { test("useSelector exposes schema state to userland", () => { const schema = createSchema({ - cache: slice.table(), + cache: slice.cache(), loaders: slice.loaders(), metadata: slice.obj({}), users: slice.table(), @@ -125,7 +125,7 @@ describe("react hook types", () => { test("createTypedHooks infers all hook state from schema input", () => { const schema = createSchema({ - cache: slice.table(), + cache: slice.cache(), loaders: slice.loaders(), metadata: slice.obj({}), users: slice.table(), From 338a588f67acddd6ca6db7c39ba25538a1842a4c Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Sun, 17 May 2026 00:30:26 -0500 Subject: [PATCH 35/35] docs update --- README.md | 5 ++--- docs/posts/dispatch.md | 6 ++---- docs/posts/endpoints.md | 11 +++++------ docs/posts/getting-started.md | 5 ++--- docs/posts/loaders.md | 4 ++-- docs/posts/schema.md | 6 +++--- docs/posts/store.md | 21 ++++++++------------- 7 files changed, 24 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 4f0e4442..c7189253 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,7 @@ Features: import { createApi, createSchema, createStore, mdw, timer } from "starfx"; import { Provider, useCache } from "starfx/react"; -const [schema, initialState] = createSchema(); -const store = createStore({ initialState }); +const schema = createSchema(); const api = createApi(); // mdw = middleware @@ -36,7 +35,7 @@ const fetchRepo = api.get( api.cache() ); -store.run(api.register); +const store = createStore({ schema, tasks: [api.register] }); function App() { return ( diff --git a/docs/posts/dispatch.md b/docs/posts/dispatch.md index b091bb51..10d9d59a 100644 --- a/docs/posts/dispatch.md +++ b/docs/posts/dispatch.md @@ -16,12 +16,10 @@ The type signature of `dispatch`: type Dispatch = (a: Action | Action[]) => any; ``` -Within `starfx`, the `dispatch` function lives on the store. - ```ts const { createSchema, createStore } from "starfx"; -const [schema, initialState] = createSchema(); -const store = createStore({ initialState }); +const schema = createSchema(); +const store = createStore({ schema }); store.dispatch({ type: "action", payload: {} }); ``` diff --git a/docs/posts/endpoints.md b/docs/posts/endpoints.md index 51912dac..2e26702b 100644 --- a/docs/posts/endpoints.md +++ b/docs/posts/endpoints.md @@ -3,13 +3,13 @@ title: Endpoints description: endpoints are tasks for managing HTTP requests --- -An endpoint is just a specialized thunk designed to manage http requests. It has +An endpoint is a specialized thunk designed to manage http requests. It has a supervisor, it has a middleware stack, and it hijacks the unique id for our thunks and turns it into a router. ```ts import { createApi, createStore, mdw } from "starfx"; -import { initialState, schema } from "./schema"; +import { schema } from "./schema"; const api = createApi(); // composition of handy middleware for createApi to function @@ -32,8 +32,7 @@ export const updateUser = api.post<{ id: string; name: string }>( }, ); -const store = createStore(initialState); -store.run(api.register); +const store = createStore({ schema, tasks: [api.register] }); store.dispatch(fetchUsers()); // now accessible with useCache(fetchUsers) @@ -331,7 +330,7 @@ function deserializeComment(com: any): Comment { }; } -const [schema, initialState] = createSchema({ +const schema = createSchema({ cache: slice.cache(), loaders: slice.loaders(), token: slice.str(), @@ -339,7 +338,7 @@ const [schema, initialState] = createSchema({ people: slice.table(), comments: slice.table(), }); -type WebState = typeof initialState; +type WebState = typeof schema.initialState; const api = createApi(); api.use(mdw.api({ schema })); diff --git a/docs/posts/getting-started.md b/docs/posts/getting-started.md index e949fb4f..80d396d7 100644 --- a/docs/posts/getting-started.md +++ b/docs/posts/getting-started.md @@ -86,8 +86,7 @@ every **5 minutes**, mimicking the basic features of `react-query`. import { createApi, createSchema, createStore, mdw, timer } from "starfx"; import { Provider, useCache } from "starfx/react"; -const [schema, initialState] = createSchema(); -const store = createStore({ initialState }); +const schema = createSchema(); const api = createApi(); // mdw = middleware @@ -102,7 +101,7 @@ const fetchRepo = api.get( api.cache(), ); -store.run(api.register); +const store = createStore({ schema, tasks: [api.register] }); function App() { return ( diff --git a/docs/posts/loaders.md b/docs/posts/loaders.md index a464470d..cdef358f 100644 --- a/docs/posts/loaders.md +++ b/docs/posts/loaders.md @@ -35,7 +35,7 @@ For thunks you can use `mdw.loader()` which will track the status of a thunk. ```ts import { createThunks, mdw } from "starfx"; // imaginary schema -import { initialState, schema } from "./schema"; +import { schema } from "./schema"; const thunks = createThunks(); thunks.use(mdw.loader(schema)); @@ -45,7 +45,7 @@ const go = thunks.create("go", function* (ctx, next) { throw new Error("boom!"); }); -const store = createStore({ initialState }); +const store = createStore({ schema }); store.dispatch(go()); schema.loaders.selectById(store.getState(), { id: `${go}` }); // status = "error"; message = "boom!" diff --git a/docs/posts/schema.md b/docs/posts/schema.md index aa061ffd..c5023ea2 100644 --- a/docs/posts/schema.md +++ b/docs/posts/schema.md @@ -31,7 +31,7 @@ a place for `starfx` and third-party functionality to hold their state. ```ts import { createSchema, slice } from "starfx"; -const [schema, initialState] = createSchema({ +const schema = createSchema({ cache: slice.cache(), loaders: slice.loaders(), }); @@ -249,10 +249,10 @@ export function counter(initialState?: number) { return (name: string) => createCounter({ name, initialState }); } -const [schema, initialState] = createSchema({ +const schema = createSchema({ counter: counter(100), }); -const store = createStore(initialState); +const store = createStore({ schema }); store.run(function* () { yield* schema.update([ diff --git a/docs/posts/store.md b/docs/posts/store.md index 0143f5a8..6f207aec 100644 --- a/docs/posts/store.md +++ b/docs/posts/store.md @@ -55,12 +55,12 @@ interface User { } // app-wide database for ui, api data, or anything that needs reactivity -const [schema, initialState] = createSchema({ +const schema = createSchema({ cache: slice.cache(), loaders: slice.loaders(), users: slice.table(), }); -type WebState = typeof initialState; +type WebState = typeof schema.initialState; // just a normal endpoint const fetchUsers = api.get( @@ -93,15 +93,14 @@ const fetchUsers = api.get( }, ); -const store = createStore(schema); -store.run(api.register); +const store = createStore({ schema, tasks: [api.register] }); store.dispatch(fetchUsers()); ``` # How to update state -There are **three** ways to update state, each with varying degrees of type -safety: +There are **two** common ways to update state, each with varying degrees of +type safety: ```ts import { updateStore } from "starfx"; @@ -112,16 +111,12 @@ function*() { // no types yield* updateStore([/* ... */]); } - -store.run(function*() { - // no types - yield* store.update([/* ... */]); -}); ``` `schema.update` has the highest type safety because it knows your state shape. -The other methods are more generic and the user will have to provide types to -them manually. +`updateStore` is more generic and the user will have to provide types to it +manually. For lower-level integration, the store also exposes `setState([...])` +directly. # Updater function