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/.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/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/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/basic/src/main.tsx b/examples/basic/src/main.tsx index a76c9041..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, initialState] = createSchema(); -const store = createStore({ initialState }); - 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()); @@ -14,17 +24,11 @@ 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 ( - - - - ); + return ; } function Example() { @@ -47,7 +51,7 @@ function Example() { const root = document.getElementById("root") as HTMLElement; ReactDOM.createRoot(root).render( - + - , + ); 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/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 23e39d19..76377afc 100644 --- a/examples/tests-rtl/src/api.ts +++ b/examples/tests-rtl/src/api.ts @@ -1,7 +1,9 @@ import { createApi, createSchema, mdw, slice } from "starfx"; +import { createTypedHooks } from "starfx/react"; const emptyUser = { id: "", name: "" }; -export const [schema, initialState] = createSchema({ +type User = typeof emptyUser; +export const schema = createSchema({ users: slice.table({ empty: emptyUser }), cache: slice.table(), loaders: slice.loaders(), @@ -12,20 +14,22 @@ 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) => { + const users = (ctx.json.value as User[]).reduce>( + (acc, user) => { acc[user.id] = user; return acc; - }, {}); + }, + {}, + ); - yield* schema.update(schema.users.add(users)); - }, -); + 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 4c30ef6c..f241679f 100644 --- a/examples/tests-rtl/src/store.ts +++ b/examples/tests-rtl/src/store.ts @@ -1,15 +1,12 @@ -import { api, initialState as schemaInitialState } from "./api"; +import { api, schema } from "./api"; import { createStore } from "starfx"; -export function setupStore({ initialState = {} }) { +export function setupStore({ initialState = {} } = {}) { + void initialState; const store = createStore({ - initialState: { - ...schemaInitialState, - ...initialState, - }, + 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..7fffd977 100644 --- a/examples/tests-rtl/tests/app.test.tsx +++ b/examples/tests-rtl/tests/app.test.tsx @@ -1,6 +1,4 @@ -import React from "react"; -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/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/tests-rtl/tsconfig.json b/examples/tests-rtl/tsconfig.json index bc68887f..4ec4bea1 100644 --- a/examples/tests-rtl/tsconfig.json +++ b/examples/tests-rtl/tsconfig.json @@ -2,14 +2,14 @@ "$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, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, - "forceConsistentCasingInFileNames": true, "target": "esnext", "module": "esnext", "moduleResolution": "bundler", @@ -17,7 +17,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"] } diff --git a/examples/tests-rtl/vitest.config.ts b/examples/tests-rtl/vitest.config.ts new file mode 100644 index 00000000..718f2b6a --- /dev/null +++ b/examples/tests-rtl/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + 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/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/age-guess.ts b/examples/vite-react/src/age-guess.ts index 5124c24e..78b5f8a6 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 { registerResource } 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 = registerResource("global-guesser", guessAge()); diff --git a/examples/vite-react/src/api.ts b/examples/vite-react/src/api.ts index c2aed6cf..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; @@ -8,12 +9,11 @@ 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 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 96f60a66..248cefc6 100644 --- a/examples/vite-react/src/main.tsx +++ b/examples/vite-react/src/main.tsx @@ -2,32 +2,30 @@ 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"; +import { GlobalGuesser } from "./age-guess.ts"; init(); function init() { - const store = createStore({ initialState }); + const store = createStore({ schema, tasks: [logger, api.register, GlobalGuesser.initialize] }); // 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( - + - , + ); } + +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/.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..854352f2 --- /dev/null +++ b/examples/yjs/src/App.tsx @@ -0,0 +1,31 @@ +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 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, make a folder perhaps?
+ +
folders: {items.length}
+ {/* {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..659bc174 --- /dev/null +++ b/examples/yjs/src/main.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { Provider } from "starfx/react"; +import { createStore, take } from "starfx"; +import { thunks, schema } from "./thunks.js"; +import App from "./App.js"; +import "./index.css"; + +init(); + +function init() { + const store = createStore({ + schema, + tasks: [logger, thunks.register], + }); + // makes `fx` available in devtools + (window as any).fx = store; + + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + + + + ); +} + +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 new file mode 100644 index 00000000..01fe2a10 --- /dev/null +++ b/examples/yjs/src/store/schema.ts @@ -0,0 +1,139 @@ +import { + baseMiddlewares, + compose, + type FxSchema, + type FxStore, + type Next, + type SchemaMap, + type SchemaUpdater, + type StoreUpdater, + type UpdaterCtx, + createSchemaWithUpdater, + expectStore, + type SliceFromSchema, + createSignal, + each, +} from "starfx"; +import type { Draft } from "immer"; +import * as Y from "yjs"; + +function createSnapshotUpdater( + snapshot: SliceFromSchema, +): 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); + }; +} + +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); +} + +/** + * 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 { + 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()); + + return createSchemaWithUpdater(slices, { + *initialize() { + const store = (yield* expectStore>()) as FxStore; + const observation = createSignal<{ + events: Y.YEvent>[]; + transaction: Y.Transaction; + }>(); + + root.observeDeep( + ( + 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 }); + } + }, + ); + + yield* reconcileSnapshot(store, root.toJSON() as SliceFromSchema); + + for (const { events, transaction } of yield* each(observation)) { + console.log( + "Y.Doc changed, updating schema state", + events, + transaction, + ); + yield* reconcileSnapshot(store, root.toJSON() as SliceFromSchema); + } + }, + *updateMdw(ctx, next) { + const store = (yield* expectStore>()) as FxStore; + const updaters = Array.isArray(ctx.updater) ? ctx.updater : [ctx.updater]; + + ydoc.transact(() => { + for (const updater of updaters) { + (updater as (root: Y.Map) => void)(root); + } + }); + + store.setState([ + createSnapshotUpdater(root.toJSON() as SliceFromSchema), + ]); + + yield* next(); + }, + }); +} + +export { createYjsSchema as createSchema }; diff --git a/examples/yjs/src/thunks.ts b/examples/yjs/src/thunks.ts new file mode 100644 index 00000000..d8cdf9b6 --- /dev/null +++ b/examples/yjs/src/thunks.ts @@ -0,0 +1,47 @@ +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; + +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(); +}); + +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: YjsRoot) => { + const yarray = root.get("data").get("items"); + const dt = Date.now().toString(); + yarray.push([ + { + id: dt, + name: "New folder from " + dt, + children: [], + }, + ]); + }) as never); + + yield* next(); +}); + +export const { useSelector } = createTypedHooks(schema); 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..4608b94f --- /dev/null +++ b/examples/yjs/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "forceConsistentCasingInFileNames": true, + "ignoreDeprecations": "5.0", + "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", + "paths": { + "starfx": ["../../src/index.ts"], + "starfx/react": ["../../src/react.ts"] + }, + + /* 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()], +}); diff --git a/package.json b/package.json index 52372d36..e235bc07 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,10 @@ "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", "lint": "biome check --write", "fmt": "biome check --write --linter-enabled=false", "ci": "biome ci .", @@ -86,6 +90,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/action.ts b/src/action.ts index 90ac6835..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,8 +13,13 @@ 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. @@ -59,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; }, }; @@ -235,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) { @@ -320,7 +328,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/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"; 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/fx/supervisor.ts b/src/fx/supervisor.ts index 9a046ce3..3cab3151 100644 --- a/src/fx/supervisor.ts +++ b/src/fx/supervisor.ts @@ -88,7 +88,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/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 e9a2dcc5..58f077d1 100644 --- a/src/mdw/store.ts +++ b/src/mdw/store.ts @@ -6,17 +6,17 @@ import { select, updateStore, } from "../store/index.js"; -import type { AnyState, Next } from "../types.js"; +import type { 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, + CacheEntity = unknown, > { schema: { - loaders: LoaderOutput; - cache: TableOutput; + loaders: LoaderOutput; + cache: TableOutput; }; errorFn?: (ctx: Ctx) => string; } @@ -62,8 +62,8 @@ 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, @@ -106,14 +106,18 @@ export function api( * }); * ``` */ -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 }); 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 { @@ -158,9 +162,7 @@ export function cache(schema: { * }); * ``` */ -export function loader(schema: { - loaders: LoaderOutput; -}) { +export function loader(schema: { loaders: LoaderOutput }) { return function* ( ctx: Ctx, next: Next, @@ -170,7 +172,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(); @@ -202,8 +204,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") @@ -247,17 +250,17 @@ function defaultErrorFn(ctx: Ctx) { * @param props.errorFn - Custom function to extract error message from context. * @returns A loader tracking middleware function. */ -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(); @@ -305,8 +308,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/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/query/types.ts b/src/query/types.ts index 05169a73..8c9ebc51 100644 --- a/src/query/types.ts +++ b/src/query/types.ts @@ -34,7 +34,7 @@ export interface ThunkCtx

extends Payload

{ * Thunk context that may be associated with a loader. */ export interface ThunkCtxWLoader extends ThunkCtx { - loader: Omit, "id"> | null; + loader: Omit | null; } /** @@ -84,7 +84,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 848b9a7a..676b97b6 100644 --- a/src/react.ts +++ b/src/react.ts @@ -1,21 +1,28 @@ +import type { Operation } from "effection"; 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"; import { + type AnyFxSchema, + 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"; +import type { SchemaMap, 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 { @@ -26,7 +33,39 @@ const { createElement: h, } = React; -export interface UseApiProps

extends LoaderState { +type WithLoadersMap = SchemaMap & { loaders: (n: string) => LoaderOutput }; +type WithCacheMap = SchemaMap & { 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

; } @@ -44,12 +83,79 @@ export type UseApiResult = | UseApiSimpleProps | UseApiAction; -export interface UseCacheResult +export interface UseCacheResult extends UseApiAction { data: D | null; } -const SchemaContext = createContext | null>(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; + +type StoreContextValue = ReturnType< + typeof import("./store/context.js").StoreContext.expect +> extends Operation + ? T + : never; + +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. @@ -64,7 +170,7 @@ const SchemaContext = createContext | null>(null); * * @param props - Provider props. * @param props.store - The {@link FxStore} instance created by {@link createStore}. - * @param props.schema - The schema created by {@link createSchema}. + * @param props.schema - Optional schema created by {@link createSchema}; defaults to `store.schema`. * @param props.children - React children to render. * * @see {@link createStore} for creating the store. @@ -84,27 +190,57 @@ const SchemaContext = createContext | null>(null); * } * ``` */ -export function Provider({ - store, - schema, - children, -}: { - store: FxStore; - schema: FxSchema; - children: React.ReactNode; -}) { - return h(ReduxProvider, { - store, - children: h(SchemaContext.Provider, { value: schema, children }) as any, - }); +export function Provider< + O extends SchemaMap, + TSchemas extends StoreSchemaRegistry = StoreSchemaRegistry>, +>(props: { + store: FxStore; + schema?: TSchemas[DefaultSchemaKey]; + children?: React.ReactNode; +}): React.ReactElement; + +export function Provider(props: { + store: unknown; + schema?: unknown; + children?: React.ReactNode; +}): React.ReactElement { + 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 { + const ctx = useContext(SchemaContext); + if (!ctx) throw new Error("No Schema available in context"); + return ctx as FxSchema; } -export function useSchema() { - return useContext(SchemaContext) as FxSchema; +// Typed variant for schemas that include `loaders`. +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; } -export function useStore() { - return useReduxStore() as FxStore; +// Typed variant for schemas that include `cache`. +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; +} + +export function useStore() { + return useReduxStore() as FxStore; } /** @@ -112,7 +248,7 @@ export function useStore() { * * @remarks * Loaders track the lifecycle of thunks and endpoints (idle, loading, success, error). - * This hook subscribes to the loader state and triggers re-renders when it changes. + * This hook subscribes to loader state and triggers re-renders when it changes. * * The returned {@link LoaderState} includes convenience booleans: * - `isIdle` - Initial state, never run @@ -121,17 +257,15 @@ export function useStore() { * - `isError` - Completed with an error * - `isInitialLoading` - Loading AND never succeeded before * - * @typeParam S - The state shape (inferred from schema). + * @typeParam O - Schema map constrained to include `loaders`. * @param action - The action creator or dispatched action to track. - * @returns The {@link LoaderState} for the action. + * @returns The loader state for the action. * * @see {@link useApi} for combining loader with trigger function. * @see {@link useQuery} for auto-triggering on mount. * - * @example With action creator + * @example * ```tsx - * import { useLoader } from 'starfx/react'; - * * function UserStatus() { * const loader = useLoader(fetchUsers()); * @@ -140,21 +274,17 @@ export function useStore() { * return

; * } * ``` - * - * @example With dispatched action (tracks specific call) - * ```tsx - * function UserDetail({ id }: { id: string }) { - * const loader = useLoader(fetchUser({ id })); - * // Tracks this specific fetchUser call by its payload - * } - * ``` */ -export function useLoader( - action: ThunkAction | ActionFnWithPayload, -) { - const schema = useSchema(); +export function useLoader< + O extends WithLoadersMap = WithLoadersMap, + A extends LoaderActionInput = LoaderActionInput, +>(action: A): LoaderState { + const schema = useSchemaWithLoaders(); const id = getIdFromAction(action); - return useSelector((s: S) => schema.loaders.selectById(s, { id })); + type LoaderSelectState = Parameters[0]; + return useSelector((s) => + schema.loaders.selectById(s as unknown as LoaderSelectState, { id }), + ); } /** @@ -162,121 +292,62 @@ export function useLoader( * * @remarks * Combines {@link useLoader} with a `trigger` function for dispatching the action. - * Does NOT automatically fetch data - use `trigger()` to initiate the request. + * Does not automatically fetch data - use `trigger()` to initiate execution. * - * For automatic fetching on mount, use {@link useQuery} instead. + * For automatic fetching on mount, use {@link useQuery}. * - * @typeParam P - The payload type for the action. - * @typeParam A - The action type. + * @typeParam P - Payload type for action creators. + * @typeParam A - Thunk/action type. * @param action - The action creator or dispatched action. - * @returns An object with loader state and `trigger` function. + * @returns An object with loader state and trigger function. * * @see {@link useQuery} for auto-triggering on mount. * @see {@link useCache} for auto-triggering with cached data. - * @see {@link useLoaderSuccess} for success callbacks. * - * @example Manual trigger + * @example * ```tsx - * import { useApi } from 'starfx/react'; - * * function CreateUserForm() { * const { isLoading, trigger } = useApi(createUser); * - * const handleSubmit = (data: FormData) => { - * trigger({ name: data.get('name') }); + * const handleSubmit = (name: string) => { + * trigger({ name }); * }; * - * return ( - *
- * - * - *
- * ); - * } - * ``` - * - * @example Fetch on mount with useEffect - * ```tsx - * function UsersList() { - * const { isLoading, trigger } = useApi(fetchUsers); - * - * useEffect(() => { - * trigger(); - * }, []); - * - * return isLoading ? : ; + * return ; * } * ``` */ -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< + O extends WithLoadersMap = WithLoadersMap, + A extends ApiActionInput = ApiActionInput, +>(action: A): UseApiReturn { const dispatch = useDispatch(); - const loader = useLoader(action); - const trigger = (p: any) => { + const loader = useLoader(action); + 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; } /** - * Auto-triggering version of {@link useApi}. + * Auto-triggering variant of {@link useApi}. * * @remarks - * Automatically dispatches the action on mount and when the action's `key` changes. - * This is useful for "fetch on render" patterns. - * - * The action is re-triggered when `action.payload.key` changes, which is a hash - * of the action name and payload. This means changing the payload (e.g., a user ID) - * will trigger a new fetch. + * Automatically dispatches the action on mount and when `action.payload.key` + * changes. Useful for fetch-on-render patterns. * - * @typeParam P - The payload type for the action. - * @typeParam A - The action type. * @param action - The dispatched action to execute. - * @returns An object with loader state and `trigger` function. - * - * @see {@link useApi} for manual triggering. - * @see {@link useCache} for auto-triggering with cached data. - * - * @example Basic usage - * ```tsx - * import { useQuery } from 'starfx/react'; - * - * function UsersList() { - * const { isLoading, isError, message } = useQuery(fetchUsers); - * - * if (isLoading) return ; - * if (isError) return ; - * return ; - * } - * ``` - * - * @example With parameters (re-fetches on change) - * ```tsx - * function UserDetail({ userId }: { userId: string }) { - * // Re-fetches when userId changes - * const { isLoading } = useQuery(fetchUser({ id: userId })); - * // ... - * } - * ``` + * @returns Loader state and trigger function. */ -export function useQuery

>( - action: A, -): UseApiAction { - const api = useApi(action); +export function useQuery< + O extends WithLoadersMap = WithLoadersMap, + A extends ThunkAction = ThunkAction, +>(action: A): UseApiAction { + const api = useApi(action) as UseApiAction; useEffect(() => { api.trigger(); }, [action.payload.key]); @@ -288,116 +359,55 @@ export function useQuery

>( * * @remarks * Combines {@link useQuery} with automatic selection of cached response data. - * The endpoint must use `api.cache()` middleware to populate the cache. - * - * This is the most convenient hook for "fetch and display" patterns where - * you want the raw API response data. - * - * @typeParam P - The payload type for the action. - * @typeParam ApiSuccess - The expected success response type. - * @param action - The dispatched action with cache enabled. - * @returns An object with loader state, `trigger` function, and `data`. + * The endpoint must use cache middleware to populate the cache table. * - * @see {@link useQuery} for queries without cache selection. - * @see {@link useApi} for manual triggering. + * @param action - Dispatched action with cache enabled. + * @returns Loader state, trigger function, and selected cache data. * - * @example Basic usage - * ```tsx - * import { useCache } from 'starfx/react'; - * - * // Endpoint with caching enabled - * const fetchUsers = api.get('/users', api.cache()); - * - * function UsersList() { - * const { isLoading, data } = useCache(fetchUsers()); - * - * if (isLoading && !data) return ; - * - * return ( - *

- * ); - * } - * ``` - * - * @example With typed response - * ```tsx - * interface User { - * id: string; - * name: string; - * email: string; - * } - * - * const fetchUser = api.get<{ id: string }, User>('/users/:id', api.cache()); - * - * function UserProfile({ id }: { id: string }) { - * const { data, isError, message } = useCache(fetchUser({ id })); - * // data is typed as User | null - * } + * @example + * ```ts + * const { isLoading, data } = useCache(fetchUsers()); + * if (isLoading && !data) return ; + * return ; * ``` */ -export function useCache

( +export function useCache< + O extends WithCacheMap = WithCacheMap, + P = unknown, + ApiSuccess = unknown, +>( action: ThunkAction, -): UseCacheResult> { - const schema = useSchema(); +): UseCacheResult> { + const schema = useSchemaWithCache(); const id = action.payload.key; - const data: any = useSelector((s: any) => schema.cache.selectById(s, { id })); + type CacheSelectState = Parameters[0]; + const data = useSelector((s) => + schema.cache.selectById(s as unknown as CacheSelectState, { id }), + ); const query = useQuery(action); - return { ...query, data: data || null }; + return { ...query, data: (data as ApiSuccess | undefined) ?? null }; } /** - * Execute a callback when a loader transitions to success state. + * Execute a callback when a loader transitions to success. * * @remarks - * Watches the loader's status and fires the callback when it changes from - * any non-success state to "success". Useful for side effects like navigation, - * showing toasts, or resetting forms after successful operations. + * Watches the loader status and fires the callback when it changes from any + * non-success state to `success`. * - * @param cur - The loader state to watch (from {@link useLoader} or {@link useApi}). + * @param cur - Loader state to watch. * @param success - Callback to execute on success transition. * - * @example Navigate after form submission - * ```tsx - * import { useApi, useLoaderSuccess } from 'starfx/react'; - * import { useNavigate } from 'react-router-dom'; - * - * function CreateUserForm() { - * const navigate = useNavigate(); - * const { trigger, ...loader } = useApi(createUser); - * - * useLoaderSuccess(loader, () => { - * // Navigate to user list after successful creation - * navigate('/users'); - * }); - * - * const handleSubmit = (data: FormData) => { - * trigger({ name: data.get('name') }); - * }; - * - * return

...
; - * } - * ``` - * - * @example Show success toast - * ```tsx - * function DeleteButton({ id }: { id: string }) { - * const { trigger, ...loader } = useApi(deleteUser); - * - * useLoaderSuccess(loader, () => { - * toast.success('User deleted successfully'); - * }); - * - * return ; - * } + * @example + * ```ts + * useLoaderSuccess(loader, () => { + * navigate('/users'); + * }); * ``` */ export function useLoaderSuccess( cur: Pick, - success: () => any, + success: () => void, ) { const prev = useRef(cur); useEffect(() => { @@ -421,59 +431,19 @@ function Loading({ text }: { text: string }) { * Delay rendering until persistence rehydration completes. * * @remarks - * When using state persistence, the store needs to be rehydrated from storage - * before rendering the app. This component shows a loading state until the - * persistence loader (identified by `PERSIST_LOADER_ID`) reaches success. - * - * If rehydration fails, the error message is displayed. - * - * @param props - Component props. - * @param props.children - Elements to render after successful rehydration. - * @param props.loading - Optional element to show while rehydrating (default: "Loading"). - * - * @see {@link createPersistor} for setting up persistence. - * @see {@link PERSIST_LOADER_ID} for the internal loader ID. - * - * @example Basic usage - * ```tsx - * import { PersistGate, Provider } from 'starfx/react'; - * - * function App() { - * return ( - * - * }> - * - * - * - * - * - * ); - * } - * ``` - * - * @example Custom loading component - * ```tsx - * function CustomLoader() { - * return ( - *
- * - *

Restoring your session...

- *
- * ); - * } - * - * }> - * - * - * ``` + * Displays a loading view until the loader identified by `PERSIST_LOADER_ID` + * reaches success. */ export function PersistGate({ children, loading = h(Loading), }: PersistGateProps) { - const schema = useSchema(); - const ldr = useSelector((s: any) => - schema.loaders.selectById(s, { id: PERSIST_LOADER_ID }), + const schema = useSchemaWithLoaders(); + type LoaderSelectState = Parameters[0]; + const ldr = useSelector((s) => + schema.loaders.selectById(s as unknown as LoaderSelectState, { + id: PERSIST_LOADER_ID, + }), ); if (ldr.status === "error") { diff --git a/src/store/context.ts b/src/store/context.ts index 1267fdd7..4a39dc9e 100644 --- a/src/store/context.ts +++ b/src/store/context.ts @@ -1,11 +1,44 @@ -import { type Channel, createChannel, createContext } from "effection"; -import type { AnyState } from "../types.js"; -import type { FxStore } 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. * - * Consumers may `StoreUpdateContext.expect()` this context to access store lifecycle notifications through the channel. + * Consumers may `StoreUpdateContext.expect()` this context to access + * store lifecycle notifications through the channel. */ export const StoreUpdateContext = createContext>( "starfx:store:update", @@ -15,6 +48,45 @@ export const StoreUpdateContext = createContext>( /** * Context that holds the active `FxStore` for the current scope. * - * Use `StoreContext.expect()` within operations to access the store instance. + * Use `expectStore()` within operations to access the store instance. + */ +export const StoreContext = createContext("starfx:store"); + +/** + * 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 const StoreContext = createContext>("starfx:store"); +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 469b73b3..1d5b1126 100644 --- a/src/store/fx.ts +++ b/src/store/fx.ts @@ -2,152 +2,53 @@ 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 { StoreContext } from "./context.js"; +import type { + ActionFn, + ActionFnWithPayload, + AnyState, + LoaderState, +} from "../types.js"; +import { expectStore } from "./context.js"; import type { LoaderOutput } from "./slice/loaders.js"; -import type { FxStore, StoreUpdater, UpdaterCtx } from "./types.js"; +import type { + FxSchema, + SchemaMap, + SliceFromSchema, + StoreUpdater, + UpdaterCtx, +} from "./types.js"; /** - * Apply a store updater within the current store context. - * - * @remarks - * This is one of three ways to update state in starfx. The recommended approach - * is to use `schema.update()` which provides full type safety. This function is - * more generic and requires manual type annotation. - * - * Updater functions receive an `immer` draft and can mutate it directly. - * Any mutations are captured and applied immutably to the real state. - * - * @typeParam S - Root state shape. - * @param updater - Updater function or array of updaters to apply. - * @returns The update context produced by the store. - * - * @see {@link https://immerjs.github.io/immer/update-patterns | Immer update patterns} - * - * @example Basic counter increment - * ```ts - * function* inc() { - * yield* updateStore((state) => { - * state.counter += 1; - * }); - * } - * ``` - * - * @example Using schema updater helpers - * ```ts - * function* addUser(user: User) { - * yield* updateStore(schema.users.add({ [user.id]: user })); - * } - * ``` - * - * @example Batch multiple updates - * ```ts - * yield* updateStore([ - * schema.users.add({ [user.id]: user }), - * schema.loaders.success({ id: 'fetch-user' }), - * ]); - * ``` + * Updates the store using the default schema's update method. + * For additional schemas, use `store.schemas[key].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); - return ctx; + const store = yield* expectStore>(); + const ctx = yield* store.schema.update( + updater as + | StoreUpdater> + | StoreUpdater>[], + ); + return ctx as UpdaterCtx; } -/** - * Evaluate a selector against the current store state. - * - * @remarks - * Selectors are functions that derive data from the store state. They encapsulate - * logic for looking up specific values and can be memoized using `createSelector` - * from reselect (re-exported by starfx). - * - * This is an Operation that must be yielded inside an Effection scope (typically a thunk/api). - * - * @typeParam S - The state shape. - * @typeParam R - The return type of the selector. - * @typeParam P - Optional parameter type for parameterized selectors. - * @param selectorFn - Selector function to evaluate. - * @param p - Optional parameter passed to the selector. - * @returns The result of calling the selector with current state. - * - * @see {@link createSelector} for memoized selectors. - * - * @example Basic selector usage - * ```ts - * // return an array of users - * const users = yield* select(schema.users.selectTableAsList); - * ``` - * - * @example Parameterized selector - * ```ts - * // return a single user by id - * const user = yield* select(schema.users.selectById, { id: '1' }); - * ``` - * - * @example With custom selector - * ```ts - * const selectActiveUsers = createSelector( - * schema.users.selectTableAsList, - * (users) => users.filter(u => u.isActive) - * ); - * const activeUsers = yield* select(selectActiveUsers); - * ``` - */ -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); } -/** - * Wait for a loader associated with `action` to enter a terminal state - * (`success` or `error`). - * - * @remarks - * Loaders are "status trackers" that monitor the lifecycle of thunks and - * endpoints. They track loading, success, and error states along with - * timestamps and optional metadata. - * - * This function polls the loader state on every action until it reaches - * a terminal state (success or error). - * - * @typeParam M - The loader metadata shape. - * @param loaders - The loader slice instance from your schema. - * @param action - The action or action-creator which identifies the loader. - * @returns The final {@link LoaderState} with helper booleans (`isSuccess`, `isError`, etc.). - * - * @see {@link waitForLoaders} for waiting on multiple loaders. - * @see {@link LoaderState} for the shape of the returned state. - * - * @example - * ```ts - * // wait until the loader for `fetchUsers()` completes - * const loader = yield* waitForLoader(schema.loaders, fetchUsers()); - * if (loader.isSuccess) { - * console.log('Users fetched successfully'); - * } else if (loader.isError) { - * console.error('Failed:', loader.message); - * } - * ``` - */ -export function* waitForLoader( - loaders: LoaderOutput, - action: ThunkAction | ActionFnWithPayload, -): Operation> { +export function* waitForLoader( + loaders: LoaderOutput, + action: ThunkAction | ActionFn | 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); @@ -164,46 +65,16 @@ export function* waitForLoader( } } -/** - * Wait for multiple loaders associated with `actions` to reach a terminal state. - * - * @example - * ```ts - * const results = yield* waitForLoaders(schema.loaders, [fetchUser(), fetchPosts()]); - * for (const res of results) { - * if (res.ok) { - * // res.value is a LoaderState - * } - * } - * ``` - */ -export function* waitForLoaders( - loaders: LoaderOutput, - actions: (ThunkAction | ActionFnWithPayload)[], -): Operation>[]> { +export function* waitForLoaders( + loaders: LoaderOutput, + actions: (ThunkAction | ActionFn | ActionFnWithPayload)[], +): Operation[]> { const ops = actions.map((action) => () => waitForLoader(loaders, action)); - const group = yield* parallel>(ops); + const group = yield* parallel(ops); return yield* group; } -/** - * Produce a helper that wraps an operation with loader start/success/error updates. - * - * @param loader - Loader slice instance used to mark start/success/error. - * - * @example - * ```ts - * const track = createTracker(schema.loaders); - * const trackedOp = track('my-id')(function* () { - * return yield* safe(() => someAsyncOp()); - * }); - * const result = yield* trackedOp; - * if (result.ok) { // result.value is the operation Result } - * ``` - */ -export function createTracker>( - loader: LoaderOutput, -) { +export function createTracker(loader: LoaderOutput) { return (id: string) => { return function* ( op: () => Operation>, 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/persist.ts b/src/store/persist.ts index a5126fc6..365d13ef 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"; @@ -149,10 +150,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/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/schema.ts b/src/store/schema.ts index 75ba7a99..0cf22233 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -1,98 +1,246 @@ -import { updateStore } from "./fx.js"; +import { type Operation, lift } from "effection"; +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"; +import { StoreUpdateContext, expectStore } from "./context.js"; import { slice } from "./slice/index.js"; -import type { FxMap, FxSchema, StoreUpdater } from "./types.js"; +import { ListenersContext } from "./store.js"; +import type { + FactoryInitial, + FactoryReturn, + FxMap, + FxSchema, + FxStore, + SchemaMap, + SchemaUpdater, + SliceFromSchema, + StoreUpdater, + UpdaterCtx, +} from "./types.js"; const defaultSchema = (): O => - ({ cache: slice.table(), loaders: slice.loaders() }) as O; + ({ cache: slice.cache(), loaders: slice.loaders() }) as O; /** - * Creates a schema object and initial state from slice factories. - * - * @remarks - * A schema defines the shape of your application state and provides reusable - * state management utilities. It is composed of "slices" that each represent - * a piece of state with associated update and query helpers. - * - * By default, `createSchema` requires `cache` and `loaders` slices which are - * used internally by starfx middleware and supervisors. These slices enable - * powerful features like automatic request caching and loader state tracking. - * - * Returns a tuple of `[schema, initialState]` where: - * - `schema` contains all slice helpers/selectors plus an `update()` method - * - `initialState` is the combined initial state from all slices - * - * @typeParam O - Map of slice factory functions. - * @typeParam S - Inferred state shape from the slices. - * @param slices - A map of slice factory functions. Defaults to a schema - * containing `cache` and `loaders` slices. - * @returns A tuple of `[schema, initialState]`. - * - * @see {@link slice} for available slice types. - * @see {@link https://zod.dev | Zod} for the inspiration behind this API. + * 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: { [K in keyof O]: FactoryReturn }; + initialState: { [key in keyof O]: FactoryInitial }; +} { + 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 + 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< + S extends AnyState, + U = StoreUpdater | StoreUpdater[], +> { + 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>; + initialize?: () => Operation; +} + +interface CreateSchemaOptions { + /** + * 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< + UpdaterCtx, SchemaUpdater | SchemaUpdater[]> + >[] + | unknown[]; +} + +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(); +} + +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. * - * @example Basic usage + * @example * ```ts - * import { createSchema, slice } from 'starfx'; - * - * interface User { - * id: string; - * name: string; - * } - * - * const [schema, initialState] = createSchema({ - * cache: slice.table(), - * loaders: slice.loaders(), - * users: slice.table({ empty: { id: '', name: '' } }), - * counter: slice.num(0), - * settings: slice.obj({ theme: 'light', notifications: true }), + * // Plain object update (no immer) + * const schema = createSchemaWithUpdater(mySlices, { + * *updateMdw(ctx: UpdaterCtx>, next: 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(); + * }, * }); - * - * type AppState = typeof initialState; - * ``` - * - * @example Using the schema - * ```ts - * const fetchUsers = api.get('/users', function* (ctx, next) { - * // do work before the request - * yield* next(); - * if (!ctx.json.ok) return; - * - * const users = ctx.json.value.reduce((acc, u) => { - * acc[u.id] = u; - * return acc; - * }, {}); - * - * // Type-safe state updates - * yield* schema.update(schema.users.add(users)); - * - * // Type-safe selectors - * const allUsers = yield* select(schema.users.selectTableAsList); - * const user = yield* select(schema.users.selectById, { id: '1' }); - * } * ``` */ -export function createSchema< - 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, - ); +export function createSchemaWithUpdater( + slices: O, + { + middleware = [], + initialize, + updateMdw, + }: CreateSchemaWithUpdaterOptions< + SliceFromSchema, + SchemaUpdater | SchemaUpdater[] + >, +): FxSchema { + const { db, initialState } = buildSlices(slices); + + // Precomputed middleware will be set on first update call + const composedMdw: ReturnType< + typeof compose< + UpdaterCtx, SchemaUpdater | SchemaUpdater[]> + > + > = compose< + UpdaterCtx, SchemaUpdater | SchemaUpdater[]> + >([updateMdw, ...middleware, ...baseMiddlewares]); + + function* update(ups: SchemaUpdater | SchemaUpdater[]) { + const ctx: UpdaterCtx< + SliceFromSchema, + SchemaUpdater | SchemaUpdater[] + > = { + updater: ups, + patches: [], + }; - const initialState = Object.keys(db).reduce((acc, key) => { - (acc as any)[key] = db[key].initialState; - return acc; - }, {}) as S; + if (!composedMdw) { + throw new Error( + "Schema update middleware not initialized. Ensure the store is properly initialized before dispatching updates.", + ); + } - function* update(ups: StoreUpdater | StoreUpdater[]) { - return yield* updateStore(ups); + yield* composedMdw(ctx); + + return ctx; } - db.update = update; + function* reset(ignoreList: (string | number | symbol)[] = []) { + return yield* update((s: Draft>) => { + 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]; + } - return [db, initialState]; + for (const key of Object.keys(stateObj) as Array< + keyof SliceFromSchema + >) { + stateObj[key] = keep[key]; + } + }); + } + + const schema = db as FxSchema; + schema.update = update; + schema.initialize = initialize; + schema.initialState = initialState as SliceFromSchema; + schema.reset = reset; + + return schema; +} + +/** + * Creates a schema object from slice factories. + * + * @remarks + * A schema defines the shape of application state and provides reusable + * state helpers via generated slices. By default, `createSchema` includes + * `cache` and `loaders` slices used by starfx middleware and supervisors. + * + * @param slices - A map of slice factory functions. + * @param options - Schema options including `name` and custom middleware. + * @returns A configured schema with `update`, `reset`, and generated slices. + */ +export function createSchema( + slices?: O, + options: CreateSchemaOptions = {}, +): FxSchema { + const middleware = options.middleware as + | BaseMiddleware< + UpdaterCtx, SchemaUpdater | SchemaUpdater[]> + >[] + | undefined; + + return createSchemaWithUpdater(slices ?? defaultSchema(), { + middleware, + *updateMdw( + ctx: UpdaterCtx< + SliceFromSchema, + SchemaUpdater | SchemaUpdater[] + >, + next: Next, + ) { + const store = (yield* expectStore>()) as FxStore; + const upds = ( + Array.isArray(ctx.updater) ? ctx.updater : [ctx.updater] + ) as StoreUpdater>[]; + + const [_nextState, patches, _inversePatches] = store.setState(upds); + ctx.patches = patches; + + yield* next(); + }, + }); } diff --git a/src/store/slice/any.ts b/src/store/slice/any.ts index 2f4dc46d..071bb5ea 100644 --- a/src/store/slice/any.ts +++ b/src/store/slice/any.ts @@ -1,49 +1,74 @@ -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: Record) => 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; } -/** - * Create a generic slice for any arbitrary value. - * - * @param name - The state key for this slice. - * @param initialState - The initial value for the slice. - * @returns An `AnyOutput` providing setter, reset, and selector helpers. - */ -export function createAny({ +const selectValue = (state: Record, name: string): T => + state[name] as T; + +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; + Object.assign(state, { [name]: initialState }); }, - select: (state) => { - return (state as any)[name]; - }, - }; + select: (state) => selectValue>(state, String(name)), + } satisfies AnyOutput; } /** - * Shortcut to define an `any` slice for schema creation. + * Public API for creating a generic unconstrained-value slice in `createSchema`. + * + * @remarks + * Use this when the slice shape is intentionally open-ended. + * For richer semantics, prefer dedicated slices like `table`, `obj`, or `num`. * * @param initialState - The initial value for the slice. + * @returns A factory consumed by `createSchema` with the slice name. + * + * @example + * ```ts + * const schema = createSchema({ + * runtime: slice.any>({}), + * }); + * ``` */ export function any(initialState: V) { - return (name: string) => createAny({ name, initialState }); + return (name: string) => createAny({ name, initialState }); } 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/loaders.ts b/src/store/slice/loaders.ts index e4a2b283..360a3cb4 100644 --- a/src/store/slice/loaders.ts +++ b/src/store/slice/loaders.ts @@ -1,6 +1,6 @@ +import type { Draft, Immutable } from "immer"; import { createSelector } from "reselect"; import type { - AnyState, LoaderItemState, LoaderPayload, LoaderState, @@ -21,34 +21,32 @@ const excludesFalse = (n?: T): n is T => Boolean(n); * Create a default loader item with sensible defaults. * * @remarks - * Returns a complete {@link LoaderItemState} with the following defaults: + * Returns a complete LoaderItemState with these defaults: * - `id`: empty string - * - `status`: 'idle' + * - `status`: `idle` * - `message`: empty string - * - `lastRun`: 0 (never run) - * - `lastSuccess`: 0 (never succeeded) + * - `lastRun`: 0 + * - `lastSuccess`: 0 * - `meta`: empty object * - * @typeParam M - Metadata shape stored on the loader. - * @param li - Partial fields to override the defaults. - * @returns A fully populated {@link LoaderItemState}. + * @param li - Partial fields to override defaults. + * @returns A fully populated loader item. * * @example * ```ts - * const loader = defaultLoaderItem({ id: 'fetch-users' }); - * // { id: 'fetch-users', status: 'idle', message: '', ... } + * const loader = defaultLoaderItem({ id: "fetch-users" }); * ``` */ -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, }; } @@ -57,35 +55,23 @@ export function defaultLoaderItem( * Create a loader state with computed helper booleans. * * @remarks - * Extends {@link defaultLoaderItem} with convenience boolean properties: - * - `isIdle` - status === 'idle' - * - `isLoading` - status === 'loading' - * - `isSuccess` - status === 'success' - * - `isError` - status === 'error' - * - `isInitialLoading` - loading AND never succeeded (`lastSuccess === 0`) + * Extends `defaultLoaderItem` with convenience booleans: + * - `isIdle` + * - `isLoading` + * - `isSuccess` + * - `isError` + * - `isInitialLoading` (loading and never succeeded) * - * The `isInitialLoading` distinction is important: if data was successfully - * loaded before, showing a loading spinner on re-fetch may not be necessary. - * This lets you show stale data while refreshing. - * - * @typeParam M - Metadata shape stored on the loader. * @param l - Partial loader fields to normalize. - * @returns A {@link LoaderState} with helper booleans. + * @returns A loader state with helper booleans. * * @example * ```ts - * const loader = defaultLoader({ status: 'loading', lastSuccess: 0 }); - * loader.isLoading; // true - * loader.isInitialLoading; // true (first load) - * - * const reloader = defaultLoader({ status: 'loading', lastSuccess: Date.now() }); - * reloader.isLoading; // true - * reloader.isInitialLoading; // false (has succeeded before) + * const loader = defaultLoader({ status: "loading", lastSuccess: 0 }); + * loader.isInitialLoading; // true * ``` */ -export function defaultLoader( - l: Partial> = {}, -): LoaderState { +export function defaultLoader(l: Partial = {}): LoaderState { const loading = defaultLoaderItem(l); return { ...loading, @@ -99,108 +85,83 @@ export function defaultLoader( }; } -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[]; +type LoaderTable = Record; +type LoaderRootState = Record; +type LoaderStateView = Record; +type LoaderDraftState = Draft; + +export interface LoaderSelectors { + findById: (d: Immutable, p: PropId) => LoaderState; + findByIds: (d: 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< - 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"], +): LoaderSelectors { + const findById: LoaderSelectors["findById"] = (data, { id }) => + defaultLoader(data[id]); + + const findByIds: LoaderSelectors["findByIds"] = (data, { ids }) => + ids.map((id) => defaultLoader(data[id])).filter(excludesFalse); return { 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; } -export interface LoaderOutput< - M extends Record, - S extends AnyState, -> extends LoaderSelectors, - BaseSchema>> { +export interface LoaderActions { + 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 + 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(); -/** - * Create a loader slice for tracking async loader state keyed by id. - * - * @typeParam M - Metadata shape stored on loader entries. - * @typeParam S - Root state shape. - * @param param0.name - The slice name to attach to the state. - * @param param0.initialState - Optional initial loader table. - * @returns A `LoaderOutput` exposing selectors and mutation helpers. - */ -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: string; + initialState?: LoaderTable; +}): LoaderOutput => { + const loaderInitialState = initialState ?? {}; + const selectors = loaderSelectors( + (state) => state[name] as Immutable, + ); return { schema: "loader", - name: name as string, - initialState, - start: (e) => (s) => { - const table = selectors.selectTable(s); + name: String(name), + initialState: loaderInitialState, + start: (e) => (state) => { + const table = state[name]; const loader = table[e.id]; table[e.id] = defaultLoaderItem({ ...loader, @@ -209,8 +170,8 @@ export const createLoaders = < lastRun: ts(), }); }, - success: (e) => (s) => { - const table = selectors.selectTable(s); + success: (e) => (state) => { + const table = state[name]; const loader = table[e.id]; table[e.id] = defaultLoaderItem({ ...loader, @@ -219,8 +180,8 @@ export const createLoaders = < lastSuccess: ts(), }); }, - error: (e) => (s) => { - const table = selectors.selectTable(s); + error: (e) => (state) => { + const table = state[name]; const loader = table[e.id]; table[e.id] = defaultLoaderItem({ ...loader, @@ -228,26 +189,39 @@ export const createLoaders = < status: "error", }); }, - reset: () => (s) => { - (s as any)[name] = initialState; + 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 = selectors.selectTable(s); + resetByIds: (ids: string[]) => (state) => { + const table = state[name]; ids.forEach((id) => { delete table[id]; }); }, ...selectors, - }; + } satisfies LoaderOutput; }; /** - * Shortcut for declaring loader slices in schema definitions. + * Public loader slice API used in `createSchema` definitions. + * + * @remarks + * Tracks async lifecycle status for thunks/endpoints and exposes: + * - updaters: `start`, `success`, `error`, `reset`, `resetByIds` + * - selectors: `selectById`, `selectByIds`, `selectTable`, `selectTableAsList` * * @param initialState - Optional initial loader table. + * @returns A factory consumed by `createSchema` with the slice name. + * + * @example + * ```ts + * const schema = createSchema({ + * loaders: slice.loaders(), + * }); + * ``` */ -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 c7537875..6b3afe1c 100644 --- a/src/store/slice/num.ts +++ b/src/store/slice/num.ts @@ -1,61 +1,77 @@ -import type { AnyState } from "../../types.js"; -import type { BaseSchema } from "../types.js"; +import type { Draft } 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: Record) => 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; } -/** - * Create a numeric slice with helpers to increment/decrement/reset the value. - * - * @param name - The state key for this slice. - * @param initialState - Optional initial value (default: 0). - * @returns A `NumOutput` with numeric helpers and a selector. - */ -export function createNum({ +const selectValue = (state: Record, name: string): T => + state[name] as T; + +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; + state[name] = initialState; }, - select: (state) => { - return (state as any)[name]; - }, - }; + select: (state) => selectValue(state, String(name)), + } satisfies NumOutput; } /** - * Shortcut to create a numeric slice for schema creation. + * Public numeric slice API used in `createSchema` definitions. + * + * @remarks + * Great for counters, pagination indexes, and lightweight scalar state. * * @param initialState - Optional initial value for the slice. + * @returns A factory consumed by `createSchema` with the slice name. + * + * @example + * ```ts + * const schema = createSchema({ + * counter: slice.num(0), + * }); + * ``` */ 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 5405925e..6b838115 100644 --- a/src/store/slice/obj.ts +++ b/src/store/slice/obj.ts @@ -1,56 +1,81 @@ -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: Record) => 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; } -/** - * Create an object slice with update, set, and reset helpers. - * - * @param name - The state key for this slice. - * @param initialState - The initial object used for this slice. - * @returns An `ObjOutput` providing setters, partial updates, and a selector. - */ -export function createObj({ +const selectValue = (state: Record, name: string): T => + state[name] as T; + +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) => selectValue>(state, String(name)), + } satisfies ObjOutput; } /** - * Shortcut to create an `obj` slice for schema creation. + * Public object slice API used in `createSchema` definitions. + * + * @remarks + * `update` mutates a single property while preserving the rest of the object. + * If no object exists at runtime, it initializes one with the updated property. * * @param initialState - The initial object used for the slice. + * @returns A factory consumed by `createSchema` with the slice name. + * + * @example + * ```ts + * const schema = createSchema({ + * settings: slice.obj({ theme: "light", notifications: true }), + * }); + * ``` */ -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 1f3ffe0c..dbb867e4 100644 --- a/src/store/slice/str.ts +++ b/src/store/slice/str.ts @@ -1,50 +1,65 @@ -import type { AnyState } from "../../types.js"; -import type { BaseSchema } from "../types.js"; +import type { Draft } 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: Record) => 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; } -/** - * Create a string slice with set/reset/select helpers. - * - * @param name - State key for this slice. - * @param initialState - Optional initial string value (defaults to empty string). - * @returns A `StrOutput` containing setters and selector helpers. - */ -export function createStr({ +const selectValue = (state: Record, name: string): T => + state[name] as T; + +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; + state[name] = initialState; }, - select: (state) => { - return (state as any)[name]; - }, - }; + select: (state) => selectValue(state, String(name)), + } satisfies StrOutput; } /** - * Shortcut for creating a `str` slice when building schema definitions. + * Public string slice API used in `createSchema` definitions. + * + * @remarks + * Useful for tokens, identifiers, and lightweight text state. * * @param initialState - Optional initial string value. + * @returns A factory consumed by `createSchema` with the slice name. + * + * @example + * ```ts + * const schema = createSchema({ + * token: slice.str(""), + * }); + * ``` */ 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 6c00166a..9462f5ab 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 { BaseSchema, SliceState } from "../types.js"; + +type TableData = Record; +type TableRootState = Record>; +type TableSelectorState = Record; +type TableDraftState = Draft>; interface PropId { id: IdProp; @@ -15,269 +21,226 @@ 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 { + findById: ( + d: Immutable>, + p: PropId, + ) => Immutable | undefined; + findByIds: ( + d: Immutable>, + p: PropIds, + ) => Immutable[]; + tableAsList: (d: Immutable>) => Immutable[]; + selectTable: (s: TableSelectorState) => Immutable>; + selectTableAsList: (state: TableSelectorState) => Immutable; + selectById: (s: TableSelectorState, p: PropId) => Immutable; + selectByIds: (s: TableSelectorState, p: PropIds) => Immutable; } -function tableSelectors< - Entity extends AnyState = AnyState, - S extends AnyState = AnyState, ->( - selectTable: (s: S) => Record, - empty?: Entity | (() => Entity) | undefined, -) { - const must = empty ? mustSelectEntity(empty) : null; - const tableAsList = (data: Record): Entity[] => +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 = (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 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 }); }; - const sbi = must ? must(selectById) : 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: must ? must(findById) : findById, + findById, findByIds, tableAsList, selectTable, - selectTableAsList: createSelector(selectTable, (data): Entity[] => - tableAsList(data), - ), - selectById: sbi, + selectTableAsList: createSelector(selectTable, (data) => tableAsList(data)), + selectById, selectByIds: createSelector( selectTable, - (_: S, p: PropIds) => p.ids, + (_, p: PropIds) => p.ids, (data, ids) => findByIds(data, { ids }), ), }; } -export interface TableOutput< - Entity extends AnyState, - S extends AnyState, - Empty extends Entity | undefined = Entity | undefined, -> extends BaseSchema> { +export interface TableActions { + 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 + extends BaseSchema>, + TableActions, + TableSelectors { schema: "table"; 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[]; + empty: Entity | undefined; } -/** - * Create a table-style slice for entity storage (id -> entity map). - * - * @remarks - * The table slice mimics a database table where entities are stored in a - * `Record` structure. It provides: - * - * **Selectors:** - * - `selectTable` - Get the entire table object - * - `selectTableAsList` - Get all entities as an array - * - `selectById` - Get single entity by id (returns `empty` if not found) - * - `selectByIds` - Get multiple entities by ids - * - * **Updaters:** - * - `add` - Add or replace entities - * - `set` - Replace entire table - * - `remove` - Remove entities by ids - * - `patch` - Partially update entities - * - `merge` - Deep merge entities - * - `reset` - Reset to initial state - * - * **Empty value:** - * When `empty` is provided and `selectById` doesn't find an entity, it returns - * the empty value instead of `undefined`. This promotes safer code by providing - * stable assumptions about data shape (no optional chaining needed). - * - * @typeParam Entity - The entity type stored in the table. - * @typeParam S - The root state type. - * @param p - Table configuration. - * @param p.name - The state key for this table. - * @param p.initialState - Optional initial map of entities. - * @param p.empty - Optional empty entity (or factory) returned for missing lookups. - * @returns A {@link TableOutput} with selectors and mutation helpers. - * - * @see {@link https://bower.sh/death-by-thousand-existential-checks | Why empty values matter} - * @see {@link https://bower.sh/entity-factories | Entity factories pattern} - * - * @example Basic usage - * ```ts - * interface User { - * id: string; - * name: string; - * email: string; - * } - * - * const [schema, initialState] = createSchema({ - * users: slice.table({ empty: { id: '', name: '', email: '' } }), - * }); - * - * // Add users - * yield* schema.update( - * schema.users.add({ - * '1': { id: '1', name: 'Alice', email: 'alice@example.com' }, - * '2': { id: '2', name: 'Bob', email: 'bob@example.com' }, - * }) - * ); - * - * // Get user (returns empty if not found) - * const user = yield* select(schema.users.selectById, { id: '1' }); - * - * // Partial update - * yield* schema.update( - * schema.users.patch({ '1': { name: 'Alice Smith' } }) - * ); - * - * // Remove users - * yield* schema.update(schema.users.remove(['2'])); - * ``` - */ -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: string; initialState?: Record; empty?: Entity | (() => Entity); -}): TableOutput { - const selectors = tableSelectors((s: S) => s[name], empty); +}): TableOutput { + const tableInitialState: TableData = initialState ?? {}; + const selectors = tableSelectors( + (state) => state[name] as Immutable>, + empty, + ); return { schema: "table", - name: name as string, - initialState, - empty: typeof empty === "function" ? empty() : empty, - add: (entities) => (s) => { - const state = selectors.selectTable(s); - Object.keys(entities).forEach((id) => { - state[id] = entities[id]; - }); + name: String(name), + initialState: tableInitialState, + empty: empty === undefined ? undefined : isFactory(empty) ? empty() : empty, + add: (entities) => (state) => { + const table = state[name]; + Object.assign(table, entities); }, - - set: (entities) => (s) => { - (s as any)[name] = 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 = selectors.selectTable(s); - ids.forEach((id) => { - delete state[id]; - }); + remove: (ids) => (state) => { + const table = state[name]; + for (const id of ids) delete table[id]; }, - patch: (entities) => (s) => { - const state = selectors.selectTable(s); - Object.keys(entities).forEach((id) => { - state[id] = { ...state[id], ...entities[id] }; - }); + patch: (entities) => (state) => { + const table = state[name]; + for (const id of Object.keys(entities)) { + const existing = table[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]; + 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 = table[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(table, { [id]: tgtRec as Entity }); + } }, - reset: () => (s) => { - (s as any)[name] = initialState; + reset: () => (state) => { + const table = state[name]; + for (const key of Object.keys(table)) delete table[key]; + Object.assign(table, tableInitialState); }, ...selectors, - }; + } satisfies 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, ->(p?: { - initialState?: Record; - empty?: Entity | (() => Entity); -}): (n: string) => TableOutput; /** - * Shortcut for defining a `table` slice when building schema declarations. + * Built-in table slice API used in `createSchema` definitions. + * + * @remarks + * The table slice mimics a normalized entity table with `id -> entity` storage. + * + * Available selectors: + * - `selectTable` + * - `selectTableAsList` + * - `selectById` + * - `selectByIds` + * + * Available updaters: + * - `add` + * - `set` + * - `remove` + * - `patch` + * - `merge` + * - `reset` + * + * If `empty` is provided and `selectById` misses, the selector returns that + * value instead of `undefined`. + * + * @param options.initialState - Optional initial entity map. + * @param options.empty - Optional empty entity or factory. + * @returns A factory consumed by `createSchema` with the slice name. * - * @param initialState - Optional initial entity map. - * @param empty - Optional empty entity or factory. - * @returns A factory function accepting the slice name. + * @example + * ```ts + * const schema = createSchema({ + * users: slice.table({ + * empty: { id: "", name: "" }, + * }), + * }); + * ``` */ -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 }); } + +/** + * 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 25b6e158..61c06d1d 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,17 +1,34 @@ import { - Ok, + type Operation, type Scope, createContext, createScope, createSignal, } 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 { AnyAction, AnyState, Next } from "../types.js"; -import { StoreContext, StoreUpdateContext } from "./context.js"; +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"; +import { StoreContext } from "./context.js"; import { createRun } from "./run.js"; -import type { FxStore, Listener, StoreUpdater, UpdaterCtx } from "./types.js"; +import { DEFAULT_SCHEMA_KEY } from "./types.js"; +import type { + AnyFxSchema, + 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; @@ -28,13 +45,32 @@ function observable() { }; } -export interface CreateStore { +export interface CreateStore { scope?: Scope; - initialState: S; - middleware?: BaseMiddleware>[]; + schema: 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)[]; +} + +export interface CreateStoreMulti { + scope?: Scope; + schema: TSchemas; + tasks?: (() => Operation)[]; } export const IdContext = createContext("starfx:id", 0); +export const ListenersContext = createContext>( + "starfx:store:listeners", + new Set(), +); /** * Creates a new FxStore instance for managing application state. @@ -45,57 +81,79 @@ export const IdContext = createContext("starfx:id", 0); * executing operations within the store's scope. * * Unlike traditional Redux stores, this store does not use reducers. Instead, - * state updates are performed using `immer`-based updater functions that directly - * mutate a draft state. This design is inspired by the observation that reducers - * were originally created to ensure immutability, but with `immer` that concern - * is handled automatically. - * - * @typeParam S - The shape of the root state object. + * state updates are performed with immer-based updater functions that mutate + * draft state. * + * @typeParam O - Slice factory map used to build schema/state shape. * @param options - Store configuration object. - * @param options.initialState - The initial state for the store. - * @param options.scope - Optional Effection scope to use. If omitted, a new scope is created. - * @param options.middleware - Optional array of store middleware. - * @returns A fully configured {@link FxStore} instance. - * - * @see {@link createSchema} for creating the schema and initial state. - * @see {@link https://immerjs.github.io/immer/update-patterns | Immer update patterns} + * @param options.scope - Optional Effection scope to use. + * @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. + * @returns A fully configured store instance. * * @example * ```ts - * import { createSchema, createStore, slice } from "starfx"; - * - * interface User { - * id: string; - * name: string; - * } - * - * const [schema, initialState] = createSchema({ + * const schema = createSchema({ + * users: slice.table(), * cache: slice.table(), * loaders: slice.loaders(), - * users: slice.table(), * }); * - * const store = createStore({ initialState }); - * store.run(api.register); - * store.dispatch(fetchUsers()); + * const store = createStore({ schema }); * ``` */ -export function createStore({ - initialState, +export function createStore( + options: CreateStore, +): FxStore; +export function createStore( + options: CreateStoreMulti, +): FxStore, TSchemas>; +export function createStore({ scope: initScope, - middleware = [], -}: CreateStore): FxStore { + schema: schemaInput, + tasks = [], +}: CreateStore | CreateStoreMulti): FxStore< + SchemaMap, + StoreSchemaRegistry> +> { + if (!schemaInput) { + throw new Error("At least one schema must be provided."); + } + + if (!isFxSchema(schemaInput) && !isSchemaRegistry(schemaInput)) { + throw new Error("A schema registry must include `default`"); + } + + 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(); - let state = initialState; const listeners = new Set(); - enablePatches(); + scope.set(ListenersContext, listeners); const signal = createSignal(); scope.set(ActionContext, signal); scope.set(IdContext, id++); + enablePatches(); + // Build initial state from all schemas + const initialState = schemas.reduce( + (acc, schema) => { + return Object.assign(acc, schema.initialState); + }, + {} as SliceFromSchema, + ); + let state = initialState; + function getScope() { return scope; } @@ -104,120 +162,50 @@ export function createStore({ return state; } - 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 - upds.forEach((updater) => updater(draft as any)); - }); - ctx.patches = patches; - - // set the state! - state = nextState; - - yield* next(); - } + function setState(upds: StoreUpdater>[]) { + const nextState = produceWithPatches( + state, + (draft: Draft>) => { + upds.forEach((updater) => updater(draft)); + }, + ); - function* logMdw(ctx: UpdaterCtx, next: Next) { - dispatch({ - type: `${API_ACTION_PREFIX}store`, - payload: ctx, - }); - yield* next(); + state = nextState[0]; + return nextState; } - 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(); - } - - function createUpdater() { - const fn = compose>([ - updateMdw, - ...middleware, - logMdw, - notifyChannelMdw, - notifyListenersMdw, - ]); - - return fn; + function getInitialState() { + return initialState; } - const mdw = createUpdater(); - function* update(updater: StoreUpdater | StoreUpdater[]) { - const ctx = { - updater, - patches: [], - result: Ok(undefined), - }; - - yield* mdw(ctx); - - if (!ctx.result.ok) { - dispatch({ - type: `${API_ACTION_PREFIX}store`, - payload: ctx.result.error, - }); - } - - return ctx; + function subscribe(fn: Listener) { + listeners.add(fn); + return () => listeners.delete(fn); } 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( - (acc, key) => { - acc[key] = s[key]; - return acc; - }, - { ...initialState }, - ); + const run = createRun(scope); - Object.keys(s).forEach((key: keyof S) => { - s[key] = keep[key]; - }); - }); - } - - const store: FxStore = { + const store: FxStore>> = { getScope, getState, + setState, subscribe, - update, - reset, - run: createRun(scope), + schema: baseSchema, + schemas: schemasMap, + 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 - replaceReducer( - _nextReducer: (_s: S, _a: AnyAction) => void, + replaceReducer( + _nextReducer: ( + s: SliceFromSchema, + a: AnyAction, + ) => SliceFromSchema, ): void { throw new Error(stubMsg); }, @@ -225,11 +213,38 @@ export function createStore({ [Symbol.observable]: observable, }; - scope.set(StoreContext, store as FxStore); + scope.set(StoreContext, store as StoreContextValue); + + 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; } -/** - * @deprecated use {@link createStore} - */ -export const configureStore = createStore; +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 ffcf76e5..594f8c0d 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -1,15 +1,25 @@ import type { Operation, Scope } from "effection"; -import type { Patch } from "immer"; -import type { BaseCtx } from "../index.js"; +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"; import type { LoaderOutput } from "./slice/loaders.js"; import type { TableOutput } from "./slice/table.js"; /** - * A function that applies mutations to the store state. + * A function that applies mutations to draft store state. + * + * @remarks + * The function receives an immer Draft and may mutate in place. */ -export type StoreUpdater = (s: S) => 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; /** * Simple listener callback type used by `subscribe`. @@ -19,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[]; } @@ -30,9 +43,6 @@ declare global { } } -/** - * Base description of a slice factory (schema output) used to build Fx schemas. - */ export interface BaseSchema { initialState: TOutput; schema: string; @@ -44,41 +54,157 @@ export type Output }> = { }; /** - * Map of slice factories used when creating a schema via {@link createSchema}. + * Canonical slice-state view used by generated slice helpers. + */ +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 FxMap { - loaders: (s: string) => LoaderOutput; - cache: (s: string) => TableOutput; - [key: string]: (name: string) => BaseSchema; +export interface BuiltinSchemaMap { + loaders?: (s: string) => LoaderOutput; + cache?: (s: string) => TableOutput; } /** - * Generated schema type mapping slice factories to their runtime output helpers. + * Map of slice factories used when creating a schema via createSchema. + * + * @remarks + * Includes optional default `loaders` and `cache` slices while allowing + * additional user-defined factories. + */ +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; + +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 SchemaMap + ? 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< + NonNullable +> extends BaseSchema + ? IS + : never; +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. + * + * @remarks + * Extends generated helpers with schema lifecycle/update APIs. */ -export type FxSchema = { - [key in keyof O]: ReturnType; -} & { update: FxStore["update"] }; +export type FxSchema = { + [K in keyof O]: FactoryReturn>; +} & { + initialize?: () => Operation; + update: ( + u: SchemaUpdater | SchemaUpdater[], + ) => Operation< + UpdaterCtx, SchemaUpdater | SchemaUpdater[]> + >; + initialState: SliceFromSchema; + reset: = keyof SliceFromSchema>( + ignoreList?: K[], + ) => Operation< + UpdaterCtx, SchemaUpdater | SchemaUpdater[]> + >; +}; /** * Runtime store instance exposing state, update, and effect helpers. + * + * @remarks + * Compatible with react-redux store expectations for interop. */ -export interface FxStore { +export interface FxStore< + O extends SchemaMap, + TSchemas extends StoreSchemaRegistry = StoreSchemaRegistry>, +> { getScope: () => Scope; - getState: () => S; + // part of redux store API + getState: () => SliceFromSchema; + setState: ( + upds: StoreUpdater>[], + ) => ReturnType; + // 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: TSchemas[DefaultSchemaKey]; + // all schemas keyed by their registry entry + schemas: TSchemas; run: ReturnType; - dispatch: (a: AnyAction | AnyAction[]) => any; - replaceReducer: (r: (s: S, a: AnyAction) => S) => void; - getInitialState: () => S; - [Symbol.observable]: () => any; + // part of redux store API + dispatch: (a: AnyAction | AnyAction[]) => unknown; + // part of redux store API + replaceReducer: ( + r: (s: SliceFromSchema, a: AnyAction) => SliceFromSchema, + ) => void; + getInitialState: () => SliceFromSchema; + [Symbol.observable]: () => unknown; } /** - * Minimal shape of the generated `QueryState`. + * Minimal shape of the generated query state. */ export interface QueryState { - cache: TableOutput["initialState"]; - loaders: LoaderOutput["initialState"]; + cache: TableOutput["initialState"]; + loaders: LoaderOutput["initialState"]; } diff --git a/src/test/api.test.ts b/src/test/api.test.ts index 3a22a805..53dff69e 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({ 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({ 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({ schema: 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({ schema: 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({ schema: 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({ schema: createSchema() }); store.run(() => keepAlive([api.register, watchAction])); store.dispatch(action2()); @@ -265,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"); @@ -365,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; } @@ -395,7 +402,7 @@ test("ensure types for get() endpoint", () => { }, ); - const store = createStore({ initialState: { users: {} } }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.dispatch(action1({ id: "1" })); @@ -433,12 +440,13 @@ test("ensure ability to cast `ctx` in function definition", () => { }, ); - const store = createStore({ initialState: { users: {} } }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.dispatch(action1({ id: "1" })); 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 @@ -466,14 +474,14 @@ test("ensure ability to cast `ctx` in function definition with no props", () => }, ); - const store = createStore({ initialState: { users: {} } }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.dispatch(action1()); expect(acc).toEqual(["wow"]); }); test("should bubble up error", () => { - let error: any = null; + let error: unknown = null; const { store } = testStore(); const api = createApi(); api.use(function* (_, next) { @@ -490,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')", ); }); @@ -530,7 +542,7 @@ test("useCache - derive api success from endpoint", () => { }, ); - const store = createStore({ initialState: { users: {} } }); + const store = createStore({ schema: createSchema() }); store.run(api.register); function _App() { diff --git a/src/test/batch.test.ts b/src/test/batch.test.ts index 19e1b84f..089c08fc 100644 --- a/src/test/batch.test.ts +++ b/src/test/batch.test.ts @@ -8,20 +8,22 @@ 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 store = createStore({ - initialState, - middleware: [createBatchMdw(queueMicrotask)], - }); + const schema = createSchema( + { + cache: slice.table({ empty: {} }), + loaders: slice.loaders(), + }, + { + middleware: [createBatchMdw(queueMicrotask)], + }, + ); + 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/create-store.test.ts b/src/test/create-store.test.ts index 1008c7dd..6c299258 100644 --- a/src/test/create-store.test.ts +++ b/src/test/create-store.test.ts @@ -1,14 +1,31 @@ +import { expectTypeOf } from "vitest"; import { call } from "../index.js"; -import { createStore, select } from "../store/index.js"; +import { + createSchema, + createStore, + expectStore, + select, + slice, +} from "../store/index.js"; import { expect, test } from "../test.js"; 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; - const store = createStore({ initialState: { user: { id: "1" } } }); + let actual: TestState["user"] | undefined; + const store = createStore({ + schema: createSchema({ user: slice.obj({ id: "1" }) }), + }); await store.run(function* () { actual = yield* select((s: TestState) => s.user); }); @@ -16,8 +33,10 @@ 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" } } }); + let actual: TestState["user"] | undefined; + const store = createStore({ + schema: createSchema({ user: slice.obj({ id: "2" }) }), + }); await store.run(function* () { actual = yield* call(function* () { return yield* select((s: TestState) => s.user); @@ -25,3 +44,112 @@ 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 schema registries without default", () => { + const metadata = createSchema({ + metadata: slice.obj>({}), + }); + + expect(() => + 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(() => + // 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(); +}); + +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/fetch.test.ts b/src/test/fetch.test.ts index d8758163..8f92a9db 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({ 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/matcher.test.ts b/src/test/matcher.test.ts index d42e5ce1..4a5819a0 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({ 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({ initialState: {} }); + const store = createStore({ schema: 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: {}, - }, + schema: 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: {}, - }, + schema: 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({ 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 d943203c..870d451b 100644 --- a/src/test/mdw.test.ts +++ b/src/test/mdw.test.ts @@ -29,17 +29,17 @@ 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); }; 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({ schema }); return { schema, store }; }; @@ -517,14 +517,13 @@ test("errorHandler", () => { ); const store = createStore({ - initialState: { - users: {}, - }, + schema: createSchema(), }); store.run(query.register); store.dispatch(fetchUsers()); expect(store.getState()).toEqual({ - users: {}, + cache: {}, + loaders: {}, }); expect(a).toEqual(2); }); 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/persist.test.ts b/src/test/persist.test.ts index 4f098241..c9307965 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({ 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({ 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({ 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({ 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,15 +276,17 @@ 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) { 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]) { @@ -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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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..347ee264 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({ 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({ initialState: {} }); + 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({ initialState: {} }); + 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({ initialState: {} }); + const store = createStore({ schema: createSchema() }); await store.run(root); const expected = ["didn't get missed"]; expect(actual).toEqual(expected); diff --git a/src/test/react-types.test.ts b/src/test/react-types.test.ts new file mode 100644 index 00000000..133fb6ab --- /dev/null +++ b/src/test/react-types.test.ts @@ -0,0 +1,244 @@ +import type { ReactElement } from "react"; +import { describe, expectTypeOf, test } from "vitest"; +import type { ThunkAction } from "../query/index.js"; +import { + Provider, + type UseApiAction, + type UseApiProps, + type UseApiSimpleProps, + type UseCacheResult, + createTypedHooks, + useApi, + useCache, + useLoader, + useQuery, + useSchema, + useSchemaWithCache, + useSchemaWithLoaders, + useSelector, + useStore, +} from "../react.js"; +import { createSchema, createStore, slice } from "../store/index.js"; +import type { + 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; + 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", () => { + 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>(); + }); + + test("bare useSelector works with schema selectors", () => { + const schema = createSchema({ + cache: slice.cache(), + loaders: slice.loaders(), + metadata: slice.obj({}), + users: slice.table(), + }); + const useUsers = () => useSelector(schema.users.selectTableAsList); + + expectTypeOf(useUsers).returns.toEqualTypeOf< + ReturnType + >(); + }); + + 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 + >(); + }); + }); + + describe("schema-bound typed hooks", () => { + test("useSelector exposes schema state to userland", () => { + const schema = createSchema({ + cache: slice.cache(), + 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("createTypedHooks infers all hook state from schema input", () => { + const schema = createSchema({ + cache: slice.cache(), + 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("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", () => { + 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< + FxSchema + >(); + }); + + 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("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; + }; + + expectTypeOf(useMetadataSelect).returns.toExtend< + ObjOutput["select"] + >(); + }); + }); +}); diff --git a/src/test/react.test.ts b/src/test/react.test.ts index bd9cdb1b..053181d7 100644 --- a/src/test/react.test.ts +++ b/src/test/react.test.ts @@ -5,12 +5,12 @@ 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 }); - 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 66d61371..b0d211cf 100644 --- a/src/test/schema.test.ts +++ b/src/test/schema.test.ts @@ -12,30 +12,33 @@ 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({ 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.run(function* () { - yield* schema.update(schema.loaders.start({ id: "1" })); - yield* schema.update(schema.cache.add({ "1": true })); + 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 () => { expect.assertions(8); - const [db, initialState] = createSchema({ + const db = createSchema({ users: slice.table({ initialState: { "1": { id: "1", name: "wow" } }, empty: emptyUser, @@ -47,7 +50,7 @@ test("general types and functionality", async () => { cache: slice.table({ empty: {} }), loaders: slice.loaders(), }); - const store = createStore({ initialState }); + const store = createStore({ schema: db }); expect(store.getState()).toEqual({ users: { "1": { id: "1", name: "wow" } }, @@ -58,6 +61,7 @@ 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" } }); @@ -67,24 +71,27 @@ test("general types and functionality", async () => { 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); @@ -93,25 +100,32 @@ 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({ schema: db }); + 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/slice-types.test.ts b/src/test/slice-types.test.ts new file mode 100644 index 00000000..12abb721 --- /dev/null +++ b/src/test/slice-types.test.ts @@ -0,0 +1,523 @@ +import type { Immutable } from "immer"; +/** + * 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 { describe, expectTypeOf, test } from "vitest"; +import { createSchema, slice } from "../store/index.js"; +import type { + AnyOutput, + LoaderOutput, + NumOutput, + ObjOutput, + StrOutput, + TableOutput, +} from "../store/slice/index.js"; +import type { FxMap } from "../store/types.js"; +import type { AnyState } from "../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).toExtend(); + }); + + 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).toExtend(); + }); + + 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).toExtend(); + }); + + 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).toExtend>(); + 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).toExtend>(); + }); + + 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("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).toExtend>(); + }); + + test("table with empty returns non-undefined selectById", () => { + const usersSlice = slice.table({ empty: emptyUser })("users"); + expectTypeOf(usersSlice).toExtend>(); + + type SelectByIdResult = ReturnType; + type FindByIdResult = ReturnType; + + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf | undefined>(); + }); + + test("table without empty returns possibly undefined selectById", () => { + const usersSlice = slice.table()("users"); + + type SelectByIdResult = ReturnType; + expectTypeOf().toEqualTypeOf>(); + }); + + 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.toEqualTypeOf< + Immutable> + >(); + }); + + test("table slice selectTableAsList returns array", () => { + const usersSlice = slice.table()("users"); + expectTypeOf(usersSlice.selectTableAsList).returns.toEqualTypeOf< + Immutable + >(); + }); + + test("table slice selectByIds returns array", () => { + const usersSlice = slice.table()("users"); + expectTypeOf(usersSlice.selectByIds).returns.toEqualTypeOf< + Immutable + >(); + }); + + test("table with empty factory function", () => { + const usersSlice = slice.table({ empty: () => emptyUser })("users"); + expectTypeOf(usersSlice.empty).toEqualTypeOf(); + type SelectByIdResult = ReturnType; + expectTypeOf().toEqualTypeOf>(); + }); +}); + +// ============================================================================= +// slice.loaders() type tests +// ============================================================================= + +describe("slice.loaders types", () => { + test("loaders factory returns LoaderOutput", () => { + const loadersFactory = slice.loaders(); + const loadersSlice = loadersFactory("loaders"); + expectTypeOf(loadersSlice).toExtend(); + }); + + 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", () => { + 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).toExtend>(); + expectTypeOf(schema.posts).toExtend>(); + expectTypeOf(schema.token).toExtend(); + expectTypeOf(schema.counter).toExtend(); + expectTypeOf(schema.currentUser).toExtend>(); + }); + + 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(); + }); + + 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(); + }); +}); + +// ============================================================================= +// 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"); + + type SettingsSelectResult = ReturnType; + expectTypeOf().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< + Immutable + >(); + }); + + 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>(); + }); +}); diff --git a/src/test/store.test.ts b/src/test/store.test.ts index 6c75897b..6d79b6b4 100644 --- a/src/test/store.test.ts +++ b/src/test/store.test.ts @@ -2,18 +2,24 @@ import { type Operation, type Result, createScope, + createThunks, parallel, put, + resource, sleep, take, } from "../index.js"; import { StoreContext, StoreUpdateContext, + createSchema, createStore, + registerResource, + slice, updateStore, } from "../store/index.js"; -import { expect, test } from "../test.js"; +import type { FxMap } from "../store/types.js"; +import { describe, expect, test } from "../test.js"; interface User { id: string; @@ -51,22 +57,32 @@ 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 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 }); - let store; + }); + const testStore = createStore({ scope, schema }); + // biome-ignore lint/suspicious/noExplicitAny: test-only + let store: any; await scope.run(function* (): Operation[]> { const result = yield* parallel([ function* () { @@ -78,26 +94,25 @@ 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; }); - expect((store as any)?.getState()).toEqual({ + expect(store?.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({ schema }); store.subscribe(() => { expect(store.getState()).toEqual({ @@ -109,25 +124,22 @@ 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" })); }); }); 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({ 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? @@ -148,16 +160,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({ 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"; @@ -171,7 +180,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" } }, @@ -180,3 +189,80 @@ test("resets store", async () => { token: "", }); }); + +describe(".registerResource", () => { + 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 [scope] = createScope(); + const TestContext = registerResource("test:context", guessAge()); + const store = createStore({ + scope, + schema: createSchema(), + tasks: [TestContext.initialize, thunk.register], + }); + let acc = "bla"; + const action = thunk.create("/users", function* (payload, next) { + const c = yield* TestContext.get(); + if (c) acc += "b"; + next(); + }); + + await store.run(function* (): Operation { + store.dispatch(action()); + }); + + expect(acc).toBe("blab"); + }); + + test("uses resource", async () => { + expect.assertions(2); + + const thunk = createThunks(); + thunk.use(thunk.routes()); + const [scope] = createScope(); + const TestContext = registerResource("test:context", guessAge()); + const store = createStore({ + scope, + schema: createSchema(), + tasks: [TestContext.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(); + }); + + await store.run(function* (): Operation { + store.dispatch(action()); + }); + + 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 6fbf8055..d24cc530 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,11 +25,10 @@ const slice = createObj({ }); test("sets up an obj", async () => { - const store = configureStore({ - initialState: { - [NAME]: crtInitialState, - }, + const schema = createSchema({ + [NAME]: () => slice, }); + const store = createStore({ schema }); await store.run(function* () { yield* updateStore( diff --git a/src/test/store/slice/table.test.ts b/src/test/store/slice/table.test.ts index 141d7e5c..1bbc4019 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,65 @@ 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({ 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({ 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({ schema }); + // Pre-populate the store await store.run(function* () { - yield* updateStore(slice.remove(["1"])); + yield* updateStore( + tableSlice.set({ [first.id]: first, [second.id]: second }), + ); + + 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({ schema }); await store.run(function* () { const updated = { id: second.id, user: "BB" }; - yield* updateStore(slice.patch({ [updated.id]: updated })); + yield* updateStore(tableSlice.set({ [second.id]: second })); + yield* updateStore(tableSlice.patch({ [updated.id]: updated })); }); expect(store.getState()[NAME]).toEqual({ [second.id]: { ...second, user: "BB" }, @@ -76,35 +77,42 @@ test("updates a row", async () => { }); test("gets a row", async () => { - const store = configureStore({ - initialState, + const schema = createSchema({ + [NAME]: () => tableSlice, }); + const store = createStore({ 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({ 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({ 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,9 +120,10 @@ 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({ schema }); expect(tbl.empty).toEqual(first); await store.run(function* () { @@ -130,9 +139,10 @@ 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({ schema }); expect(tbl.empty).toEqual(undefined); await store.run(function* () { 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/test/take-helper.test.ts b/src/test/take-helper.test.ts index 0f7493d0..e533ccbb 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({ 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({ initialState: {} }); + 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({ initialState: {} }); + 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 aba2a7ea..ca98e7d2 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({ 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({ initialState: {} }); + 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 0e8ad650..d1a48852 100644 --- a/src/test/thunk.test.ts +++ b/src/test/thunk.test.ts @@ -8,30 +8,21 @@ 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 { - 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 { @@ -148,14 +139,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({ schema }); store.run(api.register); store.dispatch(fetchUsers()); expect(store.getState()).toEqual({ + cache: {}, + loaders: {}, users: { [mockUser.id]: deserializeUser(mockUser) }, tickets: {}, }); @@ -186,13 +183,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({ schema }); store.run(api.register); store.dispatch(fetchTickets()); expect(store.getState()).toEqual({ + cache: {}, + loaders: {}, users: { [mockUser.id]: deserializeUser(mockUser) }, tickets: { [mockTicket.id]: deserializeTicket(mockTicket) }, }); @@ -200,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) { @@ -216,7 +219,7 @@ test("error handling", () => { const action = api.create("/error", { supervisor: takeEvery }); - const store = createStore({ initialState: {} }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.dispatch(action()); expect(called).toBe(true); @@ -242,7 +245,7 @@ test("error handling inside create", () => { } }, ); - const store = createStore({ initialState: {} }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.dispatch(action()); expect(called).toBe(true); @@ -271,9 +274,7 @@ test("error inside endpoint mdw", () => { ); const store = createStore({ - initialState: { - users: {}, - }, + schema: createSchema(), }); store.run(query.register); store.dispatch(fetchUsers()); @@ -306,7 +307,7 @@ test("create fn is an array", () => { }, ]); - const store = createStore({ initialState: {} }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.dispatch(action()); }); @@ -338,7 +339,7 @@ test("run() on endpoint action - should run the effect", () => { }, ); - const store = createStore({ initialState: {} }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.dispatch(action2()); expect(acc).toBe("ab"); @@ -379,7 +380,7 @@ test("run() on endpoint action with payload - should run the effect", () => { }, ); - const store = createStore({ initialState: {} }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.dispatch(action2()); expect(acc).toBe("ab"); @@ -426,7 +427,7 @@ test("middleware order of execution", async () => { }, ); - const store = createStore({ initialState: {} }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.dispatch(action()); @@ -460,7 +461,7 @@ test("retry with actionFn", async () => { } }); - const store = createStore({ initialState: {} }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.dispatch(action()); @@ -495,7 +496,7 @@ test("retry with actionFn with payload", async () => { }, ); - const store = createStore({ initialState: {} }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.dispatch(action({ page: 1 })); @@ -530,7 +531,7 @@ test("should only call thunk once", () => { }, ); - const store = createStore({ initialState: {} }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.dispatch(action2()); expect(acc).toBe("a"); @@ -540,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({ initialState: {} }); + const store = createStore({ schema: createSchema() }); store.run(api.register); let acc = ""; @@ -560,7 +561,7 @@ test("should warn when calling thunk before registered", () => { }; const api = createThunks(); api.use(api.routes()); - const store = createStore({ initialState: {} }); + const store = createStore({ schema: createSchema() }); const action = api.create("/users"); store.dispatch(action()); @@ -572,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({ initialState: {} }); + const store = createStore({ schema: createSchema() }); store.run(api.register); store.run(api.register); @@ -592,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({ initialState: {} }); + const store = createStore({ schema: createSchema() }); store.run(api1.register); store.run(api1.register); @@ -623,7 +624,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({ schema: createSchema() }); const task = store.run(api1.register); await task.halt(); store.run(api1.register); @@ -641,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({ initialState: {} }); - const storeB = createStore({ initialState: {} }); + const storeA = createStore({ schema: createSchema() }); + const storeB = createStore({ schema: createSchema() }); storeA.run(api1.register); storeB.run(api1.register); let acc = ""; @@ -682,7 +683,7 @@ describe(".manage", () => { const thunk = createThunks(); thunk.use(thunk.routes()); const TestContext = thunk.manage("test:context", guessAge()); - const store = createStore({ initialState: {} }); + const store = createStore({ schema: createSchema() }); store.run(thunk.register); let acc = ""; const action = thunk.create("/users", function* (payload, next) { @@ -700,7 +701,7 @@ describe(".manage", () => { const thunk = createThunks(); thunk.use(thunk.routes()); const TestContext = thunk.manage("test:context", guessAge()); - const store = createStore({ initialState: {} }); + const store = createStore({ schema: createSchema() }); store.run(thunk.register); let acc = ""; const action = thunk.create("/users", function* (payload, next) { @@ -719,7 +720,7 @@ describe(".manage", () => { const thunk = createThunks(); thunk.use(thunk.routes()); const TestContext = thunk.manage("test:context", guessAge()); - const store = createStore({ initialState: {} }); + const store = createStore({ schema: createSchema() }); store.run(thunk.register); let guess = 0; let acc = 0; diff --git a/src/types.ts b/src/types.ts index 61adbc0c..baa5aa9f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,17 +5,7 @@ import type { Operation } from "effection"; * * @remarks * Call `yield* next()` to pass control to the next middleware. Code after - * the yield point executes after all downstream middleware have completed. - * Not calling `next()` exits the middleware stack early. - * - * @example - * ```ts - * function* myMiddleware(ctx, next) { - * console.log('before'); - * yield* next(); // Call next middleware - * console.log('after'); - * } - * ``` + * the yield point executes after downstream middleware completes. */ export type Next = () => Operation; @@ -30,17 +20,13 @@ export type IdProp = string | number; export type LoadingStatus = "loading" | "success" | "error" | "idle"; /** - * Minimal state tracked for each loader instance (internal representation). + * Minimal state tracked for each loader instance. * * @remarks - * This is the raw state stored in the loaders slice. For consumer-facing - * state with convenience booleans, see {@link LoaderState}. - * - * @typeParam M - Shape of the `meta` object for custom metadata. + * This is the raw state stored in the loader table. For a consumer-facing + * shape with convenience booleans, see {@link LoaderState}. */ -export interface LoaderItemState< - M extends Record = Record, -> { +export interface LoaderItemState { /** Unique loader id derived from action key/payload */ id: string; /** Current loader status */ @@ -52,29 +38,22 @@ export interface LoaderItemState< /** Timestamp of the last successful run */ lastSuccess: number; /** Arbitrary metadata attached to the loader */ - meta: M; + // biome-ignore lint/suspicious/noExplicitAny: allow anything but can't be generic per this type + meta: Record; } /** * Extended loader state with convenience boolean properties. * * @remarks - * This is the type returned by loader selectors and hooks. It extends - * {@link LoaderItemState} with computed booleans for easy status checking: - * - * - `isIdle` - Initial state, operation hasn't started - * - `isLoading` - Currently executing - * - `isSuccess` - Completed successfully - * - `isError` - Failed with an error - * - `isInitialLoading` - Loading AND has never succeeded before - * - * The `isInitialLoading` property is useful for showing loading UI only - * on the first fetch, while displaying stale data during refreshes. - * - * @typeParam M - Shape of the `meta` object for custom metadata. + * Adds computed booleans for easy status checks: + * - `isIdle` + * - `isLoading` + * - `isSuccess` + * - `isError` + * - `isInitialLoading` */ -export interface LoaderState - extends LoaderItemState { +export interface LoaderState extends LoaderItemState { isIdle: boolean; isLoading: boolean; isError: boolean; @@ -82,28 +61,26 @@ 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; } /** * Basic action shape used throughout the library. * * @remarks - * Actions are plain objects with a `type` string that identifies the action. - * This follows the Flux Standard Action pattern. - * - * @see {@link AnyAction} for actions with optional payload/meta. - * @see {@link ActionWithPayload} for actions with typed payload. + * Actions are plain objects with a `type` string that identifies behavior. */ export interface Action { /** Action type string */ type: string; + // biome-ignore lint/suspicious/noExplicitAny: allow anything from the user [extraProps: string]: any; } @@ -115,33 +92,30 @@ export type ActionFn = () => { toString: () => string }; /** * An action creator that accepts a payload. */ -export type ActionFnWithPayload

= (p: P) => { toString: () => string }; +export type ActionFnWithPayload = ( + p: PayloadContent, +) => { + toString: () => string; +}; // https://github.com/redux-utilities/flux-standard-action /** * Flux Standard Action (FSA) compatible action type. * * @remarks - * Extends {@link Action} with optional FSA properties: - * - `payload` - The action's data payload - * - `meta` - Additional metadata - * - `error` - If true, `payload` is an Error object - * - * While not strictly required, keeping actions JSON serializable is - * highly recommended for debugging and time-travel features. - * - * @see {@link https://github.com/redux-utilities/flux-standard-action | FSA Spec} + * Extends {@link Action} with optional payload/meta/error fields. */ 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; } /** * AnyAction with an explicitly typed `payload`. */ -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, 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/**"] +}