├── src ├── async │ ├── index.ts │ ├── marker.ts │ ├── create-duck.ts │ ├── types.ts │ ├── retry.ts │ └── public.ts ├── create │ ├── index.ts │ ├── create-store.ts │ ├── utils.ts │ ├── extractor.ts │ ├── proxy.ts │ ├── manager.ts │ ├── __tests__ │ │ ├── manager.test.ts │ │ └── proxy.test.ts │ └── cleaner.ts ├── entities │ ├── cleaner │ │ ├── index.ts │ │ ├── bucket.ts │ │ ├── walker.ts │ │ ├── store.ts │ │ └── __tests__ │ │ │ └── walker.test.ts │ ├── record │ │ ├── marker.ts │ │ ├── types.ts │ │ ├── index.ts │ │ ├── create-entity-record.ts │ │ ├── public.ts │ │ └── entity-record.ts │ ├── constants.ts │ ├── index.ts │ ├── collection │ │ ├── marker.ts │ │ ├── index.ts │ │ ├── create-entity-collection.ts │ │ ├── types.ts │ │ ├── public.ts │ │ └── __tests__ │ │ │ └── multiEntityCollection.test.ts │ ├── create-entity-schema.ts │ ├── schema.ts │ ├── types.ts │ └── public.ts ├── utils │ ├── index.ts │ ├── throttle-by-time.ts │ └── __tests__ │ │ └── throttle-by-time.test.ts ├── constants │ ├── values.ts │ └── time.ts ├── root │ ├── marker.ts │ ├── index.ts │ ├── fallback.ts │ ├── coreApi │ │ ├── lifecycle.ts │ │ ├── create-core-api.ts │ │ ├── stores.ts │ │ ├── types.ts │ │ └── entities.ts │ ├── create-root-store.ts │ ├── public.ts │ └── types.ts ├── di │ ├── index.ts │ └── register-root-store.ts ├── index.ts └── create-store-hooks.ts ├── examples ├── mobile │ ├── assets │ │ ├── icon.png │ │ ├── favicon.png │ │ ├── adaptive-icon.png │ │ └── splash-icon.png │ ├── tsconfig.json │ ├── tsconfig.eslint.json │ ├── src │ │ ├── stores │ │ │ ├── viewer │ │ │ │ ├── dto.ts │ │ │ │ ├── schema.ts │ │ │ │ ├── model.ts │ │ │ │ └── store.ts │ │ │ ├── register.ts │ │ │ ├── hooks.ts │ │ │ ├── comments │ │ │ │ ├── dto.ts │ │ │ │ ├── schema.ts │ │ │ │ ├── model.ts │ │ │ │ └── store.ts │ │ │ ├── schema-map.ts │ │ │ ├── posts │ │ │ │ ├── schema.ts │ │ │ │ ├── dto.ts │ │ │ │ ├── model.ts │ │ │ │ └── store.ts │ │ │ └── root-store.ts │ │ ├── api │ │ │ ├── utils.ts │ │ │ ├── index.ts │ │ │ ├── dto.ts │ │ │ ├── comments.ts │ │ │ ├── posts.ts │ │ │ ├── viewer.ts │ │ │ └── db.ts │ │ ├── constants.ts │ │ ├── screens │ │ │ ├── Posts │ │ │ │ ├── post-card.tsx │ │ │ │ ├── styles.ts │ │ │ │ └── index.tsx │ │ │ ├── Viewer │ │ │ │ ├── viewer-card.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ └── Comments │ │ │ │ ├── comment-item.tsx │ │ │ │ ├── styles.ts │ │ │ │ └── index.tsx │ │ └── index.tsx │ ├── README.md │ ├── index.ts │ ├── App.tsx │ ├── .gitignore │ ├── eslint.config.js │ ├── app.json │ └── package.json └── web-react │ ├── tsconfig.eslint.json │ ├── tsconfig.json │ ├── src │ ├── stores │ │ ├── viewer │ │ │ ├── dto.ts │ │ │ ├── schema.ts │ │ │ ├── model.ts │ │ │ └── store.ts │ │ ├── register.ts │ │ ├── hooks.ts │ │ ├── comments │ │ │ ├── dto.ts │ │ │ ├── schema.ts │ │ │ ├── model.ts │ │ │ └── store.ts │ │ ├── schema-map.ts │ │ ├── posts │ │ │ ├── schema.ts │ │ │ ├── dto.ts │ │ │ ├── model.ts │ │ │ └── store.ts │ │ └── root-store.ts │ ├── api │ │ ├── utils.ts │ │ ├── index.ts │ │ ├── dto.ts │ │ ├── comments.ts │ │ ├── posts.ts │ │ ├── viewer.ts │ │ └── db.ts │ ├── main.tsx │ ├── constants.ts │ ├── examples │ │ ├── comments-by-post │ │ │ ├── CommentItem.tsx │ │ │ └── CommentsByPostExample.tsx │ │ ├── posts-pagination │ │ │ ├── PostCard.tsx │ │ │ └── PostsPaginationExample.tsx │ │ └── viewer-records │ │ │ ├── ViewerCard.tsx │ │ │ └── ViewerRecordsExample.tsx │ ├── App.css │ ├── App.tsx │ └── index.css │ ├── vite.config.ts │ ├── README.md │ ├── .gitignore │ ├── index.html │ ├── tsconfig.node.json │ ├── tsconfig.app.json │ ├── eslint.config.js │ ├── package.json │ └── public │ └── vite.svg ├── tsconfig.eslint.json ├── tsconfig.test.json ├── prettier.config.mjs ├── .gitignore ├── tsconfig.base.json ├── jest.config.cjs ├── docs ├── docs │ ├── 14-testing.md │ ├── 13-anti-patterns.md │ ├── 12-react-integration.md │ ├── 11-real-world-flows.md │ ├── 10-store-deps.md │ ├── 06-multi-collection.md │ ├── 09-async-ducks.md │ ├── 07-schemas.md │ ├── 02-architecture-overview.md │ ├── 08-models-and-entity-getter.md │ ├── 04-entity-record.md │ ├── 05-entity-collection.md │ ├── 03-entities-store.md │ └── 01-mental-model.md └── api │ ├── entity-getter.md │ ├── multi-collection.md │ ├── store-deps.md │ ├── create-entity-schema.md │ ├── entity-record.md │ ├── create-root-store.md │ └── react-hooks.md ├── tsconfig.json ├── .github └── workflows │ ├── release.yml │ └── ci.yml ├── CHANGELOG.md ├── LICENSE ├── package.json ├── eslint.config.js └── CONTRIBUTING.md /src/async/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-duck'; 2 | -------------------------------------------------------------------------------- /src/create/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-store'; 2 | -------------------------------------------------------------------------------- /src/entities/cleaner/index.ts: -------------------------------------------------------------------------------- 1 | export * from './store'; 2 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './throttle-by-time'; 2 | -------------------------------------------------------------------------------- /src/async/marker.ts: -------------------------------------------------------------------------------- 1 | export const DUCK_TAG = Symbol('AsyncDuck'); 2 | -------------------------------------------------------------------------------- /src/constants/values.ts: -------------------------------------------------------------------------------- 1 | export const META = '_meta_nexigen'; 2 | -------------------------------------------------------------------------------- /src/entities/record/marker.ts: -------------------------------------------------------------------------------- 1 | export const RECORD_TAG = Symbol('record'); 2 | -------------------------------------------------------------------------------- /src/root/marker.ts: -------------------------------------------------------------------------------- 1 | export const SET_PERSISTENCE = Symbol('SET_PERSISTENCE'); 2 | -------------------------------------------------------------------------------- /src/entities/record/types.ts: -------------------------------------------------------------------------------- 1 | export type EntityRecordSnapshot = { 2 | id: string | number | null; 3 | }; 4 | -------------------------------------------------------------------------------- /src/di/index.ts: -------------------------------------------------------------------------------- 1 | import { registerRootStore } from './register-root-store'; 2 | 3 | export { registerRootStore }; 4 | -------------------------------------------------------------------------------- /src/entities/constants.ts: -------------------------------------------------------------------------------- 1 | export const PREFIX = { 2 | COLLECTION: 'collection:', 3 | RECORD: 'record:', 4 | }; 5 | -------------------------------------------------------------------------------- /examples/mobile/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nexigenjs/entity-normalizer/HEAD/examples/mobile/assets/icon.png -------------------------------------------------------------------------------- /src/entities/record/index.ts: -------------------------------------------------------------------------------- 1 | import type { EntityRecord } from './entity-record'; 2 | 3 | export type { EntityRecord }; 4 | -------------------------------------------------------------------------------- /examples/mobile/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nexigenjs/entity-normalizer/HEAD/examples/mobile/assets/favicon.png -------------------------------------------------------------------------------- /examples/mobile/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /examples/mobile/assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nexigenjs/entity-normalizer/HEAD/examples/mobile/assets/adaptive-icon.png -------------------------------------------------------------------------------- /examples/mobile/assets/splash-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nexigenjs/entity-normalizer/HEAD/examples/mobile/assets/splash-icon.png -------------------------------------------------------------------------------- /src/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './collection'; 2 | export * from './types'; 3 | export * from './schema'; 4 | export * from './record'; 5 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/__tests__/**/*.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /src/constants/time.ts: -------------------------------------------------------------------------------- 1 | export const TIME = { 2 | SECOND: 1000, 3 | MINUTE: 60 * 1000, 4 | HOUR: 60 * 60 * 1000, 5 | DAY: 24 * 60 * 60 * 1000, 6 | }; 7 | -------------------------------------------------------------------------------- /src/entities/collection/marker.ts: -------------------------------------------------------------------------------- 1 | export const COLLECTION_TAG = Symbol('EntityCollection'); 2 | export const MULTI_COLLECTION_TAG = Symbol('MultiEntityCollection'); 3 | -------------------------------------------------------------------------------- /examples/mobile/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src/**/*"], 4 | "compilerOptions": { 5 | "noEmit": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/web-react/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src/**/*"], 4 | "compilerOptions": { 5 | "noEmit": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/web-react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src/root/index.ts: -------------------------------------------------------------------------------- 1 | import { createRootStore } from './create-root-store'; 2 | 3 | import type { StoreDeps } from './types'; 4 | 5 | export { createRootStore, type StoreDeps }; 6 | -------------------------------------------------------------------------------- /examples/mobile/src/stores/viewer/dto.ts: -------------------------------------------------------------------------------- 1 | export interface ViewerDto { 2 | id: string; 3 | name: string; 4 | 5 | email?: string; 6 | avatarUrl?: string; 7 | bio?: string; 8 | } 9 | -------------------------------------------------------------------------------- /examples/web-react/src/stores/viewer/dto.ts: -------------------------------------------------------------------------------- 1 | export interface ViewerDto { 2 | id: string; 3 | name: string; 4 | 5 | email?: string; 6 | avatarUrl?: string; 7 | bio?: string; 8 | } 9 | -------------------------------------------------------------------------------- /examples/mobile/src/stores/register.ts: -------------------------------------------------------------------------------- 1 | import { registerRootStore } from '@nexigen/entity-normalizer'; 2 | 3 | import { rootStore } from './root-store'; 4 | 5 | registerRootStore(rootStore); 6 | -------------------------------------------------------------------------------- /examples/web-react/src/stores/register.ts: -------------------------------------------------------------------------------- 1 | import { registerRootStore } from '@nexigen/entity-normalizer'; 2 | 3 | import { rootStore } from './root-store'; 4 | 5 | registerRootStore(rootStore); 6 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["jest", "node"] 5 | }, 6 | "include": ["src/**/*.test.ts", "src/**/__tests__/**/*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /examples/web-react/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | // https://vite.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }); 8 | -------------------------------------------------------------------------------- /examples/mobile/src/api/utils.ts: -------------------------------------------------------------------------------- 1 | export const delay = (ms: number) => 2 | new Promise(resolve => setTimeout(resolve, ms)); 3 | 4 | export const randomFrom = (arr: T[]): T => 5 | arr[Math.floor(Math.random() * arr.length)]; 6 | -------------------------------------------------------------------------------- /examples/web-react/src/api/utils.ts: -------------------------------------------------------------------------------- 1 | export const delay = (ms: number) => 2 | new Promise(resolve => setTimeout(resolve, ms)); 3 | 4 | export const randomFrom = (arr: T[]): T => 5 | arr[Math.floor(Math.random() * arr.length)]; 6 | -------------------------------------------------------------------------------- /src/root/fallback.ts: -------------------------------------------------------------------------------- 1 | import type { PersistenceNotifier } from './types'; 2 | 3 | export const noopPersistence: PersistenceNotifier = { 4 | onEntitiesChanged() {}, 5 | onPointersChanged() {}, 6 | onStoreStateChanged() {}, 7 | }; 8 | -------------------------------------------------------------------------------- /src/entities/collection/index.ts: -------------------------------------------------------------------------------- 1 | import type { EntityCollection } from './entity-collection'; 2 | import type { MultiCollection as MultiEntityCollection } from './types'; 3 | 4 | export type { EntityCollection, MultiEntityCollection }; 5 | -------------------------------------------------------------------------------- /examples/mobile/README.md: -------------------------------------------------------------------------------- 1 | ## React Native (Expo) Example 2 | 3 | ### Install 4 | 5 | ```bash 6 | yarn install 7 | ``` 8 | 9 | ### Run 10 | 11 | ```bash 12 | yarn start 13 | ``` 14 | 15 | ### Requirements 16 | 17 | - Node >= 18 18 | - Expo Go app or iOS simulator 19 | -------------------------------------------------------------------------------- /examples/mobile/src/stores/hooks.ts: -------------------------------------------------------------------------------- 1 | import { createStoreHooks } from '@nexigen/entity-normalizer'; 2 | 3 | import { type AppRootStore } from './root-store'; 4 | 5 | export const { useStores, useServices, useStore, useService, useCore } = 6 | createStoreHooks(); 7 | -------------------------------------------------------------------------------- /prettier.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | singleQuote: true, 3 | trailingComma: 'all', 4 | arrowParens: 'avoid', 5 | printWidth: 80, 6 | tabWidth: 2, 7 | semi: true, 8 | bracketSpacing: true, 9 | jsxSingleQuote: false, 10 | endOfLine: 'lf', 11 | }; 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules/ 3 | 4 | # build 5 | dist/ 6 | 7 | # logs 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | 12 | # OS 13 | .DS_Store 14 | 15 | # editor 16 | .idea/ 17 | .vscode/ 18 | 19 | # yarn (classic) 20 | .yarn-integrity 21 | -------------------------------------------------------------------------------- /examples/mobile/src/api/index.ts: -------------------------------------------------------------------------------- 1 | import { CommentsApi } from './comments'; 2 | import { PostsApi } from './posts'; 3 | import { ViewerApi } from './viewer'; 4 | 5 | export const Api = { 6 | Viewer: ViewerApi, 7 | Posts: PostsApi, 8 | Comments: CommentsApi, 9 | }; 10 | -------------------------------------------------------------------------------- /examples/web-react/src/api/index.ts: -------------------------------------------------------------------------------- 1 | import { CommentsApi } from './comments'; 2 | import { PostsApi } from './posts'; 3 | import { ViewerApi } from './viewer'; 4 | 5 | export const Api = { 6 | Viewer: ViewerApi, 7 | Posts: PostsApi, 8 | Comments: CommentsApi, 9 | }; 10 | -------------------------------------------------------------------------------- /examples/web-react/src/stores/hooks.ts: -------------------------------------------------------------------------------- 1 | import { createStoreHooks } from '@nexigen/entity-normalizer'; 2 | 3 | import { type AppRootStore } from './root-store'; 4 | 5 | export const { useStores, useServices, useStore, useService, useCore } = 6 | createStoreHooks(); 7 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "skipLibCheck": true, 5 | "forceConsistentCasingInFileNames": true, 6 | 7 | "allowSyntheticDefaultImports": true, 8 | "esModuleInterop": true, 9 | "resolveJsonModule": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/web-react/README.md: -------------------------------------------------------------------------------- 1 | ## Web React Example 2 | 3 | ### Install 4 | 5 | ```bash 6 | yarn install 7 | ``` 8 | 9 | ### Run 10 | 11 | ```bash 12 | yarn dev 13 | ``` 14 | 15 | ### What this example shows 16 | 17 | - Entity records 18 | - Collections 19 | - Entity relations 20 | - Pagination 21 | -------------------------------------------------------------------------------- /examples/mobile/src/stores/comments/dto.ts: -------------------------------------------------------------------------------- 1 | import type { ViewerDto } from '../viewer/dto'; 2 | 3 | export interface CommentDto { 4 | id: string; 5 | text: string; 6 | viewer: ViewerDto; 7 | } 8 | 9 | export interface CommentNormalizedDto { 10 | id: string; 11 | text: string; 12 | viewerId: string; 13 | } 14 | -------------------------------------------------------------------------------- /examples/web-react/src/stores/comments/dto.ts: -------------------------------------------------------------------------------- 1 | import type { ViewerDto } from '../viewer/dto'; 2 | 3 | export interface CommentDto { 4 | id: string; 5 | text: string; 6 | viewer: ViewerDto; 7 | } 8 | 9 | export interface CommentNormalizedDto { 10 | id: string; 11 | text: string; 12 | viewerId: string; 13 | } 14 | -------------------------------------------------------------------------------- /examples/web-react/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import './stores/register'; 3 | import { createRoot } from 'react-dom/client'; 4 | 5 | import './index.css'; 6 | import App from './App.tsx'; 7 | 8 | createRoot(document.getElementById('root')!).render( 9 | 10 | 11 | , 12 | ); 13 | -------------------------------------------------------------------------------- /examples/mobile/index.ts: -------------------------------------------------------------------------------- 1 | import { registerRootComponent } from 'expo'; 2 | 3 | import App from './App'; 4 | 5 | // registerRootComponent calls AppRegistry.registerComponent('main', () => App); 6 | // It also ensures that whether you load the app in Expo Go or in a native build, 7 | // the environment is set up appropriately 8 | registerRootComponent(App); 9 | -------------------------------------------------------------------------------- /src/create/create-store.ts: -------------------------------------------------------------------------------- 1 | import { StoreManager } from './manager'; 2 | 3 | import type { DomainDeps, StoreDepsCombined } from '../root/types'; 4 | 5 | export function createStore( 6 | StoreClass: new (deps: DomainDeps) => TStore, 7 | deps: StoreDepsCombined, 8 | ): TStore { 9 | return new StoreManager(deps).create(StoreClass); 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/throttle-by-time.ts: -------------------------------------------------------------------------------- 1 | export function throttleByTime void>( 2 | fn: T, 3 | interval: number, 4 | ) { 5 | let lastRun = 0; 6 | 7 | return (...args: Parameters) => { 8 | const now = Date.now(); 9 | 10 | if (now - lastRun >= interval) { 11 | lastRun = now; 12 | fn(...args); 13 | } 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /examples/web-react/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /examples/mobile/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const ENTITY_KEY = { 2 | COMMENTS: 'comments', 3 | VIEWERS: 'viewers', 4 | POSTS: 'posts', 5 | } as const; 6 | 7 | export const REF_SOURCE = { 8 | //collections 9 | POSTS_FEED: 'posts-feed', 10 | COMMENTS_FEED: 'comments-feed', 11 | 12 | //records 13 | CURRENT_VIEWER: 'current_viewer', 14 | VIEWER_DETAILS: 'viewer_details', 15 | }; 16 | -------------------------------------------------------------------------------- /examples/mobile/src/stores/viewer/schema.ts: -------------------------------------------------------------------------------- 1 | import { createEntitySchema } from '@nexigen/entity-normalizer'; 2 | 3 | import { ViewerModel } from './model'; 4 | import { ENTITY_KEY } from '../../constants'; 5 | 6 | export const viewerSchema = createEntitySchema( 7 | ENTITY_KEY.VIEWERS, 8 | {}, 9 | { 10 | model: ViewerModel, 11 | idAttribute: props => props.id, 12 | }, 13 | ); 14 | -------------------------------------------------------------------------------- /examples/web-react/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const ENTITY_KEY = { 2 | COMMENTS: 'comments', 3 | VIEWERS: 'viewers', 4 | POSTS: 'posts', 5 | } as const; 6 | 7 | export const REF_SOURCE = { 8 | //collections 9 | POSTS_FEED: 'posts-feed', 10 | COMMENTS_FEED: 'comments-feed', 11 | 12 | //records 13 | CURRENT_VIEWER: 'current_viewer', 14 | VIEWER_DETAILS: 'viewer_details', 15 | }; 16 | -------------------------------------------------------------------------------- /examples/web-react/src/stores/viewer/schema.ts: -------------------------------------------------------------------------------- 1 | import { createEntitySchema } from '@nexigen/entity-normalizer'; 2 | 3 | import { ViewerModel } from './model'; 4 | import { ENTITY_KEY } from '../../constants'; 5 | 6 | export const viewerSchema = createEntitySchema( 7 | ENTITY_KEY.VIEWERS, 8 | {}, 9 | { 10 | model: ViewerModel, 11 | idAttribute: props => props.id, 12 | }, 13 | ); 14 | -------------------------------------------------------------------------------- /src/root/coreApi/lifecycle.ts: -------------------------------------------------------------------------------- 1 | import type { CoreLifecycleAPI } from './types'; 2 | 3 | export function createLifecycleAPI(deps: { 4 | getIsInitialized: () => boolean; 5 | setInitialized: (v: boolean) => void; 6 | }): CoreLifecycleAPI { 7 | return { 8 | get isInitialized() { 9 | return deps.getIsInitialized(); 10 | }, 11 | setInitialized: deps.setInitialized, 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /examples/mobile/src/stores/schema-map.ts: -------------------------------------------------------------------------------- 1 | import { ENTITY_KEY } from '../constants'; 2 | import { commentSchema } from './comments/schema'; 3 | import { postSchema } from './posts/schema'; 4 | import { viewerSchema } from './viewer/schema'; 5 | 6 | export const schemaMap = { 7 | [ENTITY_KEY.VIEWERS]: viewerSchema, 8 | [ENTITY_KEY.COMMENTS]: commentSchema, 9 | [ENTITY_KEY.POSTS]: postSchema, 10 | }; 11 | -------------------------------------------------------------------------------- /examples/web-react/src/stores/schema-map.ts: -------------------------------------------------------------------------------- 1 | import { ENTITY_KEY } from '../constants'; 2 | import { commentSchema } from './comments/schema'; 3 | import { postSchema } from './posts/schema'; 4 | import { viewerSchema } from './viewer/schema'; 5 | 6 | export const schemaMap = { 7 | [ENTITY_KEY.VIEWERS]: viewerSchema, 8 | [ENTITY_KEY.COMMENTS]: commentSchema, 9 | [ENTITY_KEY.POSTS]: postSchema, 10 | }; 11 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('jest').Config} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | 6 | testMatch: ['**/__tests__/**/*.test.ts', '**/*.test.ts'], 7 | setupFilesAfterEnv: [], 8 | 9 | clearMocks: true, 10 | restoreMocks: true, 11 | 12 | collectCoverageFrom: [ 13 | 'src/**/*.ts', 14 | '!src/**/*.test.ts', 15 | '!src/**/__tests__/**', 16 | ], 17 | }; 18 | -------------------------------------------------------------------------------- /examples/web-react/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | web-react 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/mobile/App.tsx: -------------------------------------------------------------------------------- 1 | import './src/stores/register'; 2 | import { StatusBar } from 'expo-status-bar'; 3 | import { StyleSheet, View } from 'react-native'; 4 | import { Tabs } from './src'; 5 | 6 | export default function App() { 7 | return ( 8 | 9 | 10 | 11 | 12 | ); 13 | } 14 | 15 | const styles = StyleSheet.create({ 16 | container: { 17 | flex: 1, 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /examples/mobile/src/stores/comments/schema.ts: -------------------------------------------------------------------------------- 1 | import { createEntitySchema } from '@nexigen/entity-normalizer'; 2 | 3 | import { CommentModel } from './model'; 4 | import { ENTITY_KEY } from '../../constants'; 5 | import { viewerSchema } from '../viewer/schema'; 6 | 7 | export const commentSchema = createEntitySchema( 8 | ENTITY_KEY.COMMENTS, 9 | { 10 | viewer: viewerSchema, 11 | }, 12 | { 13 | model: CommentModel, 14 | idAttribute: props => props.id, 15 | }, 16 | ); 17 | -------------------------------------------------------------------------------- /examples/web-react/src/stores/comments/schema.ts: -------------------------------------------------------------------------------- 1 | import { createEntitySchema } from '@nexigen/entity-normalizer'; 2 | 3 | import { CommentModel } from './model'; 4 | import { ENTITY_KEY } from '../../constants'; 5 | import { viewerSchema } from '../viewer/schema'; 6 | 7 | export const commentSchema = createEntitySchema( 8 | ENTITY_KEY.COMMENTS, 9 | { 10 | viewer: viewerSchema, 11 | }, 12 | { 13 | model: CommentModel, 14 | idAttribute: props => props.id, 15 | }, 16 | ); 17 | -------------------------------------------------------------------------------- /examples/mobile/src/api/dto.ts: -------------------------------------------------------------------------------- 1 | export type PostGroup = 'fresh' | 'archived'; 2 | 3 | export interface ViewerDto { 4 | id: string; 5 | name: string; 6 | 7 | email?: string; 8 | avatarUrl?: string; 9 | bio?: string; 10 | } 11 | 12 | export interface CommentDto { 13 | id: string; 14 | text: string; 15 | viewer: ViewerDto; 16 | } 17 | 18 | export interface PostDto { 19 | id: string; 20 | title: string; 21 | viewer: ViewerDto; 22 | comments: CommentDto[]; 23 | group: PostGroup; 24 | } 25 | -------------------------------------------------------------------------------- /examples/web-react/src/api/dto.ts: -------------------------------------------------------------------------------- 1 | export type PostGroup = 'fresh' | 'archived'; 2 | 3 | export interface ViewerDto { 4 | id: string; 5 | name: string; 6 | 7 | email?: string; 8 | avatarUrl?: string; 9 | bio?: string; 10 | } 11 | 12 | export interface CommentDto { 13 | id: string; 14 | text: string; 15 | viewer: ViewerDto; 16 | } 17 | 18 | export interface PostDto { 19 | id: string; 20 | title: string; 21 | viewer: ViewerDto; 22 | comments: CommentDto[]; 23 | group: PostGroup; 24 | } 25 | -------------------------------------------------------------------------------- /src/entities/create-entity-schema.ts: -------------------------------------------------------------------------------- 1 | import { EntitySchema } from './schema'; 2 | 3 | import type { 4 | PublicEntitySchema, 5 | EntitySchemaConfig, 6 | EntitySchemaDefinition, 7 | } from './public'; 8 | 9 | export function createEntitySchema( 10 | key: string, 11 | definition: EntitySchemaDefinition = {}, 12 | config: EntitySchemaConfig = {}, 13 | ): PublicEntitySchema { 14 | return new EntitySchema(key, definition as any, config as any); 15 | } 16 | -------------------------------------------------------------------------------- /docs/docs/14-testing.md: -------------------------------------------------------------------------------- 1 | # Testing Strategy 2 | 3 | Nexigen is designed to be tested at the data layer. 4 | 5 | --- 6 | 7 | ## Current Coverage 8 | 9 | Test Suites: 17 passed, 17 total 10 | Tests: 154 passed, 154 total 11 | 12 | --- 13 | 14 | ## What Is Tested 15 | 16 | - normalization 17 | - merge semantics 18 | - collections behavior 19 | - refSource cleanup 20 | - async ducks 21 | 22 | --- 23 | 24 | ## Testing Philosophy 25 | 26 | - deterministic behavior 27 | - no mocks for core logic 28 | - reproducible edge cases 29 | -------------------------------------------------------------------------------- /docs/docs/13-anti-patterns.md: -------------------------------------------------------------------------------- 1 | # Anti-Patterns 2 | 3 | Violating these rules breaks Nexigen guarantees. 4 | 5 | --- 6 | 7 | ## Common Mistakes 8 | 9 | - storing DTOs in stores 10 | - cloning entity data 11 | - mutating entities outside models 12 | - bypassing collections 13 | 14 | --- 15 | 16 | ## Symptoms 17 | 18 | - stale UI 19 | - duplicated data 20 | - memory growth 21 | - unpredictable behavior 22 | 23 | --- 24 | 25 | ## Fix 26 | 27 | Return to: 28 | 29 | - entities as single source of truth 30 | - stores as orchestrators 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | 4 | "compilerOptions": { 5 | "target": "ES2020", 6 | "module": "ESNext", 7 | "moduleResolution": "Node", 8 | 9 | "declaration": true, 10 | "outDir": "dist", 11 | "sourceMap": true, 12 | 13 | "baseUrl": ".", 14 | "paths": { 15 | "@nexigen/*": ["src/*"] 16 | } 17 | }, 18 | 19 | "include": ["src"], 20 | "exclude": [ 21 | "node_modules", 22 | "dist", 23 | "**/*.test.ts", 24 | "**/*.spec.ts", 25 | "**/__tests__/**" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /examples/mobile/src/stores/posts/schema.ts: -------------------------------------------------------------------------------- 1 | import { createEntitySchema } from '@nexigen/entity-normalizer'; 2 | 3 | import { PostModel } from './model'; 4 | import { ENTITY_KEY } from '../../constants'; 5 | import { commentSchema } from '../comments/schema'; 6 | import { viewerSchema } from '../viewer/schema'; 7 | 8 | export const postSchema = createEntitySchema( 9 | ENTITY_KEY.POSTS, 10 | { 11 | viewer: viewerSchema, 12 | comments: [commentSchema], 13 | }, 14 | { 15 | model: PostModel, 16 | idAttribute: props => props.id, 17 | }, 18 | ); 19 | -------------------------------------------------------------------------------- /examples/mobile/src/stores/viewer/model.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from 'mobx'; 2 | 3 | import type { ViewerDto } from './dto'; 4 | 5 | export class ViewerModel { 6 | id: string; 7 | name: string; 8 | bio: string; 9 | avatarUrl: string; 10 | email: string; 11 | 12 | constructor(dto: ViewerDto) { 13 | this.id = dto.id; 14 | this.name = dto.name; 15 | this.bio = dto.bio ?? ''; 16 | this.avatarUrl = dto.avatarUrl ?? ''; 17 | this.email = dto.email ?? ''; 18 | 19 | makeAutoObservable(this, {}, { autoBind: true }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/web-react/src/stores/posts/schema.ts: -------------------------------------------------------------------------------- 1 | import { createEntitySchema } from '@nexigen/entity-normalizer'; 2 | 3 | import { PostModel } from './model'; 4 | import { ENTITY_KEY } from '../../constants'; 5 | import { commentSchema } from '../comments/schema'; 6 | import { viewerSchema } from '../viewer/schema'; 7 | 8 | export const postSchema = createEntitySchema( 9 | ENTITY_KEY.POSTS, 10 | { 11 | viewer: viewerSchema, 12 | comments: [commentSchema], 13 | }, 14 | { 15 | model: PostModel, 16 | idAttribute: props => props.id, 17 | }, 18 | ); 19 | -------------------------------------------------------------------------------- /examples/web-react/src/stores/viewer/model.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from 'mobx'; 2 | 3 | import type { ViewerDto } from './dto'; 4 | 5 | export class ViewerModel { 6 | id: string; 7 | name: string; 8 | bio: string; 9 | avatarUrl: string; 10 | email: string; 11 | 12 | constructor(dto: ViewerDto) { 13 | this.id = dto.id; 14 | this.name = dto.name; 15 | this.bio = dto.bio ?? ''; 16 | this.avatarUrl = dto.avatarUrl ?? ''; 17 | this.email = dto.email ?? ''; 18 | 19 | makeAutoObservable(this, {}, { autoBind: true }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/mobile/src/stores/posts/dto.ts: -------------------------------------------------------------------------------- 1 | import type { CommentDto } from '../comments/dto'; 2 | import type { ViewerDto } from '../viewer/dto'; 3 | 4 | export type PostGroup = 'fresh' | 'archived'; 5 | 6 | export interface PostDto { 7 | id: string; 8 | title: string; 9 | viewer: ViewerDto; 10 | comments: CommentDto[]; 11 | group: PostGroup; 12 | } 13 | 14 | // ----------------- 15 | // NORMALIZED DTO 16 | // ----------------- 17 | 18 | export interface PostNormalizedDto { 19 | id: string; 20 | title: string; 21 | viewerId: string; 22 | commentsId: string[]; 23 | } 24 | -------------------------------------------------------------------------------- /examples/web-react/src/examples/comments-by-post/CommentItem.tsx: -------------------------------------------------------------------------------- 1 | import type { CommentModel } from '../../stores/comments/model'; 2 | 3 | export function CommentItem({ comment }: { comment: CommentModel }) { 4 | return ( 5 |
13 |

{comment.text}

14 | 15 | 16 | by {comment.viewer?.name ?? '—'} 17 | 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /examples/web-react/src/stores/posts/dto.ts: -------------------------------------------------------------------------------- 1 | import type { CommentDto } from '../comments/dto'; 2 | import type { ViewerDto } from '../viewer/dto'; 3 | 4 | export type PostGroup = 'fresh' | 'archived'; 5 | 6 | export interface PostDto { 7 | id: string; 8 | title: string; 9 | viewer: ViewerDto; 10 | comments: CommentDto[]; 11 | group: PostGroup; 12 | } 13 | 14 | // ----------------- 15 | // NORMALIZED DTO 16 | // ----------------- 17 | 18 | export interface PostNormalizedDto { 19 | id: string; 20 | title: string; 21 | viewerId: string; 22 | commentsId: string[]; 23 | } 24 | -------------------------------------------------------------------------------- /examples/mobile/src/api/comments.ts: -------------------------------------------------------------------------------- 1 | import { commentsByPost } from './db'; 2 | import { delay } from './utils'; 3 | 4 | import type { CommentDto } from './dto'; 5 | 6 | export const CommentsApi = { 7 | async getCommentsByPost({ 8 | postId, 9 | page, 10 | limit, 11 | }: { 12 | postId: string; 13 | page: number; 14 | limit: number; 15 | }): Promise { 16 | await delay(500); 17 | 18 | const all = commentsByPost[postId] ?? []; 19 | const start = (page - 1) * limit; 20 | 21 | return all.slice(start, start + limit); 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /examples/mobile/src/api/posts.ts: -------------------------------------------------------------------------------- 1 | import { posts } from './db'; 2 | import { delay } from './utils'; 3 | 4 | import type { PostDto, PostGroup } from './dto'; 5 | 6 | export const PostsApi = { 7 | async getPosts({ 8 | page, 9 | limit, 10 | group, 11 | }: { 12 | page: number; 13 | limit: number; 14 | group: PostGroup; 15 | }): Promise { 16 | await delay(600); 17 | 18 | const filtered = posts.filter(p => p.group === group); 19 | const start = (page - 1) * limit; 20 | 21 | return filtered.slice(start, start + limit); 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /examples/web-react/src/api/comments.ts: -------------------------------------------------------------------------------- 1 | import { commentsByPost } from './db'; 2 | import { delay } from './utils'; 3 | 4 | import type { CommentDto } from './dto'; 5 | 6 | export const CommentsApi = { 7 | async getCommentsByPost({ 8 | postId, 9 | page, 10 | limit, 11 | }: { 12 | postId: string; 13 | page: number; 14 | limit: number; 15 | }): Promise { 16 | await delay(500); 17 | 18 | const all = commentsByPost[postId] ?? []; 19 | const start = (page - 1) * limit; 20 | 21 | return all.slice(start, start + limit); 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /examples/web-react/src/api/posts.ts: -------------------------------------------------------------------------------- 1 | import { posts } from './db'; 2 | import { delay } from './utils'; 3 | 4 | import type { PostDto, PostGroup } from './dto'; 5 | 6 | export const PostsApi = { 7 | async getPosts({ 8 | page, 9 | limit, 10 | group, 11 | }: { 12 | page: number; 13 | limit: number; 14 | group: PostGroup; 15 | }): Promise { 16 | await delay(600); 17 | 18 | const filtered = posts.filter(p => p.group === group); 19 | const start = (page - 1) * limit; 20 | 21 | return filtered.slice(start, start + limit); 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /src/async/create-duck.ts: -------------------------------------------------------------------------------- 1 | import { AsyncDuck } from './async-duck'; 2 | 3 | import type { PublicAsyncDuck } from './public'; 4 | 5 | export function createDuck Promise>(fn: TFn) { 6 | type TParams = TFn extends (params: infer P) => any ? P : never; 7 | type TResult = TFn extends (...args: any[]) => Promise ? R : never; 8 | 9 | const duck = new AsyncDuck(fn); 10 | 11 | return duck.proxy as unknown as Readonly< 12 | PublicAsyncDuck & 13 | Record> 14 | >; 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 20.x 17 | registry-url: https://registry.npmjs.org/ 18 | cache: yarn 19 | 20 | - run: yarn install --frozen-lockfile 21 | - run: yarn build 22 | 23 | - run: npm publish --access public 24 | env: 25 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 26 | -------------------------------------------------------------------------------- /src/root/create-root-store.ts: -------------------------------------------------------------------------------- 1 | import { RootStore } from './RootStore'; 2 | 3 | import type { CreateRootStoreConfig } from './public'; 4 | 5 | export function createRootStore< 6 | TApi, 7 | TSchemas extends Record, 8 | TStores extends Record, 9 | TServices extends Record, 10 | >(config: CreateRootStoreConfig) { 11 | return new RootStore({ 12 | api: config.api, 13 | schemaMap: config.schemaMap, 14 | stores: config.stores, 15 | services: config.services, 16 | plugins: config.plugins ?? [], 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /examples/mobile/src/stores/root-store.ts: -------------------------------------------------------------------------------- 1 | import { createRootStore } from '@nexigen/entity-normalizer'; 2 | 3 | import { Api } from '../api'; 4 | import { CommentsStore } from './comments/store'; 5 | import { PostsStore } from './posts/store'; 6 | import { schemaMap } from './schema-map'; 7 | import { ViewerStore } from './viewer/store'; 8 | 9 | export const rootStore = createRootStore({ 10 | api: Api, 11 | schemaMap, 12 | stores: { 13 | viewer: ViewerStore, 14 | posts: PostsStore, 15 | comments: CommentsStore, 16 | }, 17 | services: {}, 18 | }); 19 | 20 | export type AppRootStore = typeof rootStore; 21 | -------------------------------------------------------------------------------- /examples/web-react/src/stores/root-store.ts: -------------------------------------------------------------------------------- 1 | import { createRootStore } from '@nexigen/entity-normalizer'; 2 | 3 | import { Api } from '../api'; 4 | import { CommentsStore } from './comments/store'; 5 | import { PostsStore } from './posts/store'; 6 | import { schemaMap } from './schema-map'; 7 | import { ViewerStore } from './viewer/store'; 8 | 9 | export const rootStore = createRootStore({ 10 | api: Api, 11 | schemaMap, 12 | stores: { 13 | viewer: ViewerStore, 14 | posts: PostsStore, 15 | comments: CommentsStore, 16 | }, 17 | services: {}, 18 | }); 19 | 20 | export type AppRootStore = typeof rootStore; 21 | -------------------------------------------------------------------------------- /examples/mobile/.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | expo-env.d.ts 11 | 12 | # Native 13 | .kotlin/ 14 | *.orig.* 15 | *.jks 16 | *.p8 17 | *.p12 18 | *.key 19 | *.mobileprovision 20 | 21 | # Metro 22 | .metro-health-check* 23 | 24 | # debug 25 | npm-debug.* 26 | yarn-debug.* 27 | yarn-error.* 28 | 29 | # macOS 30 | .DS_Store 31 | *.pem 32 | 33 | # local env files 34 | .env*.local 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | 39 | # generated native folders 40 | /ios 41 | /android 42 | -------------------------------------------------------------------------------- /examples/mobile/src/api/viewer.ts: -------------------------------------------------------------------------------- 1 | import { viewers } from './db'; 2 | import { delay } from './utils'; 3 | 4 | import type { ViewerDto } from './dto'; 5 | 6 | const toViewerPreview = (viewer: ViewerDto): ViewerDto => ({ 7 | id: viewer.id, 8 | name: viewer.name, 9 | avatarUrl: viewer.avatarUrl, 10 | }); 11 | 12 | export const ViewerApi = { 13 | async getCurrentViewer(): Promise { 14 | await delay(300); 15 | 16 | return toViewerPreview(viewers[0]); 17 | }, 18 | 19 | async getViewerById(id: string): Promise { 20 | await delay(200); 21 | 22 | return viewers.find(v => v.id === id)!; 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /examples/web-react/src/api/viewer.ts: -------------------------------------------------------------------------------- 1 | import { viewers } from './db'; 2 | import { delay } from './utils'; 3 | 4 | import type { ViewerDto } from './dto'; 5 | 6 | const toViewerPreview = (viewer: ViewerDto): ViewerDto => ({ 7 | id: viewer.id, 8 | name: viewer.name, 9 | avatarUrl: viewer.avatarUrl, 10 | }); 11 | 12 | export const ViewerApi = { 13 | async getCurrentViewer(): Promise { 14 | await delay(300); 15 | 16 | return toViewerPreview(viewers[0]); 17 | }, 18 | 19 | async getViewerById(id: string): Promise { 20 | await delay(200); 21 | 22 | return viewers.find(v => v.id === id)!; 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /src/entities/record/create-entity-record.ts: -------------------------------------------------------------------------------- 1 | import { EntityRecord } from './entity-record'; 2 | 3 | import type { CoreEntitiesAPI } from '../../root/coreApi/types'; 4 | import type { EntityCleanerStore } from '../cleaner'; 5 | import type { TEntitiesStore } from '../types'; 6 | 7 | export function createEntityRecord( 8 | options: { entityKey: string; recordId: string }, 9 | system: { 10 | entities: TEntitiesStore; 11 | entitiesCleaner: EntityCleanerStore; 12 | notify: () => void; 13 | entitiesApi: CoreEntitiesAPI; 14 | }, 15 | ) { 16 | return new EntityRecord(options, system); 17 | } 18 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { createDuck } from './async'; 2 | import { createStoreHooks } from './create-store-hooks'; 3 | import { registerRootStore } from './di'; 4 | import { createEntitySchema } from './entities/create-entity-schema'; 5 | import { createRootStore } from './root'; 6 | 7 | export * from './root/coreApi/public'; 8 | export * from './root/public'; 9 | export * from './entities/record/public'; 10 | export * from './entities/collection/public'; 11 | export * from './entities/public'; 12 | export * from './async/public'; 13 | 14 | export { 15 | registerRootStore, 16 | createRootStore, 17 | createStoreHooks, 18 | createDuck, 19 | createEntitySchema, 20 | }; 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format follows: 6 | 7 | - Added 8 | - Changed 9 | - Fixed 10 | - Removed 11 | 12 | --- 13 | 14 | ## 0.1.0 15 | 16 | ### Added 17 | 18 | - Initial public release 19 | - Entity schema system 20 | - Normalized entity store 21 | - EntityRecord and EntityCollection 22 | - MultiEntityCollection 23 | - Async Ducks 24 | - RootStore and Core API 25 | - Dependency Injection (StoreDeps) 26 | - React integration hooks 27 | - Deterministic entity lifecycle 28 | - Full test coverage for core logic 29 | 30 | --- 31 | 32 | > Versions below 1.0.0 may contain breaking changes. 33 | -------------------------------------------------------------------------------- /examples/mobile/eslint.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'eslint/config'; 2 | import rootConfig from '../../eslint.config.js'; 3 | import path from 'node:path'; 4 | import { fileURLToPath } from 'node:url'; 5 | 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = path.dirname(__filename); 8 | 9 | export default defineConfig([ 10 | ...rootConfig, 11 | { 12 | files: ['**/*.{ts,tsx}'], 13 | 14 | languageOptions: { 15 | parserOptions: { 16 | project: './tsconfig.json', 17 | tsconfigRootDir: __dirname, 18 | }, 19 | }, 20 | rules: { 21 | 'no-console': 'off', 22 | 'no-restricted-imports': 'off', 23 | }, 24 | }, 25 | ]); 26 | -------------------------------------------------------------------------------- /src/async/types.ts: -------------------------------------------------------------------------------- 1 | export interface AxiosError extends Error { 2 | isAxiosError?: boolean; 3 | 4 | response?: { 5 | status: number; 6 | data?: T; 7 | headers?: unknown; 8 | }; 9 | 10 | request?: unknown; 11 | 12 | code?: string; 13 | } 14 | 15 | export interface RetryStrategy { 16 | retries: number; 17 | delayMs: number; 18 | backoff?: boolean; 19 | shouldRetry?: (error: AxiosError | Error) => boolean; 20 | } 21 | 22 | export type RunOptions = { 23 | params?: TParams; 24 | onSuccess?: (data: TResult) => void; 25 | onError?: (err: Error) => void; 26 | key?: string; 27 | skip?: boolean; 28 | 29 | retryStrategy?: RetryStrategy; 30 | }; 31 | -------------------------------------------------------------------------------- /examples/mobile/src/stores/comments/model.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from 'mobx'; 2 | 3 | import { ENTITY_KEY } from '../../constants'; 4 | 5 | import type { CommentNormalizedDto } from './dto'; 6 | import type { EntityGetter } from '@nexigen/entity-normalizer'; 7 | 8 | export class CommentModel { 9 | id: string; 10 | text: string; 11 | viewerId: string; 12 | 13 | constructor( 14 | dto: CommentNormalizedDto, 15 | private readonly get: EntityGetter, 16 | ) { 17 | this.id = dto.id; 18 | this.text = dto.text; 19 | this.viewerId = dto?.viewerId; 20 | 21 | makeAutoObservable(this, {}, { autoBind: true }); 22 | } 23 | 24 | get viewer() { 25 | return this.get(ENTITY_KEY.VIEWERS, this.viewerId); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/web-react/src/stores/comments/model.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from 'mobx'; 2 | 3 | import { ENTITY_KEY } from '../../constants'; 4 | 5 | import type { CommentNormalizedDto } from './dto'; 6 | import type { EntityGetter } from '@nexigen/entity-normalizer'; 7 | 8 | export class CommentModel { 9 | id: string; 10 | text: string; 11 | viewerId: string; 12 | 13 | constructor( 14 | dto: CommentNormalizedDto, 15 | private readonly get: EntityGetter, 16 | ) { 17 | this.id = dto.id; 18 | this.text = dto.text; 19 | this.viewerId = dto?.viewerId; 20 | 21 | makeAutoObservable(this, {}, { autoBind: true }); 22 | } 23 | 24 | get viewer() { 25 | return this.get(ENTITY_KEY.VIEWERS, this.viewerId); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /docs/docs/12-react-integration.md: -------------------------------------------------------------------------------- 1 | # React Integration 2 | 3 | Nexigen integrates with React via standard MobX patterns. 4 | 5 | --- 6 | 7 | ## observer 8 | 9 | ```ts 10 | export default observer(Component); 11 | ``` 12 | 13 | --- 14 | 15 | ## Access Pattern 16 | 17 | Components should: 18 | 19 | - read models directly 20 | - avoid destructuring entities 21 | - rely on MobX tracking 22 | 23 | --- 24 | 25 | ## Correct Usage 26 | 27 | ```tsx 28 | const PostItem = observer(({ post }) => {post.title}); 29 | ``` 30 | 31 | --- 32 | 33 | ## Anti-Patterns 34 | 35 | ❌ Copying model fields into local state 36 | ❌ Using useMemo to cache entities 37 | 38 | --- 39 | 40 | ## Guarantees 41 | 42 | - minimal re-renders 43 | - precise dependency tracking 44 | -------------------------------------------------------------------------------- /examples/web-react/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2023", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "types": ["node"], 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "erasableSyntaxOnly": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["vite.config.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /examples/web-react/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /src/root/coreApi/create-core-api.ts: -------------------------------------------------------------------------------- 1 | import { createEntitiesAPI } from './entities'; 2 | import { createLifecycleAPI } from './lifecycle'; 3 | import { createStoresAPI } from './stores'; 4 | 5 | import type { 6 | CoreAPI, 7 | CoreEntitiesDeps, 8 | CoreLifecycleDeps, 9 | CoreStoresDeps, 10 | CoreInternalAPI, 11 | } from './types'; 12 | 13 | export function createCoreAPI>(deps: { 14 | lifecycle: CoreLifecycleDeps; 15 | entities: CoreEntitiesDeps; 16 | stores: CoreStoresDeps; 17 | __internal: CoreInternalAPI; 18 | }): CoreAPI { 19 | return { 20 | lifecycle: createLifecycleAPI(deps.lifecycle), 21 | entities: createEntitiesAPI(deps.entities), 22 | stores: createStoresAPI(deps.stores), 23 | __internal: deps.__internal, 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /examples/mobile/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "mobile", 4 | "slug": "mobile", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/icon.png", 8 | "userInterfaceStyle": "light", 9 | "newArchEnabled": true, 10 | "splash": { 11 | "image": "./assets/splash-icon.png", 12 | "resizeMode": "contain", 13 | "backgroundColor": "#ffffff" 14 | }, 15 | "ios": { 16 | "supportsTablet": true 17 | }, 18 | "android": { 19 | "adaptiveIcon": { 20 | "foregroundImage": "./assets/adaptive-icon.png", 21 | "backgroundColor": "#ffffff" 22 | }, 23 | "edgeToEdgeEnabled": true, 24 | "predictiveBackGestureEnabled": false 25 | }, 26 | "web": { 27 | "favicon": "./assets/favicon.png" 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /examples/web-react/src/examples/posts-pagination/PostCard.tsx: -------------------------------------------------------------------------------- 1 | import { type PostModel } from '../../stores/posts/model'; 2 | 3 | export function PostCard({ 4 | post, 5 | type, 6 | }: { 7 | post: PostModel; 8 | type: 'fresh' | 'archived'; 9 | }) { 10 | return ( 11 |
18 |

{post.title}

19 |
20 | 21 | type: {type} 22 | 23 |
24 | 25 | viewer: {post.viewer?.name ?? '—'} 26 | 27 |
28 | 29 | comments count: {post.commentsId?.length ?? 0} 30 | 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /examples/web-react/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2022", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "types": ["vite/client"], 9 | "skipLibCheck": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "moduleDetection": "force", 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "erasableSyntaxOnly": false, 24 | "noFallthroughCasesInSwitch": true, 25 | "noUncheckedSideEffectImports": true 26 | }, 27 | "include": ["src"] 28 | } 29 | -------------------------------------------------------------------------------- /src/entities/collection/create-entity-collection.ts: -------------------------------------------------------------------------------- 1 | import { EntityCollection } from './entity-collection'; 2 | import { MultiEntityCollection as ProxyEntityCollection } from './multi-entity-collection'; 3 | 4 | import type { MultiEntityCollection } from './public'; 5 | import type { EntityCollectionOptions, SystemDeps } from './types'; 6 | 7 | export function createEntityCollection( 8 | options: EntityCollectionOptions, 9 | system: SystemDeps, 10 | ): EntityCollection { 11 | return new EntityCollection(options, system); 12 | } 13 | 14 | export function createMultiEntityCollection< 15 | T extends { id: string | number }, 16 | M, 17 | >( 18 | options: EntityCollectionOptions, 19 | system: SystemDeps, 20 | ): MultiEntityCollection { 21 | return new ProxyEntityCollection(options, system).getProxy(); 22 | } 23 | -------------------------------------------------------------------------------- /examples/mobile/src/stores/posts/model.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from 'mobx'; 2 | 3 | import { ENTITY_KEY } from '../../constants'; 4 | 5 | import type { PostNormalizedDto } from './dto'; 6 | import type { EntityGetter } from '@nexigen/entity-normalizer'; 7 | 8 | export class PostModel { 9 | id: string; 10 | viewerId: string; 11 | title: string; 12 | commentsId?: string[]; 13 | 14 | constructor( 15 | dto: PostNormalizedDto, 16 | private readonly get: EntityGetter, 17 | ) { 18 | this.id = dto.id; 19 | this.title = dto.title; 20 | this.viewerId = dto.viewerId; 21 | this.commentsId = dto.commentsId; 22 | 23 | makeAutoObservable(this, {}, { autoBind: true }); 24 | } 25 | 26 | get viewer() { 27 | return this.get(ENTITY_KEY.VIEWERS, this.viewerId); 28 | } 29 | 30 | get shortTitle() { 31 | return this.title.slice(0, 3) + '…'; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /examples/web-react/src/stores/posts/model.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from 'mobx'; 2 | 3 | import { ENTITY_KEY } from '../../constants'; 4 | 5 | import type { PostNormalizedDto } from './dto'; 6 | import type { EntityGetter } from '@nexigen/entity-normalizer'; 7 | 8 | export class PostModel { 9 | id: string; 10 | viewerId: string; 11 | title: string; 12 | commentsId?: string[]; 13 | 14 | constructor( 15 | dto: PostNormalizedDto, 16 | private readonly get: EntityGetter, 17 | ) { 18 | this.id = dto.id; 19 | this.title = dto.title; 20 | this.viewerId = dto.viewerId; 21 | this.commentsId = dto.commentsId; 22 | 23 | makeAutoObservable(this, {}, { autoBind: true }); 24 | } 25 | 26 | get viewer() { 27 | return this.get(ENTITY_KEY.VIEWERS, this.viewerId); 28 | } 29 | 30 | get shortTitle() { 31 | return this.title.slice(0, 3) + '…'; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /examples/web-react/eslint.config.js: -------------------------------------------------------------------------------- 1 | import coreConfig from '../../eslint.config.js'; 2 | 3 | import globals from 'globals'; 4 | import reactHooks from 'eslint-plugin-react-hooks'; 5 | import reactRefresh from 'eslint-plugin-react-refresh'; 6 | 7 | export default [ 8 | { 9 | ignores: ['dist/**'], 10 | }, 11 | ...coreConfig.map(cfg => ({ 12 | ...cfg, 13 | files: ['**/*.{ts,tsx}'], 14 | languageOptions: { 15 | ...cfg.languageOptions, 16 | parserOptions: { 17 | ...cfg.languageOptions?.parserOptions, 18 | project: './tsconfig.eslint.json', 19 | tsconfigRootDir: new URL('.', import.meta.url).pathname, 20 | }, 21 | }, 22 | })), 23 | 24 | reactHooks.configs.flat.recommended, 25 | reactRefresh.configs.vite, 26 | 27 | { 28 | languageOptions: { 29 | ecmaVersion: 2020, 30 | globals: globals.browser, 31 | }, 32 | }, 33 | ]; 34 | -------------------------------------------------------------------------------- /examples/mobile/src/screens/Posts/post-card.tsx: -------------------------------------------------------------------------------- 1 | import { View, Text } from 'react-native'; 2 | 3 | import { styles } from './styles'; 4 | 5 | import type { PostModel } from '../../stores/posts/model'; 6 | 7 | export function PostCard({ 8 | post, 9 | type, 10 | }: { 11 | post: PostModel; 12 | type: 'fresh' | 'archived'; 13 | }) { 14 | return ( 15 | 16 | 17 | {post.title} 18 | 19 | 20 | 21 | type: {type} 22 | 23 | 24 | 25 | viewer: {post.viewer?.name ?? '—'} 26 | 27 | 28 | 29 | comments:{' '} 30 | {post.commentsId?.length ?? 0} 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/create/utils.ts: -------------------------------------------------------------------------------- 1 | export function defineHiddenProp( 2 | obj: T, 3 | key: K, 4 | value: any, 5 | ) { 6 | Object.defineProperty(obj, key, { 7 | value, 8 | enumerable: false, 9 | configurable: true, 10 | writable: true, 11 | }); 12 | } 13 | 14 | export function deepClone(value: T): T { 15 | // structuredClone is ideal for snapshots (handles Maps/Sets/Date, etc.) 16 | // Fallback: JSON clone for plain data. 17 | try { 18 | if (typeof structuredClone === 'function') { 19 | return structuredClone(value); 20 | } 21 | } catch {} 22 | 23 | try { 24 | return JSON.parse(JSON.stringify(value)) as T; 25 | } catch { 26 | // Last resort: return as-is (better than crash). 27 | return value; 28 | } 29 | } 30 | 31 | export function isPlainObject(value: unknown): value is Record { 32 | if (!value || typeof value !== 'object') { 33 | return false; 34 | } 35 | const proto = Object.getPrototypeOf(value); 36 | return proto === Object.prototype || proto === null; 37 | } 38 | -------------------------------------------------------------------------------- /src/di/register-root-store.ts: -------------------------------------------------------------------------------- 1 | import type { RootStore } from '../root/RootStore'; 2 | 3 | /** 4 | * Internal storage for the global RootStore reference. 5 | * At runtime this will hold the actual app RootStore instance. 6 | */ 7 | let _rootStore: RootStore | null = null; 8 | 9 | /** 10 | * Registers the RootStore instance for global DI access. 11 | * Should be called exactly once from the application entry point. 12 | */ 13 | export function registerRootStore>( 14 | store: TStore, 15 | ): void { 16 | _rootStore = store; 17 | } 18 | 19 | /** 20 | * Returns the initialized RootStore instance. 21 | * Throws if registerRootStore() has not been called yet. 22 | */ 23 | export function getRootStore< 24 | TStore extends RootStore = RootStore, 25 | >(): TStore { 26 | if (!_rootStore) { 27 | throw new Error( 28 | '[core/di] RootStore is not initialized. Call registerRootStore(rootStore) first.', 29 | ); 30 | } 31 | 32 | return _rootStore as TStore; 33 | } 34 | -------------------------------------------------------------------------------- /src/create-store-hooks.ts: -------------------------------------------------------------------------------- 1 | import { getRootStore } from './di/register-root-store'; 2 | 3 | import type { RootStore } from './root/RootStore'; 4 | 5 | /** 6 | * Factory that returns typed hooks bound to a particular RootStore type. 7 | * 8 | * Usage in app: 9 | * const { useStores, useServices, useCore, useStore, useService } = 10 | * createHooks(); 11 | */ 12 | export function createStoreHooks< 13 | TRoot extends RootStore, 14 | >() { 15 | const useStores = (): TRoot['stores'] => getRootStore().stores; 16 | 17 | const useServices = (): TRoot['services'] => getRootStore().services; 18 | 19 | const useStore = (key: K) => 20 | getRootStore().stores[key]; 21 | 22 | const useService = (key: K) => 23 | getRootStore().services[key]; 24 | 25 | const useCore = (): TRoot['core'] => getRootStore().core; 26 | 27 | return { 28 | useStores, 29 | useServices, 30 | useStore, 31 | useService, 32 | useCore, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | lint: 11 | name: lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: 20.x 18 | cache: yarn 19 | - run: yarn install --frozen-lockfile 20 | - run: yarn lint 21 | 22 | typecheck: 23 | name: typecheck 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: actions/setup-node@v4 28 | with: 29 | node-version: 20.x 30 | cache: yarn 31 | - run: yarn install --frozen-lockfile 32 | - run: yarn typecheck 33 | 34 | test: 35 | name: test 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v4 39 | - uses: actions/setup-node@v4 40 | with: 41 | node-version: 20.x 42 | cache: yarn 43 | - run: yarn install --frozen-lockfile 44 | - run: yarn test --runInBand 45 | -------------------------------------------------------------------------------- /src/async/retry.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosError, RetryStrategy } from './types'; 2 | 3 | export const shouldRetryDefault = (error: AxiosError | Error): boolean => { 4 | // AbortController → no retry 5 | if (error.message === 'canceled') { 6 | return false; 7 | } 8 | 9 | const axiosErr = error as AxiosError; 10 | 11 | // Network-level issues 12 | if (axiosErr.code === 'ERR_NETWORK') { 13 | return true; 14 | } 15 | if (axiosErr.code === 'ECONNABORTED') { 16 | return true; 17 | } 18 | 19 | // No response at all → server unreachable 20 | if (!axiosErr.response) { 21 | return true; 22 | } 23 | 24 | const status = axiosErr.response.status; 25 | 26 | if (status >= 500) { 27 | return true; 28 | } // 5xx 29 | if (status === 429) { 30 | return true; 31 | } // rate limit 32 | if (status === 401) { 33 | return false; 34 | } // handled separately via interceptor 35 | 36 | return false; 37 | }; 38 | 39 | export const defaultRetryStrategy: RetryStrategy = { 40 | retries: 2, 41 | delayMs: 300, 42 | backoff: true, 43 | shouldRetry: shouldRetryDefault, 44 | }; 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 nexigenjs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/async/public.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosError } from './types'; 2 | 3 | export interface RetryStrategy { 4 | retries: number; 5 | delayMs: number; 6 | backoff?: boolean; 7 | shouldRetry?: (error: AxiosError | Error) => boolean; 8 | } 9 | 10 | export type RunOptions = { 11 | params?: TParams; 12 | onSuccess?: (data: TResult) => void; 13 | onError?: (err: Error) => void; 14 | key?: string; 15 | skip?: boolean; 16 | retryStrategy?: RetryStrategy; 17 | }; 18 | 19 | export interface PublicAsyncDuck { 20 | run(options?: RunOptions): Promise; 21 | 22 | readonly isLoading: boolean; 23 | readonly isRetrying: boolean; 24 | readonly isError: boolean; 25 | readonly isSuccess: boolean; 26 | readonly hasEverRun: boolean; 27 | 28 | readonly data: TResult | null; 29 | readonly error: Error | null; 30 | 31 | readonly asyncState: { 32 | isLoading: boolean; 33 | isRetrying: boolean; 34 | isError: boolean; 35 | isSuccess: boolean; 36 | hasEverRun: boolean; 37 | data: TResult | null; 38 | error: Error | null; 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /src/entities/cleaner/bucket.ts: -------------------------------------------------------------------------------- 1 | import type { TEntitiesStore } from '../types'; 2 | import type { SchemaWalker } from './walker'; 3 | 4 | export class BucketCollector { 5 | constructor( 6 | private entities: TEntitiesStore, 7 | private walker: SchemaWalker, 8 | ) {} 9 | 10 | collect(entityKey: string, id: string | number) { 11 | const visited = new Set(); 12 | const bucket: Record> = {} as any; 13 | 14 | const walk = (key: string, entityId: string | number) => { 15 | const visitKey = `${key}:${entityId}`; 16 | if (visited.has(visitKey)) { 17 | return; 18 | } 19 | visited.add(visitKey); 20 | 21 | const entity = this.entities.getEntity(key, entityId); 22 | 23 | if (!entity) { 24 | return; 25 | } 26 | 27 | if (!bucket[key]) { 28 | bucket[key] = new Set(); 29 | } 30 | bucket[key]!.add(String(entityId)); 31 | 32 | this.walker.walkFields(key, entity, (childKey, childId) => { 33 | walk(childKey, childId); 34 | }); 35 | }; 36 | 37 | walk(entityKey, id); 38 | 39 | return bucket; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/entities/cleaner/walker.ts: -------------------------------------------------------------------------------- 1 | import type { AnySchema } from '../types'; 2 | 3 | export class SchemaWalker { 4 | constructor(private schemaMap: Record) {} 5 | 6 | walkFields( 7 | entityKey: string, 8 | entity: any, 9 | cb: (childKey: string, id: string | number) => void, 10 | ) { 11 | const schema = this.schemaMap[entityKey]; 12 | if (!schema) { 13 | return; 14 | } 15 | 16 | for (const field of Object.keys(schema.definition)) { 17 | const subschema = schema.definition[field] as AnySchema | [AnySchema]; 18 | const isArray = Array.isArray(subschema); 19 | const childSchema = isArray ? subschema[0] : subschema; 20 | 21 | const childKey = childSchema.key; 22 | 23 | const idKey = `${field}Id`; 24 | const value = entity[idKey]; 25 | if (!value) { 26 | continue; 27 | } 28 | 29 | if (isArray) { 30 | if (!Array.isArray(value)) { 31 | continue; 32 | } 33 | for (const id of value) { 34 | cb(childKey, id); 35 | } 36 | } else { 37 | cb(childKey, value); 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /examples/mobile/src/screens/Viewer/viewer-card.tsx: -------------------------------------------------------------------------------- 1 | import { View, Text, Image } from 'react-native'; 2 | 3 | import { styles } from './styles'; 4 | 5 | import type { ViewerModel } from '../../stores/viewer/model'; 6 | 7 | export function ViewerCard({ viewer }: { viewer: ViewerModel | null }) { 8 | if (!viewer) { 9 | return ( 10 | 11 | No viewer loaded 12 | 13 | ); 14 | } 15 | 16 | return ( 17 | 18 | {viewer.avatarUrl ? ( 19 | 20 | ) : ( 21 | 22 | {viewer.name[0]} 23 | 24 | )} 25 | 26 | 27 | {viewer.name} 28 | 29 | {!!viewer.email && ( 30 | {viewer.email} 31 | )} 32 | 33 | {!!viewer.bio && {viewer.bio}} 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /examples/mobile/src/screens/Comments/comment-item.tsx: -------------------------------------------------------------------------------- 1 | import { View, Text, Image } from 'react-native'; 2 | 3 | import { styles } from './styles'; 4 | 5 | import type { CommentModel } from '../../stores/comments/model'; 6 | 7 | export function CommentItem({ comment }: { comment: CommentModel }) { 8 | const viewer = comment.viewer; 9 | 10 | return ( 11 | 12 | 13 | {viewer?.avatarUrl ? ( 14 | 15 | ) : ( 16 | 17 | {viewer?.name?.[0] ?? '?'} 18 | 19 | )} 20 | 21 | 22 | {viewer?.name ?? 'Unknown'} 23 | 24 | {!!viewer?.bio && ( 25 | 26 | {viewer.bio} 27 | 28 | )} 29 | 30 | 31 | 32 | {comment.text} 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/entities/record/public.ts: -------------------------------------------------------------------------------- 1 | // Public snapshot type 2 | export interface EntityRecordSnapshot { 3 | id: string | number | null; 4 | } 5 | 6 | // Public API contract 7 | export interface EntityRecord { 8 | // -------- state -------- 9 | 10 | /** Current entity id or null */ 11 | readonly entityId: string | number | null; 12 | 13 | /** Resolved entity model (may trigger lazy cleanup) */ 14 | readonly data: M | undefined; 15 | 16 | /** True if record points to existing entity */ 17 | readonly exists: boolean; 18 | 19 | /** True if record is empty (no id) */ 20 | readonly isEmpty: boolean; 21 | 22 | /** Stable record identifier */ 23 | readonly recordId: string; 24 | 25 | // -------- mutations -------- 26 | 27 | /** Normalize & set entity, replaces id */ 28 | set(item: T): void; 29 | 30 | /** Normalize entity without changing id */ 31 | update(item: T): void; 32 | 33 | /** Clear record and cascade delete */ 34 | reset(): void; 35 | 36 | // -------- snapshot -------- 37 | 38 | /** Serialize record state */ 39 | getSnapshot(): EntityRecordSnapshot; 40 | 41 | /** Restore record state */ 42 | applySnapshot(snapshot: EntityRecordSnapshot): void; 43 | } 44 | -------------------------------------------------------------------------------- /examples/mobile/src/stores/comments/store.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type EntityCollection, 3 | createDuck, 4 | type StoreDeps, 5 | } from '@nexigen/entity-normalizer'; 6 | import { makeAutoObservable } from 'mobx'; 7 | 8 | import { type CommentDto } from './dto'; 9 | import { type CommentModel } from './model'; 10 | import { type Api } from '../../api'; 11 | import { ENTITY_KEY, REF_SOURCE } from '../../constants'; 12 | 13 | export class CommentsStore { 14 | list: EntityCollection; 15 | 16 | constructor( 17 | private deps: StoreDeps<{ 18 | api: typeof Api; 19 | }>, 20 | ) { 21 | this.list = this.deps.core.entities.createCollection< 22 | CommentDto, 23 | CommentModel 24 | >({ 25 | entityKey: ENTITY_KEY.COMMENTS, 26 | collectionId: REF_SOURCE.COMMENTS_FEED, 27 | }); 28 | 29 | makeAutoObservable(this); 30 | } 31 | 32 | fetchComments = createDuck(async ({ postId }: { postId: string }) => { 33 | const comments = await this.deps.api.Comments.getCommentsByPost({ 34 | postId: postId, 35 | page: this.list.pageNumber, 36 | limit: this.list.limit, 37 | }); 38 | 39 | this.list.set(comments); 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /examples/web-react/src/stores/comments/store.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type EntityCollection, 3 | createDuck, 4 | type StoreDeps, 5 | } from '@nexigen/entity-normalizer'; 6 | import { makeAutoObservable } from 'mobx'; 7 | 8 | import { type CommentDto } from './dto'; 9 | import { type CommentModel } from './model'; 10 | import { type Api } from '../../api'; 11 | import { ENTITY_KEY, REF_SOURCE } from '../../constants'; 12 | 13 | export class CommentsStore { 14 | list: EntityCollection; 15 | 16 | constructor( 17 | private deps: StoreDeps<{ 18 | api: typeof Api; 19 | }>, 20 | ) { 21 | this.list = this.deps.core.entities.createCollection< 22 | CommentDto, 23 | CommentModel 24 | >({ 25 | entityKey: ENTITY_KEY.COMMENTS, 26 | collectionId: REF_SOURCE.COMMENTS_FEED, 27 | }); 28 | 29 | makeAutoObservable(this); 30 | } 31 | 32 | fetchComments = createDuck(async ({ postId }: { postId: string }) => { 33 | const comments = await this.deps.api.Comments.getCommentsByPost({ 34 | postId: postId, 35 | page: this.list.pageNumber, 36 | limit: this.list.limit, 37 | }); 38 | 39 | this.list.set(comments); 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /src/root/public.ts: -------------------------------------------------------------------------------- 1 | import type { PublicEntitySchema } from '../entities/public'; 2 | import type { CoreAPI } from './coreApi/types'; 3 | 4 | export type PublicSchemaMap = Record>; 5 | export type PublicStoreMap = Record any>; 6 | export type PublicServiceMap = Record any>; 7 | 8 | export interface CreateRootStoreConfig< 9 | TApi, 10 | TSchemas extends PublicSchemaMap, 11 | TStores extends PublicStoreMap, 12 | TServices extends PublicServiceMap, 13 | > { 14 | api: TApi; 15 | 16 | schemaMap: TSchemas; 17 | 18 | stores: TStores; 19 | services: TServices; 20 | 21 | plugins?: any[]; 22 | } 23 | 24 | /** 25 | * PUBLIC deps passed into Stores / Services 26 | * 27 | * Used like: 28 | * constructor(deps: StoreDeps<{ stores: {...}, api: Api }>) 29 | */ 30 | export type StoreDeps< 31 | C extends { 32 | stores?: any; 33 | api?: any; 34 | services?: any; 35 | }, 36 | > = { 37 | api: C['api'] extends undefined ? unknown : C['api']; 38 | stores: C['stores'] extends undefined ? {} : C['stores']; 39 | services: C['services'] extends undefined ? {} : C['services']; 40 | core: CoreAPI; 41 | }; 42 | -------------------------------------------------------------------------------- /examples/web-react/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import { CommentsByPostExample } from './examples/comments-by-post/CommentsByPostExample'; 4 | import { PostsPaginationExample } from './examples/posts-pagination/PostsPaginationExample'; 5 | import { ViewerRecordsExample } from './examples/viewer-records/ViewerRecordsExample'; 6 | import './App.css'; 7 | 8 | function App() { 9 | const [example, setExample] = useState<'posts' | 'comments' | 'viewer'>( 10 | 'posts', 11 | ); 12 | 13 | return ( 14 | <> 15 |

16 | Entities are normalized and shared between collections automatically 17 |

18 | 21 | 24 | 27 | {example === 'posts' && } 28 | {example === 'comments' && } 29 | {example === 'viewer' && } 30 | 31 | ); 32 | } 33 | export default App; 34 | -------------------------------------------------------------------------------- /src/entities/schema.ts: -------------------------------------------------------------------------------- 1 | import type { EntitySchemaOptions, EntityModelCtor } from './types'; 2 | 3 | export class EntitySchema { 4 | constructor( 5 | public key: string, 6 | public definition: Record< 7 | string, 8 | EntitySchema | [EntitySchema] 9 | > = {}, 10 | public options: EntitySchemaOptions = {}, 11 | ) {} 12 | 13 | get model(): EntityModelCtor | undefined { 14 | return this.options.model; 15 | } 16 | 17 | getId(input: TDto) { 18 | const { idAttribute = 'id' } = this.options; 19 | return typeof idAttribute === 'function' 20 | ? idAttribute(input) 21 | : (input as any)[idAttribute]; 22 | } 23 | 24 | getIdKey() { 25 | const { idAttribute = 'id' } = this.options; 26 | return typeof idAttribute === 'string' ? idAttribute : 'id'; 27 | } 28 | 29 | process(input: TDto): TDto { 30 | return this.options.processStrategy 31 | ? this.options.processStrategy(input) 32 | : { ...input }; 33 | } 34 | 35 | merge(target: TModel, source: Partial) { 36 | return this.options.mergeStrategy 37 | ? this.options.mergeStrategy(target, source) 38 | : Object.assign(target as any, source); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /docs/docs/11-real-world-flows.md: -------------------------------------------------------------------------------- 1 | # Real-World Flows 2 | 3 | This document demonstrates **real application flows** using Nexigen. 4 | 5 | --- 6 | 7 | ## Authentication Flow 8 | 9 | ```ts 10 | login = createDuck(async () => { 11 | const tokens = await api.login(); 12 | await Tokens.save(tokens); 13 | await viewerStore.fetchCurrentViewer.run({ force: true }); 14 | viewerStore.setIsLoggedIn(true); 15 | }); 16 | ``` 17 | 18 | Effects: 19 | 20 | - entities merged 21 | - viewer record updated 22 | - UI reacts automatically 23 | 24 | --- 25 | 26 | ## Feed Pagination Flow 27 | 28 | ```ts 29 | fetchMorePosts = createDuck(async ({ group }) => { 30 | if (lists[group].hasNoMore) return; 31 | const res = await api.getPosts({ 32 | page: lists[group].pageNumber, 33 | limit: lists[group].limit, 34 | }); 35 | lists[group].append(res); 36 | }); 37 | ``` 38 | 39 | --- 40 | 41 | ## Detail → List Sync 42 | 43 | ```ts 44 | updatePost = createDuck(async ({ id }) => { 45 | const post = await api.getPost(id); 46 | lists['feed'].updateItem(post); 47 | }); 48 | ``` 49 | 50 | Updating detail automatically updates list views. 51 | 52 | --- 53 | 54 | ## Why This Works 55 | 56 | - shared entity identity 57 | - normalized storage 58 | - reactive reads 59 | 60 | No manual sync logic is required. 61 | -------------------------------------------------------------------------------- /examples/web-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-react", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint src", 10 | "lint:fix": "eslint src --fix", 11 | "prettier": "prettier .", 12 | "prettier:check": "prettier . --check", 13 | "prettier:write": "prettier . --write", 14 | "preview": "vite preview" 15 | }, 16 | "dependencies": { 17 | "@nexigen/entity-normalizer": "^0.1.6", 18 | "@typescript-eslint/eslint-plugin": "8.31.1", 19 | "eslint": "9.26.0", 20 | "eslint-config-prettier": "^10.1.8", 21 | "eslint-import-resolver-typescript": "^4.4.4", 22 | "eslint-plugin-import": "^2.31.0", 23 | "eslint-plugin-react-hooks": "^7.0.1", 24 | "eslint-plugin-react-refresh": "^0.4.25", 25 | "mobx": "^6.15.0", 26 | "mobx-react-lite": "^4.1.1", 27 | "react": "^19.2.0", 28 | "react-dom": "^19.2.0" 29 | }, 30 | "devDependencies": { 31 | "@types/node": "^24.10.1", 32 | "@types/react": "^19.2.5", 33 | "@types/react-dom": "^19.2.3", 34 | "@vitejs/plugin-react": "^5.1.1", 35 | "globals": "^16.5.0", 36 | "prettier": "^3.7.4", 37 | "typescript": "~5.9.3", 38 | "vite": "^7.2.4" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /docs/docs/10-store-deps.md: -------------------------------------------------------------------------------- 1 | # StoreDeps 2 | 3 | StoreDeps is Nexigen's dependency injection mechanism. 4 | 5 | --- 6 | 7 | ## Why StoreDeps Exists 8 | 9 | Without StoreDeps: 10 | 11 | - stores import each other 12 | - circular dependencies appear 13 | - testing becomes painful 14 | 15 | StoreDeps solves this structurally. 16 | 17 | --- 18 | 19 | ## Constructor Injection 20 | 21 | ```ts 22 | class PostsStore { 23 | constructor( 24 | private deps: StoreDeps<{ 25 | api: typeof Api; 26 | stores: { viewer: ViewerStore }; 27 | }>, 28 | ) {} 29 | } 30 | ``` 31 | 32 | --- 33 | 34 | ## Available Dependencies 35 | 36 | - deps.api 37 | - deps.stores 38 | - deps.core.entities 39 | 40 | No store imports another store directly. 41 | 42 | Stores may interact with other stores by calling their public methods 43 | via `deps.stores`. This preserves runtime collaboration 44 | while avoiding compile-time coupling and circular imports. 45 | 46 | --- 47 | 48 | ## Testing Benefits 49 | 50 | - stores can be instantiated in isolation 51 | - dependencies can be mocked 52 | - no global state required 53 | 54 | --- 55 | 56 | ## Anti-Patterns 57 | 58 | ❌ Importing other stores directly 59 | ❌ Reading global singletons 60 | 61 | --- 62 | 63 | ## Guarantees 64 | 65 | - acyclic dependency graph 66 | - predictable initialization 67 | -------------------------------------------------------------------------------- /examples/web-react/src/examples/comments-by-post/CommentsByPostExample.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | import { useEffect, useState } from 'react'; 3 | 4 | import { CommentItem } from './CommentItem'; 5 | import { useStores } from '../../stores/hooks'; 6 | 7 | import type { CommentModel } from '../../stores/comments/model'; 8 | 9 | export const CommentsByPostExample = observer(() => { 10 | const { 11 | posts: { 12 | lists: { 13 | fresh: { getList: freshPosts }, 14 | }, 15 | }, 16 | comments: { 17 | fetchComments, 18 | list: { getList: commentsByPost }, 19 | }, 20 | } = useStores(); 21 | const [postId, setPostId] = useState(null); 22 | 23 | useEffect(() => { 24 | if (!postId) { 25 | return; 26 | } 27 | 28 | fetchComments.run({ params: { postId } }); 29 | }, [postId, fetchComments]); 30 | 31 | return ( 32 | <> 33 |
34 |
35 |
36 | {freshPosts.map(p => ( 37 | 40 | ))} 41 | 42 | {postId && 43 | commentsByPost.map((comment: CommentModel) => ( 44 | 45 | ))} 46 | 47 | ); 48 | }); 49 | -------------------------------------------------------------------------------- /docs/docs/06-multi-collection.md: -------------------------------------------------------------------------------- 1 | # MultiEntityCollection 2 | 3 | MultiEntityCollection manages **multiple isolated collections** 4 | of the same entity type. 5 | 6 | --- 7 | 8 | ## Purpose 9 | 10 | Used when: 11 | 12 | - same entity appears in multiple groups 13 | - tabs or filters are required 14 | - pagination differs per group 15 | 16 | --- 17 | 18 | ## Creation 19 | 20 | ```ts 21 | const lists = this.deps.core.entities.createMultiCollection({ 22 | entityKey: ENTITY_KEY.POST, 23 | collectionId: REF_SOURCE.POSTS, 24 | limit: 20, 25 | }); 26 | ``` 27 | 28 | --- 29 | 30 | ## Usage 31 | 32 | ```ts 33 | lists['active'].set(response); 34 | lists['past'].append(response); 35 | ``` 36 | 37 | Each key returns an independent EntityCollection. 38 | 39 | --- 40 | 41 | ## Isolation Rules 42 | 43 | - list state is isolated per key 44 | - pagination is isolated 45 | - refSources are tracked per list 46 | 47 | --- 48 | 49 | ## Entity Sharing 50 | 51 | Entities are shared globally. 52 | 53 | Updating an entity in one group: 54 | 55 | - updates it in all groups 56 | 57 | --- 58 | 59 | ## reset(key) 60 | 61 | ```ts 62 | lists[key].reset(); 63 | ``` 64 | 65 | - removes only that group's refSource 66 | - does not affect other groups 67 | 68 | --- 69 | 70 | ## Guarantees 71 | 72 | - no cross-group pollution 73 | - deterministic cleanup 74 | -------------------------------------------------------------------------------- /examples/web-react/src/examples/viewer-records/ViewerCard.tsx: -------------------------------------------------------------------------------- 1 | type Viewer = { 2 | id: string; 3 | name: string; 4 | bio?: string; 5 | avatarUrl?: string; 6 | email?: string; 7 | }; 8 | 9 | export function ViewerCard({ viewer }: { viewer: Viewer | null }) { 10 | if (!viewer) { 11 | return
No viewer loaded
; 12 | } 13 | 14 | return ( 15 |
27 | {viewer.avatarUrl && ( 28 | {viewer.name} 35 | )} 36 | 37 |
38 | {viewer.name} 39 | {viewer.email} 40 | {viewer.bio && ( 41 |

42 | {viewer.bio} 43 |

44 | )} 45 |
46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /examples/mobile/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mobile", 3 | "version": "1.0.0", 4 | "main": "index.ts", 5 | "type": "module", 6 | "scripts": { 7 | "start": "expo start", 8 | "android": "expo start --android", 9 | "ios": "expo start --ios", 10 | "web": "expo start --web", 11 | "lint": "eslint src", 12 | "lint:fix": "eslint src --fix", 13 | "prettier": "prettier .", 14 | "prettier:check": "prettier . --check", 15 | "prettier:write": "prettier . --write" 16 | }, 17 | "dependencies": { 18 | "@nexigen/entity-normalizer": "*", 19 | "@react-navigation/bottom-tabs": "^7.8.12", 20 | "@react-navigation/native": "^7.1.25", 21 | "expo": "~54.0.29", 22 | "expo-status-bar": "~3.0.9", 23 | "mobx": "^6.15.0", 24 | "mobx-react-lite": "^4.1.1", 25 | "react": "19.1.0", 26 | "react-native": "0.81.5", 27 | "react-native-safe-area-context": "~5.6.0", 28 | "react-native-screens": "~4.16.0" 29 | }, 30 | "devDependencies": { 31 | "@types/react": "~19.1.0", 32 | "@typescript-eslint/eslint-plugin": "^8.31.1", 33 | "@typescript-eslint/parser": "^8.31.1", 34 | "eslint": "^9.26.0", 35 | "eslint-config-prettier": "^10.1.8", 36 | "eslint-import-resolver-typescript": "^4.4.4", 37 | "eslint-plugin-import": "^2.31.0", 38 | "prettier": "^3.7.4", 39 | "typescript": "^5.8.3" 40 | }, 41 | "private": true 42 | } 43 | -------------------------------------------------------------------------------- /src/root/coreApi/stores.ts: -------------------------------------------------------------------------------- 1 | import type { StoresSnapshot } from '../types'; 2 | import type { CoreStoresAPI } from './types'; 3 | 4 | export function createStoresAPI>( 5 | stores: TStores | any = {}, 6 | ): CoreStoresAPI { 7 | return { 8 | getSnapshot(): StoresSnapshot { 9 | const out = {} as StoresSnapshot; 10 | 11 | (Object.keys(stores) as Array).forEach(key => { 12 | out[key] = stores[key]?.__getSnapshot?.(); 13 | }); 14 | 15 | return out; 16 | }, 17 | 18 | getSnapshotByKey( 19 | key: K, 20 | ): StoresSnapshot[K] { 21 | return stores[key]?.__getSnapshot?.(); 22 | }, 23 | 24 | applySnapshot(snapshot: StoresSnapshot): void { 25 | (Object.keys(snapshot) as Array).forEach(key => { 26 | stores[key]?.__applySnapshot?.(snapshot[key]); 27 | }); 28 | }, 29 | 30 | applySnapshotByKey( 31 | key: K, 32 | snap: StoresSnapshot[K], 33 | ): void { 34 | stores[key]?.__applySnapshot?.(snap); 35 | }, 36 | 37 | resetAll(): void { 38 | Object.values(stores).forEach(store => { 39 | (store as any)?.resetStore?.(); 40 | }); 41 | }, 42 | 43 | resetByKey(key: K): void { 44 | stores[key]?.resetStore?.(); 45 | }, 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /examples/web-react/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #646cff; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | display: flex; 28 | place-items: center; 29 | min-width: 320px; 30 | min-height: 100vh; 31 | } 32 | 33 | h1 { 34 | font-size: 3.2em; 35 | line-height: 1.1; 36 | } 37 | 38 | button { 39 | border-radius: 8px; 40 | border: 1px solid transparent; 41 | padding: 0.6em 1.2em; 42 | font-size: 1em; 43 | font-weight: 500; 44 | font-family: inherit; 45 | background-color: #1a1a1a; 46 | cursor: pointer; 47 | transition: border-color 0.25s; 48 | } 49 | button:hover { 50 | border-color: #646cff; 51 | } 52 | button:focus, 53 | button:focus-visible { 54 | outline: 4px auto -webkit-focus-ring-color; 55 | } 56 | 57 | @media (prefers-color-scheme: light) { 58 | :root { 59 | color: #213547; 60 | background-color: #ffffff; 61 | } 62 | a:hover { 63 | color: #747bff; 64 | } 65 | button { 66 | background-color: #f9f9f9; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /examples/web-react/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/entities/collection/types.ts: -------------------------------------------------------------------------------- 1 | import type { CoreEntitiesAPI } from '../../root/coreApi/types'; 2 | import type { EntityCleanerStore } from '../cleaner'; 3 | import type { TEntitiesStore } from '../types'; 4 | import type { EntityCollection } from './entity-collection'; 5 | import type { MULTI_COLLECTION_TAG } from './marker'; 6 | 7 | export interface SystemDeps { 8 | entities: TEntitiesStore; 9 | entitiesCleaner: EntityCleanerStore; 10 | notify: () => void; 11 | entitiesApi: CoreEntitiesAPI; 12 | } 13 | export interface EntityCollectionOptions { 14 | entityKey: string; 15 | pageSize?: number; 16 | limit?: number; 17 | reversed?: boolean; 18 | hasNoMore?: boolean; 19 | idAttribute?: keyof T; 20 | collectionId: string; 21 | } 22 | 23 | export interface EntityCollectionSnapshot { 24 | items: (string | number)[]; 25 | hasNoMore: boolean; 26 | reversed: boolean; 27 | limit: number; 28 | } 29 | 30 | export type GroupCollection< 31 | T extends { id: string | number }, 32 | M, 33 | > = EntityCollection; 34 | 35 | export interface MultiCollection { 36 | [key: string]: GroupCollection | any; 37 | 38 | ensureGroup(group: string): GroupCollection; 39 | getSubCollections(): Map>; 40 | getMultiSnapshot(): Record; 41 | applyMultiSnapshot(snap: Record): void; 42 | 43 | [MULTI_COLLECTION_TAG]: true; 44 | } 45 | -------------------------------------------------------------------------------- /examples/mobile/src/stores/viewer/store.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createDuck, 3 | type EntityRecord, 4 | type StoreDeps, 5 | } from '@nexigen/entity-normalizer'; 6 | import { makeAutoObservable } from 'mobx'; 7 | 8 | import { type ViewerModel } from './model'; 9 | import { type Api } from '../../api'; 10 | import { ENTITY_KEY, REF_SOURCE } from '../../constants'; 11 | 12 | import type { ViewerDto } from './dto'; 13 | 14 | export class ViewerStore { 15 | private current: EntityRecord; 16 | private details: EntityRecord; 17 | 18 | constructor(private deps: StoreDeps<{ api: typeof Api }>) { 19 | this.current = this.deps.core.entities.createRecord({ 20 | entityKey: ENTITY_KEY.VIEWERS, 21 | recordId: REF_SOURCE.CURRENT_VIEWER, 22 | }); 23 | 24 | this.details = this.deps.core.entities.createRecord({ 25 | entityKey: ENTITY_KEY.VIEWERS, 26 | recordId: REF_SOURCE.VIEWER_DETAILS, 27 | }); 28 | 29 | makeAutoObservable(this, {}, { autoBind: true }); 30 | } 31 | 32 | get currentViewer() { 33 | return this.current.data; 34 | } 35 | 36 | get viewerDetails() { 37 | return this.details.data; 38 | } 39 | 40 | fetchCurrentViewer = createDuck(async () => { 41 | const dto = await this.deps.api.Viewer.getCurrentViewer(); 42 | this.current.set(dto); 43 | }); 44 | 45 | fetchViewerDetails = createDuck(async ({ id }: { id: string }) => { 46 | const dto = await this.deps.api.Viewer.getViewerById(id); 47 | this.details.set(dto); 48 | }); 49 | } 50 | -------------------------------------------------------------------------------- /examples/web-react/src/stores/viewer/store.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createDuck, 3 | type EntityRecord, 4 | type StoreDeps, 5 | } from '@nexigen/entity-normalizer'; 6 | import { makeAutoObservable } from 'mobx'; 7 | 8 | import { type ViewerModel } from './model'; 9 | import { type Api } from '../../api'; 10 | import { ENTITY_KEY, REF_SOURCE } from '../../constants'; 11 | 12 | import type { ViewerDto } from './dto'; 13 | 14 | export class ViewerStore { 15 | private current: EntityRecord; 16 | private details: EntityRecord; 17 | 18 | constructor(private deps: StoreDeps<{ api: typeof Api }>) { 19 | this.current = this.deps.core.entities.createRecord({ 20 | entityKey: ENTITY_KEY.VIEWERS, 21 | recordId: REF_SOURCE.CURRENT_VIEWER, 22 | }); 23 | 24 | this.details = this.deps.core.entities.createRecord({ 25 | entityKey: ENTITY_KEY.VIEWERS, 26 | recordId: REF_SOURCE.VIEWER_DETAILS, 27 | }); 28 | 29 | makeAutoObservable(this, {}, { autoBind: true }); 30 | } 31 | 32 | get currentViewer() { 33 | return this.current.data; 34 | } 35 | 36 | get viewerDetails() { 37 | return this.details.data; 38 | } 39 | 40 | fetchCurrentViewer = createDuck(async () => { 41 | const dto = await this.deps.api.Viewer.getCurrentViewer(); 42 | this.current.set(dto); 43 | }); 44 | 45 | fetchViewerDetails = createDuck(async ({ id }: { id: string }) => { 46 | const dto = await this.deps.api.Viewer.getViewerById(id); 47 | this.details.set(dto); 48 | }); 49 | } 50 | -------------------------------------------------------------------------------- /examples/mobile/src/stores/posts/store.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createDuck, 3 | type MultiEntityCollection, 4 | type StoreDeps, 5 | } from '@nexigen/entity-normalizer'; 6 | import { makeAutoObservable } from 'mobx'; 7 | 8 | import { type PostModel } from './model'; 9 | import { type Api } from '../../api'; 10 | import { ENTITY_KEY, REF_SOURCE } from '../../constants'; 11 | 12 | import type { PostDto } from './dto'; 13 | 14 | const PAGE_LIMIT = 20; 15 | 16 | export class PostsStore { 17 | lists: MultiEntityCollection; 18 | 19 | constructor( 20 | private deps: StoreDeps<{ 21 | api: typeof Api; 22 | }>, 23 | ) { 24 | this.lists = this.deps.core.entities.createMultiCollection< 25 | PostDto, 26 | PostModel 27 | >({ 28 | entityKey: ENTITY_KEY.POSTS, 29 | collectionId: REF_SOURCE.POSTS_FEED, 30 | limit: PAGE_LIMIT, 31 | }); 32 | 33 | makeAutoObservable(this); 34 | } 35 | 36 | fetchPosts = createDuck(async ({ group }) => { 37 | const response = await this.deps.api.Posts.getPosts({ 38 | page: 1, 39 | limit: PAGE_LIMIT, 40 | group, 41 | }); 42 | 43 | this.lists[group].set(response); 44 | }); 45 | 46 | fetchMorePosts = createDuck(async ({ group }) => { 47 | if (this.lists[group].hasNoMore) { 48 | return; 49 | } 50 | 51 | const response = await this.deps.api.Posts.getPosts({ 52 | page: this.lists[group].pageNumber, 53 | limit: this.lists[group].limit, 54 | group, 55 | }); 56 | 57 | this.lists[group].append(response); 58 | }); 59 | } 60 | -------------------------------------------------------------------------------- /examples/web-react/src/stores/posts/store.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createDuck, 3 | type MultiEntityCollection, 4 | type StoreDeps, 5 | } from '@nexigen/entity-normalizer'; 6 | import { makeAutoObservable } from 'mobx'; 7 | 8 | import { type PostModel } from './model'; 9 | import { type Api } from '../../api'; 10 | import { ENTITY_KEY, REF_SOURCE } from '../../constants'; 11 | 12 | import type { PostDto } from './dto'; 13 | 14 | const PAGE_LIMIT = 20; 15 | 16 | export class PostsStore { 17 | lists: MultiEntityCollection; 18 | 19 | constructor( 20 | private deps: StoreDeps<{ 21 | api: typeof Api; 22 | }>, 23 | ) { 24 | this.lists = this.deps.core.entities.createMultiCollection< 25 | PostDto, 26 | PostModel 27 | >({ 28 | entityKey: ENTITY_KEY.POSTS, 29 | collectionId: REF_SOURCE.POSTS_FEED, 30 | limit: PAGE_LIMIT, 31 | }); 32 | 33 | makeAutoObservable(this); 34 | } 35 | 36 | fetchPosts = createDuck(async ({ group }) => { 37 | const response = await this.deps.api.Posts.getPosts({ 38 | page: 1, 39 | limit: PAGE_LIMIT, 40 | group, 41 | }); 42 | 43 | this.lists[group].set(response); 44 | }); 45 | 46 | fetchMorePosts = createDuck(async ({ group }) => { 47 | if (this.lists[group].hasNoMore) { 48 | return; 49 | } 50 | 51 | const response = await this.deps.api.Posts.getPosts({ 52 | page: this.lists[group].pageNumber, 53 | limit: this.lists[group].limit, 54 | group, 55 | }); 56 | 57 | this.lists[group].append(response); 58 | }); 59 | } 60 | -------------------------------------------------------------------------------- /examples/web-react/src/examples/viewer-records/ViewerRecordsExample.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | 3 | import { ViewerCard } from './ViewerCard'; 4 | import { useStores } from '../../stores/hooks'; 5 | 6 | export const ViewerRecordsExample = observer(() => { 7 | const { 8 | viewer: { 9 | currentViewer, 10 | viewerDetails, 11 | fetchCurrentViewer, 12 | fetchViewerDetails, 13 | }, 14 | } = useStores(); 15 | 16 | return ( 17 |
18 |

Viewer records

19 | 20 |
21 | 24 | 25 | 30 | 31 | 36 |
37 | 38 |
39 |
40 |

Current viewer

41 | 42 |
43 | 44 |
45 |

Viewer details u2 / Current Viewer Details

46 | 47 |
48 |
49 |
50 | ); 51 | }); 52 | -------------------------------------------------------------------------------- /examples/mobile/src/screens/Posts/styles.ts: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native'; 2 | 3 | export const styles = StyleSheet.create({ 4 | container: { 5 | flex: 1, 6 | backgroundColor: '#0E0F12', 7 | paddingTop: 16, 8 | }, 9 | 10 | screenTitle: { 11 | fontSize: 20, 12 | fontWeight: '600', 13 | color: '#F2F2F2', 14 | paddingHorizontal: 16, 15 | marginBottom: 12, 16 | }, 17 | 18 | tabs: { 19 | flexDirection: 'row', 20 | gap: 8, 21 | paddingHorizontal: 16, 22 | marginBottom: 12, 23 | }, 24 | 25 | tab: { 26 | paddingVertical: 8, 27 | paddingHorizontal: 14, 28 | borderRadius: 20, 29 | backgroundColor: '#1A1C22', 30 | color: '#B0B3C0', 31 | }, 32 | 33 | tabActive: { 34 | backgroundColor: '#2D6BFF', 35 | color: '#FFFFFF', 36 | fontWeight: '500', 37 | }, 38 | 39 | list: { 40 | paddingHorizontal: 12, 41 | paddingBottom: 24, 42 | }, 43 | 44 | row: { 45 | gap: 12, 46 | }, 47 | 48 | card: { 49 | flex: 1, 50 | backgroundColor: '#1A1C22', 51 | borderRadius: 12, 52 | padding: 12, 53 | marginBottom: 12, 54 | }, 55 | 56 | cardTitle: { 57 | color: '#EDEEF3', 58 | fontWeight: '600', 59 | marginBottom: 6, 60 | }, 61 | 62 | meta: { 63 | fontSize: 12, 64 | color: '#B0B3C0', 65 | marginTop: 4, 66 | }, 67 | 68 | bold: { 69 | color: '#EDEEF3', 70 | fontWeight: '500', 71 | }, 72 | 73 | loading: { 74 | textAlign: 'center', 75 | color: '#B0B3C0', 76 | marginVertical: 16, 77 | }, 78 | 79 | column: { 80 | flex: 1, 81 | paddingHorizontal: 6, 82 | }, 83 | 84 | tabText: { 85 | color: '#B0B3C0', 86 | }, 87 | }); 88 | -------------------------------------------------------------------------------- /examples/mobile/src/api/db.ts: -------------------------------------------------------------------------------- 1 | import { randomFrom } from './utils'; 2 | 3 | import type { ViewerDto, PostDto, CommentDto, PostGroup } from './dto'; 4 | 5 | // -------------------- 6 | // viewers (FULL) 7 | // -------------------- 8 | export const viewers: ViewerDto[] = Array.from({ length: 5 }).map((_, i) => ({ 9 | id: `u${i + 1}`, 10 | name: `User ${i + 1}`, 11 | email: `user${i + 1}@example.com`, 12 | avatarUrl: `https://i.pravatar.cc/150?img=${i + 10}`, 13 | bio: `Bio of user ${i + 1}`, 14 | })); 15 | 16 | const toViewerPreview = (viewer: ViewerDto) => ({ 17 | id: viewer.id, 18 | name: viewer.name, 19 | avatarUrl: viewer.avatarUrl, 20 | }); 21 | 22 | // -------------------- 23 | // comments 24 | // -------------------- 25 | export const commentsByPost: Record = {}; 26 | 27 | function generateComments(postId: string): CommentDto[] { 28 | return Array.from({ length: 30 }).map((_, i) => { 29 | const viewer = randomFrom(viewers); 30 | 31 | return { 32 | id: `${postId}-c${i + 1}`, 33 | text: `Comment ${i + 1} for post ${postId}`, 34 | viewer: toViewerPreview(viewer), 35 | }; 36 | }); 37 | } 38 | 39 | // -------------------- 40 | // posts (WITH GROUPS) 41 | // -------------------- 42 | export const posts: PostDto[] = Array.from({ length: 100 }).map((_, i) => { 43 | const id = `p${i + 1}`; 44 | const allComments = generateComments(id); 45 | const viewer = randomFrom(viewers); 46 | 47 | const group: PostGroup = i < 50 ? 'fresh' : 'archived'; 48 | 49 | commentsByPost[id] = allComments; 50 | 51 | return { 52 | id, 53 | title: `Post ${i + 1}`, 54 | viewer: toViewerPreview(viewer), 55 | comments: allComments.slice(0, 5), 56 | group, 57 | }; 58 | }); 59 | -------------------------------------------------------------------------------- /examples/web-react/src/api/db.ts: -------------------------------------------------------------------------------- 1 | import { randomFrom } from './utils'; 2 | 3 | import type { ViewerDto, PostDto, CommentDto, PostGroup } from './dto'; 4 | 5 | // -------------------- 6 | // viewers (FULL) 7 | // -------------------- 8 | export const viewers: ViewerDto[] = Array.from({ length: 5 }).map((_, i) => ({ 9 | id: `u${i + 1}`, 10 | name: `User ${i + 1}`, 11 | email: `user${i + 1}@example.com`, 12 | avatarUrl: `https://i.pravatar.cc/150?img=${i + 10}`, 13 | bio: `Bio of user ${i + 1}`, 14 | })); 15 | 16 | const toViewerPreview = (viewer: ViewerDto) => ({ 17 | id: viewer.id, 18 | name: viewer.name, 19 | avatarUrl: viewer.avatarUrl, 20 | }); 21 | 22 | // -------------------- 23 | // comments 24 | // -------------------- 25 | export const commentsByPost: Record = {}; 26 | 27 | function generateComments(postId: string): CommentDto[] { 28 | return Array.from({ length: 30 }).map((_, i) => { 29 | const viewer = randomFrom(viewers); 30 | 31 | return { 32 | id: `${postId}-c${i + 1}`, 33 | text: `Comment ${i + 1} for post ${postId}`, 34 | viewer: toViewerPreview(viewer), 35 | }; 36 | }); 37 | } 38 | 39 | // -------------------- 40 | // posts (WITH GROUPS) 41 | // -------------------- 42 | export const posts: PostDto[] = Array.from({ length: 100 }).map((_, i) => { 43 | const id = `p${i + 1}`; 44 | const allComments = generateComments(id); 45 | const viewer = randomFrom(viewers); 46 | 47 | const group: PostGroup = i < 50 ? 'fresh' : 'archived'; 48 | 49 | commentsByPost[id] = allComments; 50 | 51 | return { 52 | id, 53 | title: `Post ${i + 1}`, 54 | viewer: toViewerPreview(viewer), 55 | comments: allComments.slice(0, 5), 56 | group, 57 | }; 58 | }); 59 | -------------------------------------------------------------------------------- /examples/mobile/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; 2 | import { NavigationContainer } from '@react-navigation/native'; 3 | import { Text } from 'react-native'; 4 | 5 | import { CommentsScreen } from './screens/Comments'; 6 | import { PostsScreen } from './screens/Posts'; 7 | import { ViewerScreen } from './screens/Viewer'; 8 | 9 | const Tab = createBottomTabNavigator(); 10 | 11 | export function Tabs() { 12 | return ( 13 | 14 | 31 | Posts, 36 | }} 37 | /> 38 | Comments, 43 | }} 44 | /> 45 | Viewer, 50 | }} 51 | /> 52 | 53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /docs/docs/09-async-ducks.md: -------------------------------------------------------------------------------- 1 | # Async Ducks 2 | 3 | Async Ducks are the **only supported way** to execute asynchronous logic in Nexigen. 4 | 5 | They represent **commands**, not data containers. 6 | 7 | --- 8 | 9 | ## What a Duck Is 10 | 11 | A duck is: 12 | 13 | - an observable async function 14 | - with built-in lifecycle state 15 | - safely integrated with MobX 16 | 17 | A duck is NOT: 18 | 19 | - a promise wrapper 20 | - a data store 21 | - a cache 22 | 23 | --- 24 | 25 | ## Creating a Duck 26 | 27 | ```ts 28 | login = createDuck(async () => { 29 | const tokens = await api.login(); 30 | return tokens; 31 | }); 32 | ``` 33 | 34 | --- 35 | 36 | ## Duck State 37 | 38 | Every duck exposes: 39 | 40 | ```ts 41 | duck.isLoading; 42 | duck.isRetrying; 43 | duck.error; 44 | duck.data; 45 | duck.hasEverRun; 46 | ``` 47 | 48 | All properties are observable. 49 | 50 | --- 51 | 52 | ## Running a Duck 53 | 54 | ```ts 55 | login.run(); 56 | ``` 57 | 58 | Running a duck: 59 | 60 | - sets isLoading 61 | - resets error 62 | - executes async function 63 | - updates data or error 64 | 65 | --- 66 | 67 | ## Scoped Ducks 68 | 69 | Ducks can have independent state per key. 70 | 71 | ```ts 72 | fetchPosts[group].run(); 73 | ``` 74 | 75 | Each scope has: 76 | 77 | - isolated loading 78 | - isolated error 79 | - shared logic 80 | 81 | --- 82 | 83 | ## Error Handling 84 | 85 | Errors are: 86 | 87 | - captured 88 | - stored in duck.error 89 | - never thrown synchronously 90 | 91 | This prevents UI crashes. 92 | 93 | --- 94 | 95 | ## Anti-Patterns 96 | 97 | ❌ Using async/await directly in components 98 | ❌ Storing promises in stores 99 | ❌ Using ducks as caches 100 | 101 | --- 102 | 103 | ## Guarantees 104 | 105 | - predictable async lifecycle 106 | - no race-condition UI states 107 | - testable async flows 108 | -------------------------------------------------------------------------------- /docs/docs/07-schemas.md: -------------------------------------------------------------------------------- 1 | # Schemas & Normalization 2 | 3 | Schemas define how raw API responses are transformed into a normalized entity graph. 4 | 5 | --- 6 | 7 | ## Purpose of Schemas 8 | 9 | Schemas exist to: 10 | 11 | - flatten nested API responses 12 | - deduplicate entities 13 | - define relationships 14 | - map DTOs to Models 15 | 16 | Schemas do NOT: 17 | 18 | - fetch data 19 | - store state 20 | - contain business logic 21 | 22 | --- 23 | 24 | ## createEntitySchema 25 | 26 | ```ts 27 | const postSchema = createEntitySchema( 28 | ENTITY_KEY.POST, 29 | { 30 | viewer: viewerSchema, 31 | viewers: [viewerSchema], 32 | }, 33 | { 34 | model: PostModel, 35 | }, 36 | ); 37 | ``` 38 | 39 | --- 40 | 41 | ## Relationship Types 42 | 43 | ### One-to-One 44 | 45 | ```ts 46 | viewer: viewerSchema; 47 | ``` 48 | 49 | Produces: 50 | 51 | ```ts 52 | viewerId: string; 53 | ``` 54 | 55 | --- 56 | 57 | ### One-to-Many 58 | 59 | ```ts 60 | viewers: [viewerSchema]; 61 | ``` 62 | 63 | Produces: 64 | 65 | ```ts 66 | viewersId: string[] 67 | ``` 68 | 69 | --- 70 | 71 | ## Normalization Rules 72 | 73 | - nested objects are replaced by ids 74 | - entities are deduplicated globally 75 | - missing nested data is skipped safely 76 | 77 | --- 78 | 79 | ## Partial Data 80 | 81 | Schemas tolerate partial payloads. 82 | 83 | If a nested entity is missing: 84 | 85 | - normalization continues 86 | - no error is thrown 87 | 88 | This allows: 89 | 90 | - lightweight list payloads 91 | - heavy detail payloads 92 | 93 | --- 94 | 95 | ## Anti-Patterns 96 | 97 | ❌ Storing nested objects in models 98 | ❌ Manual flattening 99 | ❌ Coupling schemas to API endpoints 100 | 101 | --- 102 | 103 | ## Guarantees 104 | 105 | - deterministic normalization 106 | - stable entity identity 107 | - safe merging of partial data 108 | -------------------------------------------------------------------------------- /src/create/extractor.ts: -------------------------------------------------------------------------------- 1 | import { runInAction } from 'mobx'; 2 | 3 | import { type StoreShape } from './scanner'; 4 | 5 | import type { Scanner } from './scanner'; 6 | 7 | export class StoreSnapshotExtractor { 8 | constructor(private scanner: Scanner) {} 9 | 10 | getSnapshot(store: any, shape: StoreShape) { 11 | const out: Record = {}; 12 | 13 | // plain 14 | for (const key of Object.keys(shape.plain)) { 15 | out[key] = store[key]; 16 | } 17 | 18 | // single collections 19 | for (const key of shape.single) { 20 | out[key] = store[key]?.getSnapshot?.(); 21 | } 22 | 23 | // multi collections 24 | for (const key of shape.multi) { 25 | const mc = store[key]; 26 | if (mc?.getMultiSnapshot) { 27 | out[key] = mc.getMultiSnapshot(); 28 | } 29 | } 30 | 31 | // records 32 | for (const key of shape.records) { 33 | out[key] = store[key]?.getSnapshot?.(); 34 | } 35 | 36 | return out; 37 | } 38 | 39 | applySnapshot(store: any, snap: any, shape: StoreShape) { 40 | if (!snap) { 41 | return; 42 | } 43 | 44 | runInAction(() => { 45 | // plain 46 | for (const key of Object.keys(shape.plain)) { 47 | if (key in snap) { 48 | try { 49 | store[key] = snap[key]; 50 | } catch {} 51 | } 52 | } 53 | 54 | // single 55 | for (const key of shape.single) { 56 | if (snap[key]) { 57 | store[key]?.applySnapshot?.(snap[key], { silent: true }); 58 | } 59 | } 60 | 61 | // multi 62 | for (const key of shape.multi) { 63 | if (snap[key]) { 64 | store[key]?.applyMultiSnapshot?.(snap[key]); 65 | } 66 | } 67 | 68 | // records 69 | for (const key of shape.records) { 70 | if (snap[key]) { 71 | store[key]?.applySnapshot?.(snap[key]); 72 | } 73 | } 74 | }); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /examples/web-react/src/examples/posts-pagination/PostsPaginationExample.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | import { useState, useEffect } from 'react'; 3 | 4 | import { PostCard } from './PostCard'; 5 | import { useStore } from '../../stores/hooks'; 6 | 7 | import type { PostModel } from '../../stores/posts/model'; 8 | 9 | type Group = 'fresh' | 'archived'; 10 | 11 | export const PostsPaginationExample = observer(() => { 12 | const { lists, fetchPosts, fetchMorePosts } = useStore('posts'); 13 | const [group, setGroup] = useState('fresh'); 14 | 15 | const collection = lists[group]; 16 | 17 | const handleLoadMore = () => { 18 | fetchMorePosts[group].run({ params: { group } }); 19 | }; 20 | 21 | useEffect(() => { 22 | fetchPosts[group].run({ params: { group } }); 23 | }, [group, fetchPosts]); 24 | 25 | const isLoading = 26 | fetchPosts[group].isLoading || fetchMorePosts[group].isLoading; 27 | 28 | return ( 29 |
30 |

Posts pagination

31 | 32 |
33 | 40 | 47 |
48 | 49 |
50 | {collection.getList.map((post: PostModel) => ( 51 | 52 | ))} 53 |
54 | 55 | {isLoading &&

Loading…

} 56 | 57 | {!collection.hasNoMore && ( 58 | 61 | )} 62 |
63 | ); 64 | }); 65 | -------------------------------------------------------------------------------- /src/create/proxy.ts: -------------------------------------------------------------------------------- 1 | import { runInAction } from 'mobx'; 2 | 3 | import type { SystemDeps } from '../root/types'; 4 | 5 | const SUPPRESS_KEY = '__suppressPersistNotify'; 6 | 7 | export class StoreProxy { 8 | private notifyQueued = false; 9 | 10 | constructor( 11 | private deps: SystemDeps, 12 | private target: any, 13 | private actions: string[], 14 | ) {} 15 | 16 | build() { 17 | return new Proxy(this.target, { 18 | get: (target, prop, receiver) => { 19 | const value = Reflect.get(target, prop, receiver); 20 | 21 | if (!this.actions.includes(prop as string)) { 22 | return value; 23 | } 24 | 25 | // allow duck/collection objects to pass-through (no wrapping) 26 | if (value && typeof value === 'object') { 27 | return value; 28 | } 29 | 30 | if (typeof value === 'function') { 31 | return this.wrapAction(target, value); 32 | } 33 | 34 | return value; 35 | }, 36 | 37 | set: (target, prop, val, receiver) => { 38 | const result = Reflect.set(target, prop, val, receiver); 39 | this.scheduleNotify(target); 40 | return result; 41 | }, 42 | }); 43 | } 44 | 45 | private scheduleNotify(target: any) { 46 | if ((target?.[SUPPRESS_KEY] ?? 0) > 0) { 47 | return; 48 | } 49 | 50 | if (this.notifyQueued) { 51 | return; 52 | } 53 | this.notifyQueued = true; 54 | 55 | queueMicrotask(() => { 56 | this.notifyQueued = false; 57 | this.deps.getPersistence?.()?.onStoreStateChanged?.(); 58 | }); 59 | } 60 | 61 | private wrapAction(target: any, fn: Function) { 62 | return (...args: any[]) => { 63 | const result = runInAction(() => fn.apply(target, args)); 64 | 65 | if (result instanceof Promise) { 66 | return result.finally(() => this.scheduleNotify(target)); 67 | } 68 | 69 | this.scheduleNotify(target); 70 | return result; 71 | }; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/create/manager.ts: -------------------------------------------------------------------------------- 1 | import { Cleaner } from './cleaner'; 2 | import { StoreSnapshotExtractor } from './extractor'; 3 | import { StoreProxy } from './proxy'; 4 | import { Scanner, type StoreShape } from './scanner'; 5 | import { defineHiddenProp } from './utils'; 6 | 7 | import type { StoreDepsCombined, DomainDeps } from '../root/types'; 8 | 9 | const SUPPRESS_KEY = '__suppressPersistNotify'; 10 | 11 | function incSuppress(store: any) { 12 | store[SUPPRESS_KEY] = (store[SUPPRESS_KEY] ?? 0) + 1; 13 | } 14 | function decSuppress(store: any) { 15 | store[SUPPRESS_KEY] = Math.max(0, (store[SUPPRESS_KEY] ?? 0) - 1); 16 | } 17 | 18 | export class StoreManager { 19 | private scanner = new Scanner(); 20 | private cleaner: Cleaner; 21 | private extractor: StoreSnapshotExtractor; 22 | 23 | constructor(private deps: StoreDepsCombined) { 24 | this.cleaner = new Cleaner(deps.system); 25 | this.extractor = new StoreSnapshotExtractor(this.scanner); 26 | } 27 | 28 | create(StoreClass: new (deps: DomainDeps) => TStore): TStore { 29 | const instance = new StoreClass(this.deps.domain); 30 | const shape: StoreShape = this.scanner.scan(instance); 31 | 32 | // reset 33 | this.cleaner.applyReset(instance, shape); 34 | 35 | // snapshot API (hidden) 36 | defineHiddenProp(instance as any, '__getSnapshot', () => 37 | this.extractor.getSnapshot(instance, shape), 38 | ); 39 | 40 | defineHiddenProp(instance as any, '__applySnapshot', (snap: any) => { 41 | // suppress persist spam while mutating many fields 42 | incSuppress(instance); 43 | this.extractor.applySnapshot(instance, snap, shape); 44 | decSuppress(instance); 45 | 46 | // one notify after restore 47 | queueMicrotask(() => 48 | this.deps.system?.getPersistence?.()?.onStoreStateChanged?.(), 49 | ); 50 | }); 51 | 52 | // proxy for action wrapping + coalesced notify 53 | return new StoreProxy( 54 | this.deps.system, 55 | instance, 56 | shape.actions, 57 | ).build() as TStore; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/utils/__tests__/throttle-by-time.test.ts: -------------------------------------------------------------------------------- 1 | import { throttleByTime } from '../throttle-by-time'; 2 | 3 | jest.useFakeTimers(); 4 | 5 | describe('throttleByTime', () => { 6 | it('runs immediately on first call', () => { 7 | const fn = jest.fn(); 8 | const throttled = throttleByTime(fn, 1000); 9 | 10 | throttled(); 11 | 12 | expect(fn).toHaveBeenCalledTimes(1); 13 | }); 14 | 15 | it('ignores calls within the interval', () => { 16 | const fn = jest.fn(); 17 | const throttled = throttleByTime(fn, 1000); 18 | 19 | throttled(); 20 | throttled(); 21 | throttled(); 22 | 23 | expect(fn).toHaveBeenCalledTimes(1); 24 | }); 25 | 26 | it('runs again after interval passes', () => { 27 | const fn = jest.fn(); 28 | const throttled = throttleByTime(fn, 1000); 29 | 30 | throttled(); 31 | jest.advanceTimersByTime(1000); 32 | throttled(); 33 | 34 | expect(fn).toHaveBeenCalledTimes(2); 35 | }); 36 | 37 | it('handles rapid successive calls safely', () => { 38 | const fn = jest.fn(); 39 | const throttled = throttleByTime(fn, 500); 40 | 41 | throttled(); 42 | jest.advanceTimersByTime(100); 43 | throttled(); // ignored 44 | jest.advanceTimersByTime(100); 45 | throttled(); // ignored 46 | jest.advanceTimersByTime(305); 47 | throttled(); // now allowed 48 | 49 | expect(fn).toHaveBeenCalledTimes(2); 50 | }); 51 | 52 | it('forwards arguments correctly', () => { 53 | const fn = jest.fn(); 54 | const throttled = throttleByTime(fn, 1000); 55 | 56 | throttled('a', 123); 57 | 58 | expect(fn).toHaveBeenCalledWith('a', 123); 59 | }); 60 | 61 | it('keeps internal lastRun isolated between different instances', () => { 62 | const fn1 = jest.fn(); 63 | const fn2 = jest.fn(); 64 | 65 | const throttled1 = throttleByTime(fn1, 1000); 66 | const throttled2 = throttleByTime(fn2, 1000); 67 | 68 | throttled1(); 69 | throttled2(); 70 | 71 | expect(fn1).toHaveBeenCalledTimes(1); 72 | expect(fn2).toHaveBeenCalledTimes(1); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/entities/collection/public.ts: -------------------------------------------------------------------------------- 1 | /** Snapshot shape for persistence */ 2 | export interface EntityCollectionSnapshot { 3 | items: Id[]; 4 | hasNoMore: boolean; 5 | reversed: boolean; 6 | limit: number; 7 | } 8 | 9 | /** Read-only public view */ 10 | export interface EntityCollectionView { 11 | readonly idAttribute: string; 12 | 13 | readonly asArray: Id[]; 14 | readonly first: Id | undefined; 15 | readonly last: Id | undefined; 16 | readonly count: number; 17 | readonly isEmpty: boolean; 18 | 19 | /** Pagination */ 20 | readonly limit: number; 21 | readonly hasNoMore: boolean; 22 | readonly pageNumber: number; 23 | readonly offset: number; 24 | 25 | /** Resolved entities (may trigger lazy cleanup internally) */ 26 | readonly getList: M[]; 27 | 28 | findById(id: Id | undefined): M | undefined; 29 | findIndexById(id: Id): number; 30 | byIndex(index: number): Id | undefined; 31 | includes(item: any): boolean; 32 | } 33 | 34 | /** Mutable public API */ 35 | export interface EntityCollectionActions { 36 | set(data: T[]): void; 37 | append(data: T[]): void; 38 | prepend(data: T[]): void; 39 | add(item: T): void; 40 | updateItem(item: T): void; 41 | 42 | removeById(id: Id): void; 43 | removeIds(ids: Id[]): void; 44 | 45 | reset(): void; 46 | setHasNoMore(value: boolean): void; 47 | } 48 | 49 | /** Snapshot API (used by persist, but safe to expose) */ 50 | export interface EntityCollectionSnapshotAPI { 51 | getSnapshot(): EntityCollectionSnapshot; 52 | applySnapshot(snapshot: EntityCollectionSnapshot): void; 53 | } 54 | 55 | /** Final public type */ 56 | export type EntityCollection< 57 | T = any, 58 | M = any, 59 | Id = string | number, 60 | > = EntityCollectionView & 61 | EntityCollectionActions & 62 | EntityCollectionSnapshotAPI; 63 | 64 | export type MultiEntityCollection = Record< 65 | string, 66 | EntityCollection 67 | > & { 68 | readonly base: EntityCollection; 69 | reset(): void; 70 | }; 71 | -------------------------------------------------------------------------------- /src/create/__tests__/manager.test.ts: -------------------------------------------------------------------------------- 1 | import { StoreManager } from '../manager'; 2 | 3 | jest.mock('mobx', () => ({ 4 | runInAction: (fn: any) => fn(), 5 | })); 6 | 7 | class TestStore { 8 | constructor(public deps: any) {} 9 | 10 | x = 1; 11 | 12 | actionOne() {} 13 | actionTwo() {} 14 | } 15 | 16 | describe('StoreManager (integration)', () => { 17 | let deps: any; 18 | let manager: StoreManager; 19 | let notify: jest.Mock; 20 | 21 | beforeEach(() => { 22 | notify = jest.fn(); 23 | 24 | deps = { 25 | domain: { foo: 1 }, 26 | system: { 27 | getPersistence: () => ({ 28 | onStoreStateChanged: notify, 29 | }), 30 | }, 31 | }; 32 | 33 | manager = new StoreManager(deps); 34 | }); 35 | 36 | // ------------------------------------- 37 | it('creates store instance and wraps it with proxy', () => { 38 | const store = manager.create(TestStore); 39 | 40 | expect(store).toBeDefined(); 41 | expect(store).toBeInstanceOf(TestStore); 42 | }); 43 | 44 | // ------------------------------------- 45 | it('injects deps into store constructor', () => { 46 | const store = manager.create(TestStore); 47 | 48 | expect(store.deps).toBe(deps.domain); 49 | }); 50 | 51 | // ------------------------------------- 52 | it('attaches hidden snapshot APIs', () => { 53 | const store: any = manager.create(TestStore); 54 | 55 | expect(typeof store.__getSnapshot).toBe('function'); 56 | expect(typeof store.__applySnapshot).toBe('function'); 57 | }); 58 | 59 | // ------------------------------------- 60 | it('attaches resetStore via Cleaner', () => { 61 | const store: any = manager.create(TestStore); 62 | 63 | expect(typeof store.resetStore).toBe('function'); 64 | }); 65 | 66 | // ------------------------------------- 67 | it('wraps actions and triggers persist notify', async () => { 68 | const store: any = manager.create(TestStore); 69 | 70 | store.actionOne(); 71 | 72 | // microtask flush 73 | await Promise.resolve(); 74 | 75 | expect(notify).toHaveBeenCalled(); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nexigen/entity-normalizer", 3 | "version": "0.1.6", 4 | "description": "Reactive entity infrastructure for complex client applications, built on MobX.", 5 | "license": "MIT", 6 | "type": "module", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/nexigenjs/entity-normalizer.git" 10 | }, 11 | "homepage": "https://github.com/nexigenjs/entity-normalizer", 12 | "bugs": { 13 | "url": "https://github.com/nexigenjs/entity-normalizer/issues" 14 | }, 15 | "main": "dist/index.js", 16 | "types": "dist/index.d.ts", 17 | "files": [ 18 | "dist" 19 | ], 20 | "sideEffects": false, 21 | "scripts": { 22 | "lint": "eslint src", 23 | "lint:fix": "eslint src --fix", 24 | "prettier": "prettier .", 25 | "prettier:check": "prettier . --check", 26 | "prettier:write": "prettier . --write", 27 | "test": "jest", 28 | "test:watch": "jest --watch", 29 | "typecheck": "tsc --noEmit", 30 | "build": "tsc", 31 | "prepare": "husky install" 32 | }, 33 | "lint-staged": { 34 | "*.{ts,tsx}": [ 35 | "eslint --fix" 36 | ] 37 | }, 38 | "peerDependencies": { 39 | "mobx": ">=6.12 <7" 40 | }, 41 | "devDependencies": { 42 | "@types/jest": "^30.0.0", 43 | "@types/node": "^25.0.2", 44 | "@typescript-eslint/eslint-plugin": "^8.31.1", 45 | "@typescript-eslint/parser": "^8.31.1", 46 | "eslint": "^9.26.0", 47 | "eslint-config-prettier": "^10.1.8", 48 | "eslint-import-resolver-typescript": "^4.4.4", 49 | "eslint-plugin-import": "^2.31.0", 50 | "husky": "^9.1.7", 51 | "jest": "^30.2.0", 52 | "lint-staged": "^16.2.7", 53 | "mobx": "^6.13.7", 54 | "prettier": "^3.7.4", 55 | "ts-jest": "^29.4.6", 56 | "ts-node": "^10.9.2", 57 | "typescript": "^5.8.3" 58 | }, 59 | "engines": { 60 | "node": ">=20" 61 | }, 62 | "keywords": [ 63 | "entity", 64 | "normalization", 65 | "state-management", 66 | "mobx", 67 | "mobx-store", 68 | "data-layer", 69 | "client-state", 70 | "normalized-cache", 71 | "frontend-architecture", 72 | "domain-driven-design", 73 | "ddd", 74 | "reactive" 75 | ] 76 | } 77 | -------------------------------------------------------------------------------- /examples/mobile/src/screens/Viewer/index.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | import { View, Text, Pressable } from 'react-native'; 3 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 4 | 5 | import { styles } from './styles'; 6 | import { ViewerCard } from './viewer-card'; 7 | import { useStores } from '../../stores/hooks'; 8 | 9 | export const ViewerScreen = observer(() => { 10 | const { top } = useSafeAreaInsets(); 11 | 12 | const { 13 | viewer: { 14 | currentViewer, 15 | viewerDetails, 16 | fetchCurrentViewer, 17 | fetchViewerDetails, 18 | }, 19 | } = useStores(); 20 | 21 | return ( 22 | 23 | Viewer records 24 | 25 | {/* actions */} 26 | 27 | fetchCurrentViewer.run()} 30 | > 31 | Load current viewer button 32 | 33 | 34 | fetchViewerDetails.run({ params: { id: 'u2' } })} 37 | > 38 | Load viewer u2 button 39 | 40 | 41 | fetchViewerDetails.run({ params: { id: 'u1' } })} 44 | > 45 | Load viewer u1 button 46 | 47 | 48 | 49 | {/* cards */} 50 | 51 | 52 | Current viewer 53 | 54 | 55 | 56 | 57 | 58 | Viewer details (always full data of this user) 59 | 60 | 61 | 62 | 63 | 64 | ); 65 | }); 66 | -------------------------------------------------------------------------------- /docs/docs/02-architecture-overview.md: -------------------------------------------------------------------------------- 1 | # Architecture Overview 2 | 3 | This document explains **how Nexigen is structured internally** 4 | and how data flows through the system. 5 | 6 | --- 7 | 8 | ## High-Level Flow 9 | 10 | ``` 11 | API Response 12 | ↓ 13 | Schema Normalization 14 | ↓ 15 | Records / Collections register refSources 16 | ↓ 17 | EntitiesStore.merge() 18 | ↓ 19 | MobX reactive graph updates UI 20 | ``` 21 | 22 | --- 23 | 24 | ## Core Building Blocks 25 | 26 | ### EntitiesStore 27 | 28 | Global normalized cache. 29 | Owns all entity instances. 30 | 31 | ### EntityRecord 32 | 33 | Pointer to a single entity. 34 | Used for detail-like state. 35 | 36 | ### EntityCollection 37 | 38 | Ordered list of entity IDs. 39 | Used for lists and pagination. 40 | 41 | ### MultiEntityCollection 42 | 43 | Multiple isolated collections over the same entity type. 44 | 45 | ### Duck 46 | 47 | Observable async command. 48 | 49 | --- 50 | 51 | ## Dependency Direction 52 | 53 | Dependencies always point **inward**: 54 | 55 | ``` 56 | Store → EntitiesStore 57 | Store → API 58 | Model → EntityGetter → EntitiesStore 59 | ``` 60 | 61 | Never: 62 | 63 | ``` 64 | EntitiesStore → Store 65 | Model → Store 66 | Collection → Store 67 | ``` 68 | 69 | This ensures: 70 | 71 | - no cycles 72 | - predictable ownership 73 | - easy testing 74 | 75 | --- 76 | 77 | ## Why There Is One EntitiesStore 78 | 79 | Having a single entity cache ensures: 80 | 81 | - identity stability 82 | - cross-screen consistency 83 | - trivial deduplication 84 | - deterministic cleanup 85 | 86 | Multiple entity caches reintroduce duplication problems. 87 | 88 | --- 89 | 90 | ## Why Collections Are Separate from Entities 91 | 92 | Entities represent **what exists**. 93 | Collections represent **how it is viewed**. 94 | 95 | This separation allows: 96 | 97 | - multiple views over same data 98 | - independent pagination 99 | - safe resets without data loss 100 | 101 | --- 102 | 103 | ## Architectural Guarantees 104 | 105 | If used correctly, Nexigen guarantees: 106 | 107 | - no duplicated entities 108 | - consistent updates everywhere 109 | - bounded memory (with refSource cleanup) 110 | - predictable async behavior 111 | -------------------------------------------------------------------------------- /examples/mobile/src/screens/Viewer/styles.ts: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native'; 2 | 3 | export const styles = StyleSheet.create({ 4 | container: { 5 | flex: 1, 6 | backgroundColor: '#0E0F12', 7 | paddingHorizontal: 16, 8 | }, 9 | 10 | screenTitle: { 11 | fontSize: 20, 12 | fontWeight: '600', 13 | color: '#F2F2F2', 14 | marginBottom: 16, 15 | }, 16 | 17 | /* -------- actions -------- */ 18 | 19 | actions: { 20 | gap: 10, 21 | marginBottom: 20, 22 | }, 23 | 24 | actionButton: { 25 | backgroundColor: '#1A1C22', 26 | paddingVertical: 12, 27 | paddingHorizontal: 14, 28 | borderRadius: 12, 29 | }, 30 | 31 | actionText: { 32 | color: '#EDEEF3', 33 | fontWeight: '500', 34 | fontSize: 14, 35 | }, 36 | 37 | /* -------- cards -------- */ 38 | 39 | cards: { 40 | gap: 20, 41 | }, 42 | 43 | cardBlock: { 44 | gap: 8, 45 | }, 46 | 47 | blockTitle: { 48 | color: '#9CA3AF', 49 | fontSize: 13, 50 | fontWeight: '500', 51 | }, 52 | 53 | viewerCard: { 54 | flexDirection: 'row', 55 | gap: 12, 56 | padding: 14, 57 | borderRadius: 14, 58 | backgroundColor: '#1A1C22', 59 | }, 60 | 61 | avatar: { 62 | width: 64, 63 | height: 64, 64 | borderRadius: 32, 65 | }, 66 | 67 | avatarPlaceholder: { 68 | width: 64, 69 | height: 64, 70 | borderRadius: 32, 71 | backgroundColor: '#2D6BFF', 72 | alignItems: 'center', 73 | justifyContent: 'center', 74 | }, 75 | 76 | avatarLetter: { 77 | color: '#fff', 78 | fontSize: 22, 79 | fontWeight: '700', 80 | }, 81 | 82 | viewerMeta: { 83 | flex: 1, 84 | gap: 4, 85 | }, 86 | 87 | viewerName: { 88 | color: '#F9FAFB', 89 | fontSize: 16, 90 | fontWeight: '600', 91 | }, 92 | 93 | viewerEmail: { 94 | color: '#9CA3AF', 95 | fontSize: 13, 96 | }, 97 | 98 | viewerBio: { 99 | color: '#D1D5DB', 100 | fontSize: 13, 101 | marginTop: 6, 102 | }, 103 | 104 | emptyCard: { 105 | padding: 16, 106 | borderRadius: 14, 107 | backgroundColor: '#1A1C22', 108 | alignItems: 'center', 109 | }, 110 | 111 | emptyText: { 112 | color: '#9CA3AF', 113 | }, 114 | }); 115 | -------------------------------------------------------------------------------- /src/create/cleaner.ts: -------------------------------------------------------------------------------- 1 | import { runInAction } from 'mobx'; 2 | 3 | import type { StoreShape } from './scanner'; 4 | import type { SystemDeps } from '../root/types'; 5 | 6 | const SUPPRESS_KEY = '__suppressPersistNotify'; 7 | 8 | function incSuppress(store: any) { 9 | store[SUPPRESS_KEY] = (store[SUPPRESS_KEY] ?? 0) + 1; 10 | } 11 | function decSuppress(store: any) { 12 | store[SUPPRESS_KEY] = Math.max(0, (store[SUPPRESS_KEY] ?? 0) - 1); 13 | } 14 | 15 | export class Cleaner { 16 | constructor(private deps: SystemDeps) {} 17 | 18 | applyReset( 19 | store: any, 20 | shape: Pick, 21 | ) { 22 | const initialPlain = shape.plain; 23 | 24 | if (typeof store.resetStore === 'function') { 25 | return; 26 | } 27 | 28 | Object.defineProperty(store, 'resetStore', { 29 | enumerable: false, 30 | configurable: true, 31 | writable: true, 32 | value: () => { 33 | incSuppress(store); 34 | 35 | runInAction(() => { 36 | // plain fields 37 | for (const [key, val] of Object.entries(initialPlain)) { 38 | try { 39 | store[key] = val; 40 | } catch {} 41 | } 42 | 43 | // single collections 44 | for (const key of shape.single) { 45 | try { 46 | store[key].reset?.(); 47 | } catch {} 48 | } 49 | 50 | // multi collections 51 | for (const key of shape.multi) { 52 | const mc = store[key]; 53 | try { 54 | if (mc?.resetAll) { 55 | mc.resetAll(); 56 | } else if (mc?.getSubCollections) { 57 | for (const col of mc.getSubCollections().values()) { 58 | col.reset?.({ silent: true }); 59 | } 60 | } 61 | } catch {} 62 | } 63 | 64 | // records 65 | for (const key of shape.records) { 66 | try { 67 | store[key].reset?.(); 68 | } catch {} 69 | } 70 | }); 71 | 72 | decSuppress(store); 73 | 74 | // 1 notify, async 75 | queueMicrotask(() => 76 | this.deps?.getPersistence?.()?.onStoreStateChanged?.(), 77 | ); 78 | }, 79 | }); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /docs/docs/08-models-and-entity-getter.md: -------------------------------------------------------------------------------- 1 | # Models & EntityGetter 2 | 3 | Models wrap normalized DTOs and provide computed logic. 4 | 5 | --- 6 | 7 | ## Model Responsibilities 8 | 9 | Models: 10 | 11 | - expose fields 12 | - provide computed values 13 | - resolve relations lazily 14 | 15 | Models do NOT: 16 | 17 | - own data 18 | - fetch data 19 | - manage lifecycle 20 | 21 | --- 22 | 23 | ## Constructor Signature 24 | 25 | ```ts 26 | export class PostModel { 27 | id: string; 28 | viewerId: string; 29 | title: string; 30 | 31 | constructor( 32 | dto: PostDto, 33 | private readonly get: EntityGetter, 34 | ) { 35 | this.id = dto.id; 36 | this.viewerId = dto.viewerId; 37 | this.title = dto.title; 38 | 39 | makeAutoObservable(this, {}, { autoBind: true }); 40 | } 41 | 42 | get viewer() { 43 | return this.get(ENTITY_KEY.VIEWER, this.viewerId); 44 | } 45 | } 46 | ``` 47 | 48 | --- 49 | 50 | ## EntityGetter 51 | 52 | EntityGetter resolves entities from EntitiesStore without strong references. 53 | 54 | ```ts 55 | get(ENTITY_KEY.VIEWER, viewerId); 56 | ``` 57 | 58 | --- 59 | 60 | ## Relations Example 61 | 62 | ```ts 63 | get viewer() { 64 | return this.get(ENTITY_KEY.VIEWER, this.viewerId); 65 | } 66 | ``` 67 | 68 | --- 69 | 70 | ## Why Not Direct References 71 | 72 | Direct references cause: 73 | 74 | - circular graphs 75 | - memory leaks 76 | - unsafe cleanup 77 | 78 | EntityGetter avoids these problems. 79 | 80 | --- 81 | 82 | ## Reactivity Guarantees 83 | 84 | - relation access is reactive 85 | - MobX tracks reads automatically 86 | - UI updates are precise 87 | 88 | --- 89 | 90 | ## Anti-Patterns 91 | 92 | ❌ Caching resolved relations 93 | ❌ Storing model references 94 | ❌ Mutating DTOs directly 95 | 96 | --- 97 | 98 | ### Mutating DTOs Directly 99 | 100 | Entity models must be treated as **read-only views** 101 | over normalized data. 102 | 103 | Directly mutating fields originating from DTOs 104 | (e.g. `model.title = ...`) is not allowed. 105 | 106 | All entity updates must go through Records or Collections, 107 | ensuring proper normalization, lifecycle tracking 108 | and deterministic cleanup. 109 | 110 | --- 111 | 112 | ## Guarantees 113 | 114 | - GC-safe relations 115 | - no stale references 116 | - shared entity updates 117 | -------------------------------------------------------------------------------- /src/entities/types.ts: -------------------------------------------------------------------------------- 1 | import type { EntitySchema } from './schema'; 2 | import type { EntitiesStore } from './store'; 3 | import type { META } from '../constants/values'; 4 | 5 | /** 6 | * Runtime schema type for infra layers 7 | */ 8 | export type AnySchema = EntitySchema; 9 | 10 | /** 11 | * Generic entity buckets shape 12 | * key -> entityKey (string) 13 | * value -> map of id -> stored entity 14 | */ 15 | export type EntityBuckets = Record>>; 16 | 17 | /** 18 | * Resolver injected into models 19 | */ 20 | export type GetEntity = ( 21 | entityKey: string, 22 | id: string | number, 23 | ) => PublicEntity> | undefined; 24 | 25 | /** 26 | * Entity model constructor contract 27 | */ 28 | export interface EntityModelCtor { 29 | new (dto: TDto, getEntity: GetEntity): TModel; 30 | } 31 | 32 | /** 33 | * Schema options (runtime-oriented) 34 | */ 35 | export interface EntitySchemaOptions { 36 | idAttribute?: ((input: any) => string | number) | string; 37 | processStrategy?: (input: any) => any; 38 | mergeStrategy?: (a: any, b: any) => any; 39 | model?: EntityModelCtor; 40 | } 41 | 42 | /** 43 | * Core store type (no domain knowledge) 44 | */ 45 | export type TEntitiesStore = EntitiesStore; 46 | 47 | /** 48 | * Entity metadata (infra concern) 49 | */ 50 | export type EntityMeta = { 51 | createdAt: number; 52 | updatedAt: number; 53 | accessedAt: number; 54 | refSources?: Set; 55 | }; 56 | 57 | /** 58 | * Stored entity = model + meta 59 | */ 60 | export type StoredEntity = T & { 61 | _meta_nexigen: EntityMeta; 62 | }; 63 | 64 | /** 65 | * Garbage collector item 66 | */ 67 | export type EntityGarbageCollectorItem = { 68 | id: string; 69 | meta: EntityMeta; 70 | }; 71 | 72 | /** 73 | * Normalization output 74 | */ 75 | export interface NormalizedOutput { 76 | map: Partial; 77 | ids: (string | number)[]; 78 | } 79 | 80 | /** 81 | * Entity processing options 82 | */ 83 | export interface EntityProcessOptions { 84 | data: T | T[]; 85 | entityKey: string; 86 | sourceRefId: string; 87 | isCollection?: boolean; 88 | } 89 | 90 | /** 91 | * Persist snapshot shape 92 | */ 93 | export type EntitiesSnapshot = Record>; 94 | 95 | /** 96 | * Public projection (hide meta from DX) 97 | */ 98 | export type PublicEntity = Omit; 99 | -------------------------------------------------------------------------------- /docs/api/entity-getter.md: -------------------------------------------------------------------------------- 1 | # EntityGetter 2 | 3 | `EntityGetter` is a **read-only access interface** to the normalized entity graph. 4 | 5 | It is the **only supported way** for models to access other entities. 6 | 7 | --- 8 | 9 | ## Purpose 10 | 11 | EntityGetter exists to solve a core problem: 12 | 13 | > How can models reference other entities **without owning them**? 14 | 15 | EntityGetter provides: 16 | 17 | - safe access to related entities 18 | - reactive reads 19 | - garbage-collection safety 20 | 21 | Models must never access `EntitiesStore` directly. 22 | 23 | --- 24 | 25 | ## Mental Model 26 | 27 | An `EntityGetter` is a **lens** into the entity graph. 28 | 29 | It: 30 | 31 | - does not store data 32 | - does not create references 33 | - does not affect lifecycle 34 | 35 | It only **reads**. 36 | 37 | --- 38 | 39 | ## Public API 40 | 41 | ### get(entityKey, id) 42 | 43 | ```ts 44 | get(entityKey: string, id: string | number): Model | undefined 45 | ``` 46 | 47 | Returns: 48 | 49 | - model instance if entity exists 50 | - `undefined` if entity is missing or already cleaned up 51 | 52 | This method is **reactive**. 53 | 54 | --- 55 | 56 | ## Usage in Models 57 | 58 | ```ts 59 | export class PostModel { 60 | constructor( 61 | dto: PostDto, 62 | private get: EntityGetter, 63 | ) {} 64 | 65 | get author() { 66 | return this.get(ENTITY_KEY.VIEWER, this.viewerId); 67 | } 68 | } 69 | ``` 70 | 71 | Behavior: 72 | 73 | - accessing `author` subscribes to that entity 74 | - updates propagate automatically 75 | - no strong references are created 76 | 77 | --- 78 | 79 | ## GC Safety 80 | 81 | EntityGetter is **garbage-collector safe** by design. 82 | 83 | Important properties: 84 | 85 | - does not attach refSources 86 | - does not prevent entity removal 87 | - handles missing entities gracefully 88 | 89 | If an entity is removed: 90 | 91 | - `get()` returns `undefined` 92 | - no exceptions are thrown 93 | - models remain stable 94 | 95 | --- 96 | 97 | ## Reactive Semantics 98 | 99 | Reads through `EntityGetter`: 100 | 101 | - participate in MobX dependency tracking 102 | - trigger re-computation on entity updates 103 | - do not trigger writes or lifecycle changes 104 | 105 | --- 106 | 107 | ## Anti-Patterns 108 | 109 | ❌ Storing returned models in local state 110 | ❌ Accessing `EntitiesStore` inside models 111 | ❌ Assuming related entities always exist 112 | 113 | --- 114 | 115 | ## Guarantees 116 | 117 | - no ownership 118 | - no memory leaks 119 | - safe missing references 120 | - deterministic reactivity 121 | -------------------------------------------------------------------------------- /examples/mobile/src/screens/Posts/index.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | import { useEffect, useState } from 'react'; 3 | import { View, Text, FlatList, Pressable } from 'react-native'; 4 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 5 | 6 | import { PostCard } from './post-card'; 7 | import { styles } from './styles'; 8 | import { useStore } from '../../stores/hooks'; 9 | 10 | type Group = 'fresh' | 'archived'; 11 | 12 | export const PostsScreen = observer(() => { 13 | const { top } = useSafeAreaInsets(); 14 | const { lists, fetchPosts, fetchMorePosts } = useStore('posts'); 15 | const [group, setGroup] = useState('fresh'); 16 | 17 | const collection = lists[group]; 18 | const fetchState = fetchPosts[group]; 19 | 20 | useEffect(() => { 21 | fetchState.run({ params: { group } }); 22 | }, [group, fetchState]); 23 | 24 | const isInitialLoading = fetchState.isLoading && collection.isEmpty; 25 | 26 | const isLoadingMore = fetchMorePosts[group].isLoading; 27 | 28 | return ( 29 | 30 | Posts pagination 31 | 32 | 33 | setGroup('fresh')} 35 | style={[styles.tab, group === 'fresh' && styles.tabActive]} 36 | > 37 | Fresh 38 | 39 | 40 | setGroup('archived')} 42 | style={[styles.tab, group === 'archived' && styles.tabActive]} 43 | > 44 | Archived 45 | 46 | 47 | {isInitialLoading && Loading…} 48 | {!isInitialLoading && ( 49 | item.id} 53 | numColumns={2} 54 | contentContainerStyle={styles.list} 55 | renderItem={({ item }) => ( 56 | 57 | 58 | 59 | )} 60 | onEndReached={() => { 61 | if (!collection.hasNoMore) { 62 | fetchMorePosts[group].run({ params: { group } }); 63 | } 64 | }} 65 | onEndReachedThreshold={0.6} 66 | ListFooterComponent={ 67 | isLoadingMore ? Loading… : null 68 | } 69 | /> 70 | )} 71 | 72 | ); 73 | }); 74 | -------------------------------------------------------------------------------- /examples/mobile/src/screens/Comments/styles.ts: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native'; 2 | 3 | export const styles = StyleSheet.create({ 4 | container: { 5 | flex: 1, 6 | backgroundColor: '#0E0F12', 7 | }, 8 | 9 | screenTitle: { 10 | fontSize: 20, 11 | fontWeight: '600', 12 | color: '#F2F2F2', 13 | paddingHorizontal: 16, 14 | marginBottom: 12, 15 | }, 16 | 17 | postsList: { 18 | paddingHorizontal: 12, 19 | paddingBottom: 8, 20 | }, 21 | 22 | postChip: { 23 | paddingVertical: 8, 24 | paddingHorizontal: 12, 25 | borderRadius: 16, 26 | backgroundColor: '#1A1C22', 27 | marginRight: 8, 28 | maxWidth: 160, 29 | height: 30, 30 | justifyContent: 'center', 31 | alignItems: 'center', 32 | }, 33 | 34 | postChipActive: { 35 | backgroundColor: '#2D6BFF', 36 | }, 37 | 38 | postChipText: { 39 | color: '#B0B3C0', 40 | fontSize: 13, 41 | }, 42 | 43 | postChipTextActive: { 44 | color: '#FFFFFF', 45 | fontWeight: '500', 46 | }, 47 | 48 | hint: { 49 | color: '#7C8090', 50 | textAlign: 'center', 51 | marginVertical: 12, 52 | }, 53 | 54 | commentsList: { 55 | paddingHorizontal: 16, 56 | paddingBottom: 24, 57 | }, 58 | 59 | commentCard: { 60 | backgroundColor: '#1A1C22', 61 | borderRadius: 14, 62 | padding: 12, 63 | marginBottom: 12, 64 | }, 65 | 66 | commentHeader: { 67 | flexDirection: 'row', 68 | alignItems: 'center', 69 | marginBottom: 8, 70 | }, 71 | 72 | avatar: { 73 | width: 36, 74 | height: 36, 75 | borderRadius: 18, 76 | marginRight: 10, 77 | }, 78 | 79 | avatarPlaceholder: { 80 | width: 36, 81 | height: 36, 82 | borderRadius: 18, 83 | backgroundColor: '#2D6BFF', 84 | alignItems: 'center', 85 | justifyContent: 'center', 86 | marginRight: 10, 87 | }, 88 | 89 | avatarLetter: { 90 | color: '#fff', 91 | fontWeight: '600', 92 | }, 93 | 94 | commentMeta: { 95 | flex: 1, 96 | }, 97 | 98 | commentAuthor: { 99 | color: '#EDEEF3', 100 | fontWeight: '600', 101 | fontSize: 13, 102 | }, 103 | 104 | commentBio: { 105 | color: '#9CA3AF', 106 | fontSize: 11, 107 | }, 108 | 109 | commentText: { 110 | color: '#D6D8E0', 111 | fontSize: 14, 112 | lineHeight: 18, 113 | }, 114 | 115 | loading: { 116 | textAlign: 'center', 117 | color: '#B0B3C0', 118 | marginVertical: 16, 119 | }, 120 | 121 | empty: { 122 | textAlign: 'center', 123 | color: '#B0B3C0', 124 | marginTop: 24, 125 | }, 126 | }); 127 | -------------------------------------------------------------------------------- /docs/docs/04-entity-record.md: -------------------------------------------------------------------------------- 1 | # EntityRecord 2 | 3 | EntityRecord represents a **stable pointer to a single entity** inside EntitiesStore. 4 | It does not own data and never clones it. 5 | 6 | --- 7 | 8 | ## Purpose 9 | 10 | EntityRecord is designed for: 11 | 12 | - current user (viewer) 13 | - selected item 14 | - detail screens 15 | - singleton domain state 16 | 17 | A record answers the question: 18 | 19 | > “Which entity is currently active for this concern?” 20 | 21 | --- 22 | 23 | ## Creation 24 | 25 | ```ts 26 | const viewerRecord = this.deps.core.entities.createRecord< 27 | ViewerDto, 28 | ViewerModel 29 | >({ 30 | entityKey: ENTITY_KEY.VIEWER, 31 | recordId: REF_SOURCE.CURRENT_VIEWER, 32 | }); 33 | ``` 34 | 35 | - `entityKey` defines entity type 36 | - `recordId` is used as a **refSource** 37 | - `recordId` must be stable and deterministic 38 | 39 | --- 40 | 41 | ## API 42 | 43 | ```ts 44 | record.set(item); 45 | record.update(item); 46 | record.reset(); 47 | 48 | record.data; // Model | null 49 | record.entityId; // string | null 50 | record.exists; 51 | record.isEmpty; 52 | ``` 53 | 54 | --- 55 | 56 | ## set(dto) 57 | 58 | Behavior: 59 | 60 | 1. DTO is normalized 61 | 2. Entity is merged into EntitiesStore 62 | 3. refSource is attached 63 | 4. record.entityId is updated 64 | 65 | ```ts 66 | viewerRecord.set(response); 67 | ``` 68 | 69 | If multiple records point to the same entity: 70 | 71 | - all records resolve to the same model instance 72 | 73 | --- 74 | 75 | ## reset() 76 | 77 | Behavior: 78 | 79 | 1. refSource is detached 80 | 2. record.entityId becomes null 81 | 3. entity may be removed if orphaned 82 | 83 | ```ts 84 | viewerRecord.reset(); 85 | ``` 86 | 87 | Important: 88 | 89 | - record instance remains valid 90 | - accessing record.data returns null 91 | 92 | --- 93 | 94 | ## Lifecycle Example 95 | 96 | ```ts 97 | viewerRecord.set({ id: '1', name: 'Anna' }); 98 | viewerRecord.data.name === 'Anna'; 99 | 100 | viewerRecord.reset(); 101 | viewerRecord.data === null; 102 | ``` 103 | 104 | If no other refSources exist: 105 | 106 | - entity VIEWER:1 is removed 107 | 108 | --- 109 | 110 | ## Edge Cases 111 | 112 | ### Multiple Records 113 | 114 | ```ts 115 | const r1 = createRecord(...); 116 | const r2 = createRecord(...); 117 | 118 | r1.set({ id: '1' }); 119 | r2.set({ id: '1' }); 120 | ``` 121 | 122 | - entity exists once 123 | - refSources = { r1, r2 } 124 | 125 | Removing one record does NOT delete entity. 126 | 127 | --- 128 | 129 | ## Anti-Patterns 130 | 131 | ❌ Copying record.data into local state 132 | ❌ Manually mutating entity fields 133 | 134 | --- 135 | 136 | ## Guarantees 137 | 138 | - stable identity 139 | - no duplication 140 | - safe cleanup 141 | -------------------------------------------------------------------------------- /docs/docs/05-entity-collection.md: -------------------------------------------------------------------------------- 1 | # EntityCollection 2 | 3 | EntityCollection represents an **ordered list of entity IDs**. 4 | It is the primary abstraction for lists and pagination. 5 | 6 | --- 7 | 8 | ## Purpose 9 | 10 | EntityCollection is used for: 11 | 12 | - feeds 13 | - search results 14 | - paginated lists 15 | - ordered projections over entities 16 | 17 | It does NOT store objects. 18 | 19 | --- 20 | 21 | ## Creation 22 | 23 | ```ts 24 | const viewers = this.deps.core.entities.createCollection< 25 | ViewerDto, //types 26 | ViewerModel 27 | >({ 28 | entityKey: ENTITY_KEY.VIEWER, 29 | collectionId: REF_SOURCE.VIEWERS, 30 | }); 31 | ``` 32 | 33 | --- 34 | 35 | ## Mutation API 36 | 37 | ``` 38 | collection.set(data) 39 | collection.append(data) 40 | collection.prepend(data) 41 | collection.add(item) 42 | collection.updateItem(item) 43 | collection.reset() 44 | collection.removeById(id) 45 | collection.removeIds(ids) 46 | collection.setHasNoMore(value) 47 | ``` 48 | 49 | ## Read API 50 | 51 | ``` 52 | collection.getList 53 | collection.pageNumber 54 | collection.offset 55 | collection.hasNoMore 56 | collection.limit 57 | collection.count 58 | collection.isEmpty 59 | collection.first 60 | collection.last 61 | collection.asArray 62 | 63 | ``` 64 | 65 | ## Lookup helpers (id based helpers) 66 | 67 | ``` 68 | collection.findById(id) 69 | collection.includes(item) 70 | collection.findIndexById(id) 71 | ``` 72 | 73 | --- 74 | 75 | ## set(items) 76 | 77 | - replaces list contents 78 | - registers refSource for all items 79 | - resets pagination state 80 | 81 | ```ts 82 | posts.set(response); 83 | ``` 84 | 85 | --- 86 | 87 | ## append(items) 88 | 89 | - adds items to the end 90 | - preserves order 91 | - updates pagination metadata 92 | 93 | ```ts 94 | posts.append(response); 95 | ``` 96 | 97 | --- 98 | 99 | ## updateItem(item) 100 | 101 | - merges entity into store 102 | - replaces item in list if present 103 | - does NOT change order 104 | 105 | Used for detail updates. 106 | 107 | --- 108 | 109 | ## Pagination Semantics 110 | 111 | ### pageNumber 112 | 113 | `pageNumber` always represents the **NEXT page to fetch**. 114 | 115 | ```ts 116 | page = collection.pageNumber; 117 | ``` 118 | 119 | ### hasNoMore 120 | 121 | `true` when last fetch returned less than `limit`. 122 | 123 | --- 124 | 125 | ## Offset-Based APIs 126 | 127 | For offset-based APIs: 128 | 129 | ``` 130 | offset = collection.count 131 | ``` 132 | 133 | Never derive offset from pageNumber. 134 | 135 | --- 136 | 137 | ## reset() 138 | 139 | - removes refSource 140 | - clears list 141 | - entities may be deleted if orphaned 142 | 143 | --- 144 | 145 | ## Guarantees 146 | 147 | - order preserved 148 | - shared entities 149 | - deterministic cleanup 150 | -------------------------------------------------------------------------------- /docs/docs/03-entities-store.md: -------------------------------------------------------------------------------- 1 | # EntitiesStore 2 | 3 | EntitiesStore is the **core of Nexigen**. 4 | 5 | Every other abstraction exists to interact with it safely. 6 | 7 | --- 8 | 9 | ## What EntitiesStore Is 10 | 11 | EntitiesStore is: 12 | 13 | - a global registry of entities 14 | - normalized by ENTITY_KEY 15 | - shared by the entire application 16 | 17 | There is exactly **one EntitiesStore per app**. 18 | 19 | --- 20 | 21 | ## Responsibilities 22 | 23 | EntitiesStore is responsible for: 24 | 25 | - storing entities by type + id 26 | - deduplicating entities 27 | - merging incoming data 28 | - tracking refSources 29 | - providing entity access 30 | 31 | It is NOT responsible for: 32 | 33 | - UI state 34 | - business logic 35 | - async orchestration 36 | 37 | --- 38 | 39 | ## Entity Identity 40 | 41 | An entity is uniquely identified by: 42 | 43 | ``` 44 | (entityKey, entityId) 45 | ``` 46 | 47 | If two API responses contain the same entity: 48 | 49 | - they resolve to the same model instance 50 | 51 | This identity stability is critical. 52 | 53 | --- 54 | 55 | ## Core API 56 | 57 | ### merge 58 | 59 | ```ts 60 | entities.merge(payload, refSource?) 61 | ``` 62 | 63 | Merges normalized payload into the store. 64 | 65 | Behavior: 66 | 67 | - existing entities are updated 68 | - new entities are created 69 | - meta timestamps are updated 70 | - refSource is registered 71 | 72 | ### getEntity 73 | 74 | ```ts 75 | entities.getEntity(entityKey, id); 76 | ``` 77 | 78 | Returns: 79 | 80 | - model instance if exists 81 | - null if missing 82 | 83 | This call is reactive. 84 | 85 | --- 86 | 87 | ## RefSource Tracking 88 | 89 | Every entity tracks **why it exists**. 90 | 91 | Examples of refSources: 92 | 93 | - collection id 94 | - record id 95 | 96 | If all refSources are removed: 97 | 98 | - the entity becomes orphaned 99 | - it is removed automatically 100 | 101 | --- 102 | 103 | ## Merge Semantics 104 | 105 | - last write wins per field 106 | - undefined fields are ignored 107 | - models are not replaced 108 | - object identity is preserved 109 | 110 | This ensures UI stability. 111 | 112 | ## Internal Merge Flow (Simplified) 113 | 114 | > ⚠️ This example illustrates **internal Nexigen behavior**. 115 | > Application code must **not** call `entities.merge` directly. 116 | 117 | Internally, Nexigen performs a merge similar to the following: 118 | 119 | ```ts 120 | const response = await api.getPosts(); 121 | entities.merge(response, REF_SOURCE.POSTS); 122 | ``` 123 | 124 | --- 125 | 126 | ## Testing Implications 127 | 128 | Because EntitiesStore is deterministic: 129 | 130 | - it can be tested in isolation 131 | - merge behavior is predictable 132 | - edge cases are reproducible 133 | 134 | This is why most Nexigen tests target EntitiesStore. 135 | -------------------------------------------------------------------------------- /examples/mobile/src/screens/Comments/index.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | import { useEffect, useState } from 'react'; 3 | import { View, Text, FlatList, Pressable } from 'react-native'; 4 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 5 | 6 | import { CommentItem } from './comment-item'; 7 | import { styles } from './styles'; 8 | import { useStores } from '../../stores/hooks'; 9 | 10 | import type { CommentModel } from '../../stores/comments/model'; 11 | import type { PostModel } from '../../stores/posts/model'; 12 | 13 | export const CommentsScreen = observer(() => { 14 | const { top } = useSafeAreaInsets(); 15 | const { 16 | posts: { 17 | lists: { 18 | fresh: { getList: freshPosts }, 19 | }, 20 | }, 21 | comments: { fetchComments, list }, 22 | } = useStores(); 23 | 24 | const [postId, setPostId] = useState('p1'); 25 | 26 | useEffect(() => { 27 | if (!postId) { 28 | return; 29 | } 30 | fetchComments.run({ params: { postId } }); 31 | }, [postId, fetchComments]); 32 | 33 | const isInitialLoading = fetchComments.isLoading && list.isEmpty; 34 | 35 | return ( 36 | 37 | Comments by post 38 | 39 | {/* post selector */} 40 | item.id} 44 | showsHorizontalScrollIndicator={false} 45 | contentContainerStyle={styles.postsList} 46 | renderItem={({ item }) => { 47 | const active = postId === item.id; 48 | return ( 49 | setPostId(item.id)} 51 | style={[styles.postChip, active && styles.postChipActive]} 52 | > 53 | 60 | {item.title} 61 | 62 | 63 | ); 64 | }} 65 | /> 66 | 67 | {!postId && ( 68 | Select a post to see comments 69 | )} 70 | 71 | {isInitialLoading && Loading…} 72 | 73 | {!isInitialLoading && postId && ( 74 | item.id} 77 | contentContainerStyle={styles.commentsList} 78 | renderItem={({ item }) => } 79 | ListEmptyComponent={No comments} 80 | /> 81 | )} 82 | 83 | ); 84 | }); 85 | -------------------------------------------------------------------------------- /docs/api/multi-collection.md: -------------------------------------------------------------------------------- 1 | # MultiEntityCollection 2 | 3 | `MultiEntityCollection` represents a **set of isolated EntityCollections** 4 | keyed by an arbitrary identifier. 5 | 6 | It is designed for cases where the same entity type 7 | must be stored in **multiple independent lists**. 8 | 9 | --- 10 | 11 | ## Purpose 12 | 13 | MultiEntityCollection is used when: 14 | 15 | - lists are grouped by a dynamic key 16 | - each group has its own pagination state 17 | - entities are shared across groups 18 | - UI needs isolated loading / paging per group 19 | 20 | Typical examples: 21 | 22 | - tabs (active / past / archived) 23 | - filters 24 | - categories 25 | - user-scoped lists 26 | 27 | --- 28 | 29 | ## Mental Model 30 | 31 | A MultiEntityCollection is a **collection factory**, not a data container. 32 | 33 | It: 34 | 35 | - does not store items itself 36 | - lazily creates collections per key 37 | - guarantees isolation between groups 38 | - shares the same entity graph 39 | 40 | --- 41 | 42 | ## Creation 43 | 44 | Multi collections are created via `EntitiesStore`: 45 | 46 | ```ts 47 | const lists = entities.createMultiCollection({ 48 | entityKey: ENTITY_KEY.POST, 49 | collectionId: REF_SOURCE.POSTS, 50 | limit: 20, 51 | }); 52 | ``` 53 | 54 | --- 55 | 56 | ## Access Pattern 57 | 58 | ```ts 59 | lists[key]: EntityCollection 60 | ``` 61 | 62 | Accessing a key: 63 | 64 | - creates the collection lazily (if missing) 65 | - returns the same instance on subsequent access 66 | 67 | ```ts 68 | lists.active.set(response); 69 | lists.archived.append(response); 70 | ``` 71 | 72 | --- 73 | 74 | ## Isolation Guarantees 75 | 76 | Each key has its own: 77 | 78 | - pagination state 79 | - item order 80 | - `hasNoMore` flag 81 | - refSource lifecycle 82 | 83 | But all keys share: 84 | 85 | - entity identity 86 | - models 87 | - normalization rules 88 | 89 | --- 90 | 91 | ## Example Usage 92 | 93 | ```ts 94 | lists['active'].set(activePosts); 95 | lists['past'].set(pastPosts); 96 | ``` 97 | 98 | Pagination is isolated: 99 | 100 | ```ts 101 | lists['active'].pageNumber; // 2 102 | lists['past'].pageNumber; // 1 103 | ``` 104 | 105 | --- 106 | 107 | ## Lifecycle & GC 108 | 109 | Each underlying `EntityCollection`: 110 | 111 | - attaches its own refSource 112 | - detaches entities independently 113 | - allows orphan cleanup when unused 114 | 115 | Removing items from one group 116 | does not affect other groups unless entities become orphaned. 117 | 118 | --- 119 | 120 | ## Anti-Patterns 121 | 122 | ❌ Storing list data outside collections 123 | ❌ Sharing pagination state between keys 124 | ❌ Manually synchronizing groups 125 | 126 | --- 127 | 128 | ## Guarantees 129 | 130 | - isolated list state per key 131 | - shared entity identity 132 | - deterministic behavior 133 | - lazy initialization 134 | -------------------------------------------------------------------------------- /docs/api/store-deps.md: -------------------------------------------------------------------------------- 1 | # StoreDeps 2 | 3 | `StoreDeps` is Nexigen’s **dependency injection container** for domain stores. 4 | 5 | It provides controlled access to shared system services 6 | without introducing direct imports or cyclic dependencies. 7 | 8 | --- 9 | 10 | ## Purpose 11 | 12 | StoreDeps exists to solve three core problems: 13 | 14 | 1. **Circular dependencies between stores** 15 | 2. **Hidden global state** 16 | 3. **Untestable store logic** 17 | 18 | Instead of importing other stores or singletons directly, 19 | each store declares what it depends on. 20 | 21 | --- 22 | 23 | ## Mental Model 24 | 25 | Think of StoreDeps as a **constructor contract**. 26 | 27 | A store does not ask _where_ dependencies come from — 28 | it only declares _what it needs_. 29 | 30 | --- 31 | 32 | ## Constructor Injection 33 | 34 | ```ts 35 | class PostsStore { 36 | constructor( 37 | private deps: StoreDeps<{ 38 | api: typeof Api; 39 | stores: { 40 | viewer: ViewerStore; 41 | }; 42 | }>, 43 | ) {} 44 | } 45 | ``` 46 | 47 | All dependencies are provided at store creation time. 48 | 49 | --- 50 | 51 | ## Available Dependencies 52 | 53 | A StoreDeps instance may contain: 54 | 55 | ```ts 56 | deps.api; // API layer 57 | deps.stores; // other domain stores 58 | deps.core; // system-level services 59 | ``` 60 | 61 | ### deps.stores 62 | 63 | Allows calling methods on other stores **without importing them**. 64 | 65 | ```ts 66 | this.deps.stores.viewer.fetchCurrentViewer.run(); 67 | ``` 68 | 69 | This preserves: 70 | 71 | - acyclic dependency graph 72 | - explicit coupling 73 | - predictable initialization order 74 | 75 | --- 76 | 77 | ### deps.api 78 | 79 | Access to the API layer. 80 | 81 | ```ts 82 | this.deps.api.Posts.getPosts(); 83 | ``` 84 | 85 | The API layer is: 86 | 87 | - shared 88 | - stateless 89 | - safe to mock 90 | 91 | --- 92 | 93 | ### deps.core 94 | 95 | Access to core system services. 96 | 97 | Common examples: 98 | 99 | - `deps.core.entities` 100 | 101 | Core services are **infrastructure**, not domain logic. 102 | 103 | --- 104 | 105 | ## What StoreDeps Is NOT 106 | 107 | StoreDeps is NOT: 108 | 109 | - a service locator 110 | - a global singleton 111 | - a runtime registry 112 | 113 | Dependencies are fixed at construction time. 114 | 115 | --- 116 | 117 | ## Testing Benefits 118 | 119 | StoreDeps makes stores easy to test: 120 | 121 | ```ts 122 | new PostsStore({ 123 | api: mockApi, 124 | stores: { viewer: mockViewer }, 125 | core: mockCore, 126 | }); 127 | ``` 128 | 129 | No globals. No setup hacks. 130 | 131 | --- 132 | 133 | ## Anti-Patterns 134 | 135 | ❌ Mutating dependencies 136 | 137 | --- 138 | 139 | ## Guarantees 140 | 141 | - explicit dependencies 142 | - no circular imports 143 | - deterministic initialization 144 | - testable store logic 145 | -------------------------------------------------------------------------------- /docs/api/create-entity-schema.md: -------------------------------------------------------------------------------- 1 | # Entity Schemas 2 | 3 | Entity Schemas define **how raw API data is transformed into a normalized entity graph**. 4 | 5 | A schema is the **single source of truth** for: 6 | 7 | - entity identity 8 | - relationships 9 | - normalization rules 10 | - model instantiation 11 | 12 | Every entity type in Nexigen **must have exactly one schema**. 13 | 14 | --- 15 | 16 | ## Purpose 17 | 18 | An Entity Schema answers three fundamental questions: 19 | 20 | 1. **How is this entity identified?** 21 | 2. **How does it reference other entities?** 22 | 3. **Which model represents it at runtime?** 23 | 24 | Schemas are used by: 25 | 26 | - normalization engine 27 | - entity collections 28 | - entity records 29 | - garbage collection graph 30 | - entity hydration 31 | 32 | --- 33 | 34 | ## Creating a Schema 35 | 36 | ```ts 37 | const postSchema = createEntitySchema( 38 | ENTITY_KEY.POST, 39 | { 40 | viewer: viewerSchema, 41 | viewers: [viewerSchema], 42 | }, 43 | { 44 | model: PostModel, 45 | }, 46 | ); 47 | ``` 48 | 49 | --- 50 | 51 | ## Parameters 52 | 53 | ### entityKey 54 | 55 | ```ts 56 | entityKey: string; 57 | ``` 58 | 59 | A globally unique identifier for the entity type. 60 | 61 | Examples: 62 | 63 | - ENTITY_KEY.POST 64 | - ENTITY_KEY.VIEWER 65 | 66 | --- 67 | 68 | ### relations 69 | 70 | ```ts 71 | relations: Record; 72 | ``` 73 | 74 | Defines structural relationships between entities. 75 | 76 | Supported forms: 77 | 78 | ```ts 79 | viewer: viewerSchema; 80 | viewers: [viewerSchema]; 81 | ``` 82 | 83 | Behavior: 84 | 85 | - nested objects are normalized 86 | - references are replaced with xxxId / xxxId[] 87 | - related entities are merged into their own buckets 88 | 89 | --- 90 | 91 | ### options.model 92 | 93 | ```ts 94 | model: new (dto, entityGetter) => Model 95 | ``` 96 | 97 | Defines the runtime representation of the entity. 98 | 99 | Models: 100 | 101 | - are instantiated once per entity 102 | - preserve identity across updates 103 | - expose derived logic only 104 | 105 | --- 106 | 107 | ## Normalization Example 108 | 109 | ```json 110 | { 111 | "id": "1", 112 | "title": "Post", 113 | "viewer": { "id": "10", "name": "Anna" } 114 | } 115 | ``` 116 | 117 | Produces: 118 | 119 | ```ts 120 | posts: { 121 | "1": { id: "1", title: "Post", viewerId: "10" } 122 | } 123 | viewers: { 124 | "10": { id: "10", name: "Anna" } 125 | } 126 | ``` 127 | 128 | --- 129 | 130 | ## Guarantees 131 | 132 | - stable entity identity 133 | - consistent normalization 134 | - no model duplication 135 | - safe missing relations 136 | 137 | --- 138 | 139 | ## Anti-Patterns 140 | 141 | ❌ Multiple schemas per entity 142 | ❌ Business logic in schemas 143 | ❌ Mutable schema definitions 144 | 145 | --- 146 | 147 | ## Mental Model 148 | 149 | A schema is a blueprint, not a container. 150 | -------------------------------------------------------------------------------- /src/entities/public.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ============================ 3 | * Nexigen – Public Entity API 4 | * ============================ 5 | */ 6 | 7 | /** 8 | * Primitive entity identifier 9 | */ 10 | export type EntityId = string | number; 11 | 12 | /** 13 | * ============================ 14 | * Entity Model Context 15 | * ============================ 16 | */ 17 | export type EntityGetter = ( 18 | entityKey: string, 19 | id: string | number, 20 | ) => T | undefined; 21 | /** 22 | * ============================ 23 | * Entity Model Constructor 24 | * ============================ 25 | * 26 | * User-defined domain model constructor. 27 | * 28 | * Example: 29 | * class PostModel { 30 | * constructor(dto: PostDto, ctx: EntityGetter) {} 31 | * } 32 | */ 33 | export type EntityModelCtor = new ( 34 | dto: TDto, 35 | ctx: EntityGetter, 36 | ) => TModel; 37 | 38 | /** 39 | * ============================ 40 | * Public Entity Schema 41 | * ============================ 42 | * 43 | * Read-only interface exposed to userland. 44 | * No normalization, no GC, no persistence details. 45 | */ 46 | export interface PublicEntitySchema { 47 | /** 48 | * Unique entity key 49 | */ 50 | readonly key: string; 51 | 52 | /** 53 | * Extract entity id from DTO 54 | */ 55 | getId(input: TDto): EntityId; 56 | 57 | /** 58 | * Return id attribute key (string only) 59 | */ 60 | getIdKey(): string; 61 | 62 | /** 63 | * Optional preprocessing before normalization 64 | */ 65 | process(input: TDto): TDto; 66 | 67 | /** 68 | * Merge strategy for model updates 69 | */ 70 | merge(target: TModel, source: Partial): TModel; 71 | } 72 | 73 | /** 74 | * ============================ 75 | * Entity Relations Definition 76 | * ============================ 77 | * 78 | * Used to describe nested schemas. 79 | * 80 | * Example: 81 | * { 82 | * author: userSchema, 83 | * comments: [commentSchema], 84 | * } 85 | */ 86 | export type EntitySchemaDefinition = Record< 87 | string, 88 | PublicEntitySchema | [PublicEntitySchema] 89 | >; 90 | 91 | /** 92 | * ============================ 93 | * Entity Schema Config 94 | * ============================ 95 | * 96 | * User-provided configuration. 97 | */ 98 | export interface EntitySchemaConfig { 99 | /** 100 | * ID attribute or resolver 101 | * 102 | * Default: "id" 103 | */ 104 | idAttribute?: keyof TDto | ((dto: TDto) => EntityId); 105 | 106 | /** 107 | * Domain model constructor 108 | * 109 | * Example: 110 | * model: PostModel 111 | */ 112 | model?: EntityModelCtor; 113 | 114 | /** 115 | * Optional DTO preprocessing 116 | */ 117 | processStrategy?: (dto: TDto) => TDto; 118 | 119 | /** 120 | * Optional merge strategy 121 | */ 122 | mergeStrategy?: (target: TModel, source: Partial) => TModel; 123 | } 124 | -------------------------------------------------------------------------------- /src/entities/cleaner/store.ts: -------------------------------------------------------------------------------- 1 | import { BucketCollector } from './bucket'; 2 | import { SchemaWalker } from './walker'; 3 | import { META } from '../../constants/values'; 4 | 5 | import type { AnySchema, TEntitiesStore } from '../types'; 6 | 7 | export class EntityCleanerStore { 8 | private walker: SchemaWalker; 9 | private collector: BucketCollector; 10 | 11 | constructor( 12 | private entities: TEntitiesStore, 13 | schemaMap: Record, 14 | ) { 15 | this.walker = new SchemaWalker(schemaMap); 16 | this.collector = new BucketCollector(entities, this.walker); 17 | } 18 | 19 | private canDelete(entityKey: string, id: string | number) { 20 | const live = this.entities.getEntity(entityKey, id); 21 | if (!live) { 22 | return false; 23 | } 24 | 25 | if (live.__partial) { 26 | return false; 27 | } 28 | 29 | const refs = live[META]?.refSources; 30 | return !refs || refs.size === 0; 31 | } 32 | 33 | private collectCascade(entityKey: string, ids: (string | number)[]) { 34 | const finalBucket: Record> = {} as any; 35 | 36 | for (const id of ids) { 37 | const bucket = this.collector.collect(entityKey, id); 38 | 39 | for (const key of Object.keys(bucket) as string[]) { 40 | if (!finalBucket[key]) { 41 | finalBucket[key] = new Set(); 42 | } 43 | bucket[key].forEach(v => finalBucket[key].add(v)); 44 | } 45 | } 46 | 47 | return finalBucket; 48 | } 49 | 50 | private filterBucket(bucket: Record>) { 51 | const filtered: Record> = {} as any; 52 | 53 | for (const key of Object.keys(bucket) as string[]) { 54 | for (const id of bucket[key]) { 55 | if (this.canDelete(key, id)) { 56 | if (!filtered[key]) { 57 | filtered[key] = new Set(); 58 | } 59 | filtered[key].add(id); 60 | } 61 | } 62 | } 63 | 64 | return filtered; 65 | } 66 | 67 | private detachRefByPlan( 68 | bucket: Record>, 69 | sourceRefId: string, 70 | ) { 71 | for (const entityKey of Object.keys(bucket)) { 72 | for (const id of bucket[entityKey]) { 73 | const entity = this.entities.getEntity(entityKey, id); 74 | if (!entity) { 75 | continue; 76 | } 77 | 78 | const refs = entity[META]?.refSources; 79 | if (!refs) { 80 | continue; 81 | } 82 | 83 | refs.delete(sourceRefId); 84 | } 85 | } 86 | } 87 | 88 | deleteCascade( 89 | entityKey: string, 90 | ids: (string | number)[], 91 | sourceRefId: string, 92 | ) { 93 | if (!ids.length) { 94 | return; 95 | } 96 | 97 | const raw = this.collectCascade(entityKey, ids); 98 | this.detachRefByPlan(raw, sourceRefId); 99 | const toDetach = this.filterBucket(raw); 100 | 101 | this.entities.removeMany(toDetach); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'eslint/config'; 2 | 3 | import tsParser from '@typescript-eslint/parser'; 4 | import tsPlugin from '@typescript-eslint/eslint-plugin'; 5 | import importPlugin from 'eslint-plugin-import'; 6 | 7 | import path from 'node:path'; 8 | import { fileURLToPath } from 'node:url'; 9 | 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = path.dirname(__filename); 12 | 13 | export default defineConfig([ 14 | { 15 | files: ['src/**/*.{ts,tsx}'], 16 | ignores: ['dist/**', 'node_modules/**'], 17 | 18 | languageOptions: { 19 | parser: tsParser, 20 | parserOptions: { 21 | project: './tsconfig.eslint.json', 22 | tsconfigRootDir: __dirname, 23 | sourceType: 'module', 24 | }, 25 | }, 26 | 27 | plugins: { 28 | '@typescript-eslint': tsPlugin, 29 | import: importPlugin, 30 | }, 31 | 32 | settings: { 33 | 'import/resolver': { 34 | typescript: { 35 | project: './tsconfig.eslint.json', 36 | alwaysTryTypes: true, 37 | }, 38 | node: { 39 | extensions: ['.js', '.ts', '.tsx'], 40 | }, 41 | }, 42 | }, 43 | 44 | rules: { 45 | /* ---------- correctness ---------- */ 46 | eqeqeq: 'error', 47 | curly: ['error', 'all'], 48 | 'no-debugger': 'error', 49 | 'no-console': ['error', { allow: ['warn', 'error'] }], 50 | 51 | /* ---------- typescript ---------- */ 52 | 'no-unused-vars': 'off', 53 | '@typescript-eslint/no-unused-vars': [ 54 | 'error', 55 | { argsIgnorePattern: '^_' }, 56 | ], 57 | '@typescript-eslint/no-use-before-define': 'error', 58 | '@typescript-eslint/consistent-type-imports': [ 59 | 'error', 60 | { 61 | prefer: 'type-imports', 62 | fixStyle: 'inline-type-imports', 63 | }, 64 | ], 65 | 66 | /* ---------- imports ---------- */ 67 | 'import/no-unresolved': 'error', 68 | 69 | 'import/order': [ 70 | 'error', 71 | { 72 | groups: [ 73 | 'builtin', 74 | 'external', 75 | 'internal', 76 | ['parent', 'sibling', 'index'], 77 | 'type', 78 | ], 79 | 'newlines-between': 'always', 80 | alphabetize: { 81 | order: 'asc', 82 | caseInsensitive: true, 83 | }, 84 | }, 85 | ], 86 | 87 | /* ---------- architecture guards ---------- */ 88 | 'no-restricted-imports': [ 89 | 'error', 90 | { 91 | paths: [ 92 | { 93 | name: 'mobx', 94 | importNames: ['autorun', 'reaction', 'when'], 95 | message: 96 | 'autorun / reaction / when are forbidden in Nexigen core. Use explicit command flow.', 97 | }, 98 | ], 99 | }, 100 | ], 101 | }, 102 | }, 103 | ]); 104 | -------------------------------------------------------------------------------- /src/entities/cleaner/__tests__/walker.test.ts: -------------------------------------------------------------------------------- 1 | //@ts-nocheck 2 | import { EntitySchema } from '../../schema'; 3 | import { SchemaWalker } from '../walker'; 4 | 5 | const schemaA = new EntitySchema('A' as string, { 6 | b: new EntitySchema('B' as string), 7 | c: [new EntitySchema('C' as string)], 8 | }); 9 | 10 | const schemaB = new EntitySchema('B' as string, { 11 | c: new EntitySchema('C' as string), 12 | }); 13 | 14 | const schemaC = new EntitySchema('C' as string, {}); 15 | 16 | const schemaMap = { 17 | A: schemaA, 18 | B: schemaB, 19 | C: schemaC, 20 | } as const; 21 | 22 | describe('SchemaWalker', () => { 23 | const walker = new SchemaWalker(schemaMap); 24 | 25 | test('walks single nested reference (A -> B)', () => { 26 | const entity = { 27 | bId: '10', 28 | }; 29 | 30 | const result: Array<[string, string | number]> = []; 31 | 32 | walker.walkFields('A', entity, (key, id) => { 33 | result.push([key, id]); 34 | }); 35 | 36 | expect(result).toEqual([['B', '10']]); 37 | }); 38 | 39 | test('walks array nested reference (A -> C[])', () => { 40 | const entity = { 41 | cId: ['21', '22'], 42 | }; 43 | 44 | const result: Array<[string, string | number]> = []; 45 | 46 | walker.walkFields('A', entity, (key, id) => { 47 | result.push([key, id]); 48 | }); 49 | 50 | expect(result).toEqual([ 51 | ['C', '21'], 52 | ['C', '22'], 53 | ]); 54 | }); 55 | 56 | test('walks deep nested references A -> B -> C', () => { 57 | const entityA = { bId: '10' }; 58 | const entityB = { cId: '77' }; 59 | 60 | const callsA: Array<[string, string | number]> = []; 61 | const callsB: Array<[string, string | number]> = []; 62 | 63 | walker.walkFields('A', entityA, (key, id) => { 64 | callsA.push([key, id]); 65 | }); 66 | 67 | walker.walkFields('B', entityB, (key, id) => { 68 | callsB.push([key, id]); 69 | }); 70 | 71 | expect(callsA).toEqual([['B', '10']]); 72 | expect(callsB).toEqual([['C', '77']]); 73 | }); 74 | 75 | test('ignores missing fields', () => { 76 | const entity = {}; // no bId, no cId 77 | 78 | const result: any[] = []; 79 | 80 | walker.walkFields('A', entity, (key, id) => { 81 | result.push([key, id]); 82 | }); 83 | 84 | expect(result).toEqual([]); 85 | }); 86 | 87 | test('skips non-array values for array schema', () => { 88 | const entity = { 89 | cId: 'not-array', 90 | }; 91 | 92 | const result: any[] = []; 93 | 94 | walker.walkFields('A', entity, (key, id) => { 95 | result.push([key, id]); 96 | }); 97 | 98 | expect(result).toEqual([]); 99 | }); 100 | 101 | test('handles empty array in array schema', () => { 102 | const entity = { 103 | cId: [], 104 | }; 105 | 106 | const result: any[] = []; 107 | 108 | walker.walkFields('A', entity, (key, id) => { 109 | result.push([key, id]); 110 | }); 111 | 112 | expect(result).toEqual([]); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Nexigen 2 | 3 | Thanks for your interest in contributing to **Nexigen** ❤️ 4 | 5 | This project is a **core-level state management engine**, so we value 6 | correctness, consistency, and architectural discipline over speed. 7 | 8 | Please read this document carefully before opening an issue or PR. 9 | 10 | --- 11 | 12 | ## Philosophy 13 | 14 | Nexigen is built around strict invariants: 15 | 16 | - entity-first architecture 17 | - normalized graph 18 | - stable identity 19 | - explicit lifecycle 20 | - zero UI coupling 21 | 22 | Any contribution **must preserve these principles**. 23 | 24 | If you're unsure — open an issue first. 25 | 26 | --- 27 | 28 | ## Getting Started 29 | 30 | ### 1. Fork & Clone 31 | 32 | ```bash 33 | git clone https://github.com//entity-normalizer.git 34 | cd entity-normalizer 35 | ``` 36 | 37 | ### 2. Install dependencies 38 | 39 | ```bash 40 | yarn install 41 | ``` 42 | 43 | ### 3. Run checks 44 | 45 | ```bash 46 | yarn lint 47 | yarn typecheck 48 | yarn test 49 | ``` 50 | 51 | All checks **must pass** before submitting a PR. 52 | 53 | --- 54 | 55 | ## Project Structure 56 | 57 | ``` 58 | src/ 59 | ├─ async/ # Async commands (Ducks) 60 | ├─ entities/ # Records, collections, schemas (entity graph) 61 | ├─ root/ # RootStore & Core API 62 | ├─ di/ # Dependency injection & store registration 63 | ├─ utils/ # Pure utilities (no side effects) 64 | ├─ constants/ # Shared constants & internal flags 65 | ├─ create/ # Internal factories (store) 66 | 67 | ``` 68 | 69 | Rules: 70 | 71 | - No UI dependencies (React / React Native are forbidden) 72 | - No side effects at module level 73 | - No implicit async flows 74 | 75 | --- 76 | 77 | ## Code Style 78 | 79 | - ESLint + Prettier are mandatory 80 | - No unused exports 81 | - Explicit typing preferred 82 | - `import type` for type-only imports 83 | - No `autorun`, `reaction`, `when` usage 84 | 85 | Run auto-fix before committing: 86 | 87 | ```bash 88 | yarn lint:fix 89 | ``` 90 | 91 | --- 92 | 93 | ## Tests 94 | 95 | - All core logic **must be covered by tests** 96 | - Prefer unit tests over integration 97 | - Deterministic behavior only 98 | 99 | If a change affects lifecycle, GC, or identity — tests are **required**. 100 | 101 | --- 102 | 103 | ## Pull Requests 104 | 105 | ### Requirements 106 | 107 | - Small, focused changes 108 | - Clear commit messages 109 | - No unrelated refactors 110 | - No breaking API changes without discussion 111 | 112 | ### Commit Message Style 113 | 114 | ``` 115 | feat: add explicit entity cleanup phase 116 | fix: prevent orphan collection leak 117 | docs: clarify entity lifecycle rules 118 | ``` 119 | 120 | --- 121 | 122 | ## Breaking Changes 123 | 124 | Breaking changes are **not accepted** without: 125 | 126 | 1. Prior discussion in an issue 127 | 2. Clear migration path 128 | 3. Changelog entry 129 | 130 | --- 131 | 132 | ## Questions / Ideas 133 | 134 | If you’re unsure whether something belongs in Nexigen — 135 | **open an issue first**. Discussion is welcome. 136 | 137 | --- 138 | 139 | Thank you for contributing 🚀 140 | -------------------------------------------------------------------------------- /src/root/coreApi/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | EntityCollection, 3 | EntityProcessOptions, 4 | EntityRecord, 5 | TEntitiesStore, 6 | } from '../../entities'; 7 | import type { EntityCleanerStore } from '../../entities/cleaner'; 8 | import type { MultiEntityCollection } from '../../entities/collection/public'; 9 | import type { EntityCollectionOptions } from '../../entities/collection/types'; 10 | import type { PersistenceNotifier, StoresSnapshot, TSchemaMap } from '../types'; 11 | 12 | export interface CoreAPIExtensions {} 13 | 14 | export type CoreInternalAPI = { 15 | setPersistence(notifier: PersistenceNotifier): void; 16 | }; 17 | 18 | export type CoreStoresAPI = { 19 | getSnapshot(): StoresSnapshot; 20 | 21 | getSnapshotByKey(key: K): StoresSnapshot[K]; 22 | 23 | applySnapshot(snapshot: StoresSnapshot): void; 24 | 25 | applySnapshotByKey( 26 | key: K, 27 | snapshot: StoresSnapshot[K], 28 | ): void; 29 | 30 | resetAll(): void; 31 | 32 | resetByKey(key: K): void; 33 | }; 34 | 35 | export type CoreLifecycleAPI = { 36 | isInitialized: boolean; 37 | setInitialized(v: boolean): void; 38 | }; 39 | 40 | export type CoreEntitiesAPI = { 41 | createRecord(options: { 42 | entityKey: string; 43 | recordId: string; 44 | }): EntityRecord; 45 | 46 | createCollection( 47 | options: EntityCollectionOptions, 48 | ): EntityCollection; 49 | 50 | createMultiCollection( 51 | options: EntityCollectionOptions, 52 | ): MultiEntityCollection; 53 | 54 | process( 55 | options: EntityProcessOptions & { entityKey: TKey }, 56 | ): Array; 57 | 58 | hydrate( 59 | snapshot: Record> | null | undefined, 60 | ): void; 61 | 62 | get( 63 | key: T, 64 | id: string | number, 65 | ): ReturnType; 66 | 67 | getAll(key: T): ReturnType; 68 | 69 | getCount(key: T): number; 70 | 71 | getSnapshot(): Record; 72 | 73 | getSchema(key: TKey): TSchemaMap[TKey]; 74 | }; 75 | 76 | export type CoreGCAPI = { 77 | bootstrap(): void; 78 | processOrphan(): void; 79 | }; 80 | 81 | export type CoreAPI = CoreAPIExtensions & { 82 | lifecycle: CoreLifecycleAPI; 83 | entities: CoreEntitiesAPI; 84 | stores: CoreStoresAPI; 85 | 86 | __internal: CoreInternalAPI; 87 | }; 88 | 89 | export type CoreEntitiesDeps = { 90 | entities: TEntitiesStore; 91 | entitiesCleaner: EntityCleanerStore; 92 | schemaMap: TSchemaMap; 93 | 94 | //system api 95 | getPersistence: () => PersistenceNotifier | undefined; 96 | }; 97 | 98 | export type CoreStoresDeps> = { 99 | [K in keyof TStores]: InstanceType; 100 | }; 101 | 102 | export type CoreLifecycleDeps = { 103 | getIsInitialized: () => boolean; 104 | setInitialized: (v: boolean) => void; 105 | }; 106 | -------------------------------------------------------------------------------- /src/create/__tests__/proxy.test.ts: -------------------------------------------------------------------------------- 1 | import { StoreProxy } from '../proxy'; 2 | 3 | describe('StoreProxy', () => { 4 | let root: any; 5 | let notify: jest.Mock; 6 | let target: any; 7 | 8 | beforeEach(() => { 9 | notify = jest.fn(); 10 | 11 | root = { 12 | getPersistence: () => ({ 13 | onStoreStateChanged: notify, 14 | }), 15 | }; 16 | 17 | target = { 18 | x: 1, 19 | 20 | actionSync: jest.fn(function () { 21 | this.x = 2; 22 | return 'SYNC'; 23 | }), 24 | 25 | actionAsync: jest.fn(function () { 26 | return Promise.resolve().then(() => { 27 | this.x = 3; 28 | return 'ASYNC'; 29 | }); 30 | }), 31 | 32 | nonAction: jest.fn(), 33 | }; 34 | }); 35 | 36 | const flush = async () => { 37 | await Promise.resolve(); 38 | await Promise.resolve(); 39 | }; 40 | 41 | function buildProxy(actions: string[]) { 42 | return new StoreProxy(root, target, actions).build(); 43 | } 44 | 45 | // ------------------------------------------------------- 46 | it('wraps only declared actions', () => { 47 | const proxy = buildProxy(['actionSync']); 48 | 49 | expect(proxy.actionSync).not.toBe(target.actionSync); 50 | expect(proxy.nonAction).toBe(target.nonAction); 51 | }); 52 | 53 | // ------------------------------------------------------- 54 | it('notifies after sync action', async () => { 55 | const proxy = buildProxy(['actionSync']); 56 | 57 | const result = proxy.actionSync(); 58 | expect(result).toBe('SYNC'); 59 | 60 | await flush(); 61 | 62 | expect(notify).toHaveBeenCalledTimes(1); 63 | }); 64 | 65 | // ------------------------------------------------------- 66 | it('notifies after async action resolves', async () => { 67 | const proxy = buildProxy(['actionAsync']); 68 | 69 | const result = proxy.actionAsync(); 70 | expect(result).toBeInstanceOf(Promise); 71 | 72 | await result; 73 | await flush(); 74 | 75 | expect(notify).toHaveBeenCalledTimes(1); 76 | }); 77 | 78 | // ------------------------------------------------------- 79 | it('notifies on direct property mutation', async () => { 80 | const proxy = buildProxy([]); 81 | 82 | proxy.x = 999; 83 | expect(proxy.x).toBe(999); 84 | 85 | await flush(); 86 | 87 | expect(notify).toHaveBeenCalledTimes(1); 88 | }); 89 | 90 | // ------------------------------------------------------- 91 | it('preserves "this" context for wrapped actions', async () => { 92 | const proxy = buildProxy(['actionSync']); 93 | 94 | proxy.actionSync(); 95 | 96 | expect(target.actionSync.mock.instances[0]).toBe(target); 97 | 98 | await flush(); 99 | expect(notify).toHaveBeenCalled(); 100 | }); 101 | 102 | // ------------------------------------------------------- 103 | it('returns raw values for non-function fields', () => { 104 | const proxy = buildProxy(['actionSync']); 105 | expect(proxy.x).toBe(1); 106 | }); 107 | 108 | // ------------------------------------------------------- 109 | it('does not wrap actions not listed', () => { 110 | const proxy = buildProxy([]); 111 | 112 | expect(proxy.actionSync).toBe(target.actionSync); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /src/entities/collection/__tests__/multiEntityCollection.test.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { EntityCollection } from '../entity-collection'; 3 | import { MultiEntityCollection } from '../multi-entity-collection'; 4 | 5 | // ---------- MOCKS ---------- 6 | class MockEntitiesStore { 7 | map = new Map(); 8 | mergeEntity(key, dto, id) { 9 | this.map.set(`${key}:${id}`, dto); 10 | } 11 | getEntity(key, id) { 12 | return this.map.get(`${key}:${id}`); 13 | } 14 | } 15 | 16 | class MockEntitiesCleaner { 17 | deleteCascade = jest.fn(); 18 | resetEntity = jest.fn(); 19 | } 20 | 21 | class MockPersist { 22 | pointers = 0; 23 | notifyPointersChanged = jest.fn(() => { 24 | this.pointers++; 25 | }); 26 | } 27 | 28 | class MockEntitiesApi { 29 | process = jest.fn(({ data }) => data.map(item => item.id)); 30 | } 31 | 32 | // SystemDeps mock 33 | class MockSystem { 34 | entities = new MockEntitiesStore(); 35 | entitiesCleaner = new MockEntitiesCleaner(); 36 | persist = new MockPersist(); 37 | entitiesApi = new MockEntitiesApi(); 38 | 39 | notify = () => { 40 | this.persist.notifyPointersChanged(); 41 | }; 42 | } 43 | 44 | // ------------------------------------------------------------ 45 | 46 | describe('MultiEntityCollection', () => { 47 | let system; 48 | let multi; 49 | 50 | beforeEach(() => { 51 | system = new MockSystem(); 52 | 53 | multi = new MultiEntityCollection( 54 | { 55 | entityKey: 'post', 56 | collectionId: 'main', 57 | limit: 10, 58 | }, 59 | system, 60 | ); 61 | }); 62 | 63 | test('ensureGroup() creates group once and returns same instance', () => { 64 | const g1 = multi.ensureGroup('popular'); 65 | const g2 = multi.ensureGroup('popular'); 66 | 67 | expect(g1).toBe(g2); 68 | expect(g1).toBeInstanceOf(EntityCollection); 69 | }); 70 | 71 | test('getProxy auto-creates group on missing key access', () => { 72 | const proxy = multi.getProxy(); 73 | 74 | proxy.popular.append([{ id: 1 }]); 75 | 76 | expect(proxy.popular.asArray).toEqual([1]); 77 | expect(multi.getSubCollections().has('popular')).toBe(true); 78 | }); 79 | 80 | test('applyMultiSnapshot creates groups and applies snapshots', () => { 81 | multi.applyMultiSnapshot({ 82 | recent: { items: [1, 2], hasNoMore: false, reversed: false, limit: 10 }, 83 | starred: { items: [9], hasNoMore: true, reversed: false, limit: 10 }, 84 | }); 85 | 86 | expect(system.persist.pointers).toBe(1); 87 | }); 88 | 89 | test('getMultiSnapshot returns only registered groups', () => { 90 | const g = multi.ensureGroup('alpha'); 91 | g.set([{ id: 100 }]); 92 | 93 | expect(multi.getMultiSnapshot()).toEqual({ 94 | alpha: { 95 | items: [100], 96 | hasNoMore: true, 97 | reversed: false, 98 | limit: 10, 99 | }, 100 | }); 101 | }); 102 | 103 | test('resetAll resets all groups', () => { 104 | const a = multi.ensureGroup('a'); 105 | const b = multi.ensureGroup('b'); 106 | 107 | a.set([{ id: 1 }]); 108 | b.set([{ id: 2 }]); 109 | 110 | multi.resetAll(); 111 | 112 | expect(a.asArray).toEqual([]); 113 | expect(b.asArray).toEqual([]); 114 | expect(system.persist.pointers).toBe(3); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /src/root/coreApi/entities.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createEntityCollection, 3 | createMultiEntityCollection, 4 | } from '../../entities/collection/create-entity-collection'; 5 | import { 6 | createEntityProcessor, 7 | createEntityRestorer, 8 | } from '../../entities/processor'; 9 | import { createEntityRecord } from '../../entities/record/create-entity-record'; 10 | 11 | import type { EntityCleanerStore } from '../../entities/cleaner'; 12 | import type { MultiEntityCollection } from '../../entities/collection/public'; 13 | import type { EntityCollectionOptions } from '../../entities/collection/types'; 14 | import type { EntityRecord } from '../../entities/record/entity-record'; 15 | import type { 16 | EntitiesSnapshot, 17 | EntityProcessOptions, 18 | TEntitiesStore, 19 | } from '../../entities/types'; 20 | import type { TSchemaMap, PersistenceNotifier } from '../types'; 21 | import type { CoreEntitiesAPI } from './types'; 22 | 23 | export function createEntitiesAPI(deps: { 24 | entities: TEntitiesStore; 25 | schemaMap: TSchemaMap; 26 | entitiesCleaner: EntityCleanerStore; 27 | getPersistence: () => PersistenceNotifier | undefined; 28 | }): CoreEntitiesAPI { 29 | const { entities, entitiesCleaner, schemaMap, getPersistence } = deps; 30 | 31 | const notify = () => { 32 | getPersistence?.()?.onPointersChanged?.(); 33 | }; 34 | 35 | return { 36 | createRecord(options: { 37 | entityKey: string; 38 | recordId: string; 39 | }): EntityRecord { 40 | return createEntityRecord(options, { 41 | entities, 42 | notify, 43 | entitiesCleaner, 44 | entitiesApi: this, 45 | }); 46 | }, 47 | 48 | createCollection( 49 | options: EntityCollectionOptions, 50 | ) { 51 | return createEntityCollection(options, { 52 | entities, 53 | entitiesCleaner, 54 | notify, 55 | entitiesApi: this, 56 | }); 57 | }, 58 | 59 | createMultiCollection( 60 | options: EntityCollectionOptions, 61 | ): MultiEntityCollection { 62 | return createMultiEntityCollection(options, { 63 | entities, 64 | entitiesCleaner, 65 | notify, 66 | entitiesApi: this, 67 | }); 68 | }, 69 | 70 | process( 71 | options: EntityProcessOptions & { entityKey: TKey }, 72 | ): Array { 73 | return createEntityProcessor(entities, schemaMap, options); 74 | }, 75 | 76 | hydrate(snapshot: EntitiesSnapshot | null | undefined): void { 77 | return createEntityRestorer(entities, schemaMap)(snapshot); 78 | }, 79 | 80 | get(key: T, id: string | number) { 81 | return entities.getEntity(key, id) as any; 82 | }, 83 | 84 | getAll(key: T) { 85 | return entities.getAll(key) as any; 86 | }, 87 | 88 | getCount(key: T) { 89 | return entities.getCount(key); 90 | }, 91 | 92 | getSnapshot() { 93 | return entities.getSnapshot; 94 | }, 95 | 96 | getSchema(key: TKey) { 97 | return schemaMap[key]; 98 | }, 99 | }; 100 | } 101 | -------------------------------------------------------------------------------- /src/root/types.ts: -------------------------------------------------------------------------------- 1 | import type { AnySchema, TEntitiesStore } from '../entities'; 2 | import type { CoreAPI } from './coreApi/types'; 3 | import type { RootStore } from './RootStore'; 4 | 5 | export type DomainDeps< 6 | TStores = {}, 7 | TApi = any, 8 | TServices = {}, 9 | CoreAPI = {}, 10 | > = { 11 | api: TApi; 12 | stores: TStores; 13 | services: TServices; 14 | core: CoreAPI; 15 | }; 16 | 17 | export type RootStoreInstance< 18 | TApi = any, 19 | TSchemaMap extends Record = any, 20 | TStores extends StoreClassMap = any, 21 | TServices extends ServiceClassMap = any, 22 | > = RootStore; 23 | 24 | // -------------------------------------------------- 25 | // SYSTEM DEPS (infra) 26 | // -------------------------------------------------- 27 | export type SystemDeps = { 28 | getPersistence: () => PersistenceNotifier | undefined; 29 | entities: TEntitiesStore; 30 | }; 31 | 32 | // -------------------------------------------------- 33 | // COMBINED DEPS (final deps passed into Store & Service) 34 | // -------------------------------------------------- 35 | export type StoreDepsCombined = { 36 | domain: DomainDeps; 37 | system: SystemDeps; 38 | }; 39 | 40 | export type StoreClass = new (deps: TDeps) => TInstance; 41 | 42 | export type StoreClassMap = { 43 | [K in keyof TStoreClasses]: StoreClass; 44 | }; 45 | 46 | export type ServiceClassMap = { 47 | [K in keyof TServiceClasses]: new (deps: TDeps) => any; 48 | }; 49 | 50 | export type TSchemaMap = Record; 51 | 52 | export type RootStoreDeps< 53 | TApi, 54 | TSchemaMap extends Record, 55 | TStoreClasses extends StoreClassMap, 56 | TServiceClasses extends ServiceClassMap, 57 | > = { 58 | api: TApi; 59 | schemaMap: TSchemaMap; 60 | stores: TStoreClasses; 61 | services: TServiceClasses; 62 | plugins?: RootStorePlugin[]; 63 | }; 64 | 65 | type DepsConfig = { 66 | api?: any; 67 | stores?: any; 68 | services?: any; 69 | }; 70 | 71 | type NormalizeDeps = DomainDeps< 72 | C['stores'] extends undefined ? {} : C['stores'], 73 | C['api'] extends undefined ? any : C['api'], 74 | C['services'] extends undefined ? {} : C['services'], 75 | CoreAPI 76 | >; 77 | 78 | export type StoreDeps = NormalizeDeps>; 79 | 80 | export type StoreSnapshot = Record; 81 | 82 | export type StoresSnapshot = { 83 | [K in keyof TStores]?: StoreSnapshot; 84 | }; 85 | 86 | export type Decorators = { 87 | isRecord: (v: any) => boolean; 88 | isCollection: (v: any) => boolean; 89 | isMultiCollection: (v: any) => boolean; 90 | }; 91 | 92 | export type RootStorePlugin = { 93 | name: string; 94 | config: TConfig; 95 | setup(ctx: { 96 | entities: any; 97 | core: Record; 98 | config: TConfig; 99 | domain: DomainDeps; 100 | decorators: Decorators; 101 | }): void; 102 | }; 103 | 104 | export type PersistenceNotifier = { 105 | onEntitiesChanged(): void; 106 | onPointersChanged(): void; 107 | onStoreStateChanged(): void; 108 | }; 109 | -------------------------------------------------------------------------------- /src/entities/record/entity-record.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable, runInAction } from 'mobx'; 2 | 3 | import { PREFIX } from '../constants'; 4 | import { RECORD_TAG } from './marker'; 5 | 6 | import type { CoreEntitiesAPI } from '../../root/coreApi/types'; 7 | import type { EntityCleanerStore } from '../cleaner'; 8 | import type { TEntitiesStore } from '../types'; 9 | import type { EntityRecordSnapshot } from './types'; 10 | 11 | export class EntityRecord { 12 | private entitiesApi: CoreEntitiesAPI; 13 | 14 | public [RECORD_TAG] = true; 15 | 16 | private id: string | number | null = null; 17 | private entityKey: string; 18 | private entities: TEntitiesStore; 19 | private entitiesCleaner: EntityCleanerStore; 20 | private notify: () => void; 21 | public recordId: string; 22 | 23 | constructor( 24 | options: { 25 | entityKey: string; 26 | recordId: string; 27 | }, 28 | system: { 29 | entities: TEntitiesStore; 30 | entitiesCleaner: EntityCleanerStore; 31 | notify: () => void; 32 | entitiesApi: CoreEntitiesAPI; 33 | }, 34 | ) { 35 | this.entityKey = options.entityKey; 36 | this.recordId = options.recordId; 37 | this.entities = system.entities; 38 | this.entitiesCleaner = system.entitiesCleaner; 39 | this.notify = system.notify; 40 | this.entitiesApi = system.entitiesApi; 41 | 42 | makeAutoObservable(this, {}, { autoBind: true }); 43 | } 44 | 45 | // ---------------- NORMALIZATION ---------------- 46 | 47 | private process(item: T) { 48 | const ids = this.entitiesApi.process({ 49 | data: [item], 50 | entityKey: this.entityKey, 51 | sourceRefId: this.recordId, 52 | }); 53 | 54 | return ids[0]; 55 | } 56 | 57 | private sync() { 58 | this.notify(); 59 | } 60 | 61 | // ---------------- PUBLIC API ---------------- 62 | 63 | set(item: T) { 64 | this.id = this.process(item); 65 | this.sync(); 66 | } 67 | 68 | update(item: T) { 69 | this.process(item); 70 | this.sync(); 71 | } 72 | 73 | reset() { 74 | if (this.id === null) { 75 | return; 76 | } 77 | 78 | this.entitiesCleaner.deleteCascade( 79 | this.entityKey, 80 | [this.id], 81 | `${PREFIX.RECORD}${this.recordId}`, 82 | ); 83 | this.id = null; 84 | this.sync(); 85 | } 86 | 87 | // ---------------- GETTERS ---------------- 88 | 89 | get data(): M | undefined { 90 | if (!this.id) { 91 | return undefined; 92 | } 93 | 94 | const entity = this.entities.getEntity(this.entityKey, this.id) as 95 | | M 96 | | undefined; 97 | 98 | // lazy cleanup 99 | if (!entity) { 100 | queueMicrotask(() => { 101 | runInAction(() => { 102 | this.id = null; 103 | this.sync(); 104 | }); 105 | }); 106 | 107 | return undefined; 108 | } 109 | 110 | return entity; 111 | } 112 | 113 | get entityId() { 114 | return this.id; 115 | } 116 | 117 | get exists() { 118 | return this.id !== null && this.data !== undefined; 119 | } 120 | 121 | get isEmpty() { 122 | return this.id === null; 123 | } 124 | 125 | // ---------------- SNAPSHOT ---------------- 126 | 127 | getSnapshot(): EntityRecordSnapshot { 128 | return { id: this.id }; 129 | } 130 | 131 | applySnapshot(snapshot: EntityRecordSnapshot) { 132 | this.id = snapshot.id; 133 | this.sync(); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /docs/api/entity-record.md: -------------------------------------------------------------------------------- 1 | # EntityRecord 2 | 3 | `EntityRecord` represents a **stable pointer to a single entity** 4 | inside the normalized entity graph. 5 | 6 | It does not own data and never clones it. 7 | 8 | --- 9 | 10 | ## Purpose 11 | 12 | EntityRecord is designed for **singleton-like domain state**, such as: 13 | 14 | - current viewer 15 | - selected item 16 | - focused detail entity 17 | - active context entity 18 | 19 | It answers the question: 20 | 21 | > “Which entity is currently selected for this concern?” 22 | 23 | --- 24 | 25 | ## Mental Model 26 | 27 | An EntityRecord is a **reference**, not storage. 28 | 29 | - it stores only an entity id 30 | - it resolves the model dynamically 31 | - it does not extend entity lifetime beyond its refSource 32 | 33 | --- 34 | 35 | ## Creating a Record 36 | 37 | ```ts 38 | const viewerRecord = entities.createRecord({ 39 | entityKey: ENTITY_KEY.VIEWER, 40 | recordId: REF_SOURCE.CURRENT_VIEWER, 41 | }); 42 | ``` 43 | 44 | - `entityKey` defines entity type 45 | - `recordId` is used as a **refSource** 46 | - `recordId` must be stable and deterministic 47 | 48 | --- 49 | 50 | ## Public API 51 | 52 | ### data 53 | 54 | ```ts 55 | record.data: Model | undefined 56 | ``` 57 | 58 | Returns: 59 | 60 | - model instance if entity exists 61 | - `undefined` if record is empty or entity was removed 62 | 63 | This property is **reactive**. 64 | 65 | --- 66 | 67 | ### entityId 68 | 69 | ```ts 70 | record.entityId: string | number | null 71 | ``` 72 | 73 | Returns the current entity id, if any. 74 | 75 | --- 76 | 77 | ### exists 78 | 79 | ```ts 80 | record.exists: boolean 81 | ``` 82 | 83 | Indicates whether the record currently points to a valid entity. 84 | 85 | --- 86 | 87 | ### isEmpty 88 | 89 | ```ts 90 | record.isEmpty: boolean 91 | ``` 92 | 93 | True when no entity is selected. 94 | 95 | --- 96 | 97 | ## Mutations 98 | 99 | ### set(dto) 100 | 101 | ```ts 102 | record.set(dto); 103 | ``` 104 | 105 | Behavior: 106 | 107 | - normalizes incoming DTO 108 | - merges entity into `EntitiesStore` 109 | - attaches record refSource 110 | - updates `entityId` 111 | 112 | If multiple records reference the same entity: 113 | 114 | - the model instance is shared 115 | 116 | --- 117 | 118 | ### update(dto) 119 | 120 | ```ts 121 | record.update(dto); 122 | ``` 123 | 124 | Updates entity data **without changing the record binding**. 125 | 126 | --- 127 | 128 | ### reset() 129 | 130 | ```ts 131 | record.reset(); 132 | ``` 133 | 134 | Behavior: 135 | 136 | - detaches record refSource 137 | - clears `entityId` 138 | - entity may be removed if orphaned 139 | 140 | The record instance itself remains valid. 141 | 142 | --- 143 | 144 | ## Lifecycle Example 145 | 146 | ```ts 147 | record.set({ id: '1', name: 'Anna' }); 148 | record.data?.name === 'Anna'; 149 | 150 | record.reset(); 151 | record.data === undefined; 152 | ``` 153 | 154 | --- 155 | 156 | ## GC Safety 157 | 158 | EntityRecord participates in garbage collection via refSources. 159 | 160 | - records keep entities alive while active 161 | - removing a record allows cleanup 162 | - no memory leaks are possible 163 | 164 | --- 165 | 166 | ## Anti-Patterns 167 | 168 | ❌ Storing DTOs directly in stores 169 | ❌ Copying `record.data` into local state 170 | ❌ Mutating entity fields outside models 171 | 172 | --- 173 | 174 | ## Guarantees 175 | 176 | - stable identity 177 | - no duplicated models 178 | - safe cleanup 179 | - deterministic behavior 180 | -------------------------------------------------------------------------------- /docs/api/create-root-store.md: -------------------------------------------------------------------------------- 1 | # Root Store & Application Bootstrap 2 | 3 | The **Root Store** is the top-level entry point of a Nexigen application. 4 | 5 | It is responsible for wiring together: 6 | 7 | - API layer 8 | - entity schemas 9 | - domain stores 10 | - domain services 11 | 12 | There is exactly **one Root Store per application**. 13 | 14 | --- 15 | 16 | ## Purpose 17 | 18 | The Root Store exists to: 19 | 20 | - create a single dependency graph 21 | - initialize all domain stores consistently 22 | - provide a shared normalized entity graph 23 | - act as the application lifecycle boundary 24 | 25 | All application state flows **through** the Root Store. 26 | 27 | --- 28 | 29 | ## Creating a Root Store 30 | 31 | ```ts 32 | export const rootStore = createRootStore({ 33 | api: Api, 34 | schemaMap, 35 | stores: { 36 | auth: AuthStore, 37 | viewers: ViewersStore, 38 | viewer: ViewerStore, 39 | posts: PostsStore, 40 | }, 41 | services: { 42 | auth: AuthService, 43 | bootstrap: BootstrapService, 44 | }, 45 | }); 46 | ``` 47 | 48 | --- 49 | 50 | ## Configuration Object 51 | 52 | ### api 53 | 54 | ```ts 55 | api: Api; 56 | ``` 57 | 58 | The API layer used by all stores and services. 59 | 60 | Requirements: 61 | 62 | - stateless 63 | - deterministic 64 | - safe to reuse 65 | 66 | The API is injected, not imported directly. 67 | 68 | --- 69 | 70 | ### schemaMap 71 | 72 | ```ts 73 | schemaMap: Record; 74 | ``` 75 | 76 | A registry of all entity schemas used by the application. 77 | 78 | Schemas define: 79 | 80 | - entity identity 81 | - relationships 82 | - normalization rules 83 | - model instantiation 84 | 85 | All schemas must be registered **before** the app starts. 86 | 87 | --- 88 | 89 | ### stores 90 | 91 | ```ts 92 | stores: Record; 93 | ``` 94 | 95 | A map of domain store constructors. 96 | 97 | Each store: 98 | 99 | - is instantiated once 100 | - receives StoreDeps via constructor 101 | - participates in the shared entity graph 102 | 103 | Stores must not import each other directly. 104 | 105 | --- 106 | 107 | ### services 108 | 109 | ```ts 110 | services: Record; 111 | ``` 112 | 113 | Services represent **orchestration and side-effect logic**. 114 | 115 | Typical responsibilities: 116 | 117 | - app bootstrap flows 118 | - cross-store coordination 119 | - non-reactive workflows 120 | 121 | Services: 122 | 123 | - can call stores 124 | - can call API 125 | - do not hold observable state 126 | 127 | --- 128 | 129 | ## Registering the Root Store 130 | 131 | ```ts 132 | registerRootStore(rootStore); 133 | ``` 134 | 135 | Registers the Root Store instance globally. 136 | 137 | This enables: 138 | 139 | - access from React hooks 140 | - debugging and dev tooling 141 | - controlled global visibility 142 | 143 | Registration must happen **exactly once**. 144 | 145 | --- 146 | 147 | ## Lifecycle 148 | 149 | 1. Root Store is created 150 | 2. Schemas are registered 151 | 3. Stores are instantiated 152 | 4. Services are instantiated 153 | 5. Root Store is registered 154 | 6. Application starts 155 | 156 | --- 157 | 158 | ## Design Rules 159 | 160 | - One Root Store per app 161 | - No dynamic store registration 162 | - No global singletons outside Root Store 163 | - No store-to-store imports 164 | 165 | --- 166 | 167 | ## Guarantees 168 | 169 | - deterministic initialization order 170 | - explicit dependency graph 171 | - shared entity identity 172 | - predictable application lifecycle 173 | -------------------------------------------------------------------------------- /docs/docs/01-mental-model.md: -------------------------------------------------------------------------------- 1 | # Mental Model 2 | 3 | This document is **mandatory reading** before using Nexigen. 4 | 5 | If the mental model described here is violated, the system will still work 6 | at runtime, but its guarantees (consistency, cleanup, predictability) will 7 | no longer hold. 8 | 9 | --- 10 | 11 | ## Nexigen in One Sentence 12 | 13 | > Nexigen is an entity-normalized domain data engine where **entities own data** 14 | > and **stores only orchestrate behavior**. 15 | 16 | This single sentence explains most design decisions in the system. 17 | 18 | --- 19 | 20 | ## Problem Nexigen Solves 21 | 22 | Modern applications usually have: 23 | 24 | - multiple screens rendering the same data 25 | - lists and detail views over the same entities 26 | - partial updates from different endpoints 27 | - pagination with merging 28 | - async flows that update shared data 29 | 30 | Without normalization, this leads to: 31 | 32 | - duplicated data 33 | - inconsistent UI 34 | - manual cache invalidation 35 | - accidental memory growth 36 | 37 | Nexigen enforces **normalization and sharing by construction**. 38 | 39 | --- 40 | 41 | ## Core Rules (Non‑Negotiable) 42 | 43 | ### Rule 1 — Entities Own Data 44 | 45 | All domain data lives in **EntitiesStore**. 46 | 47 | Stores do not own DTOs. 48 | Collections do not own objects. 49 | Records do not clone state. 50 | 51 | Everything references entities. 52 | 53 | Breaking this rule leads to: 54 | 55 | - duplicated data 56 | - stale UI 57 | - impossible cleanup 58 | 59 | --- 60 | 61 | ### Rule 2 — Stores Orchestrate Behavior 62 | 63 | Stores are responsible for: 64 | 65 | - triggering async operations 66 | - choosing where data goes (record, collection) 67 | - reacting to user intent 68 | 69 | Stores are NOT responsible for: 70 | 71 | - storing entity fields 72 | - merging data manually 73 | - holding normalized objects 74 | 75 | --- 76 | 77 | ### Rule 3 — Lists Store IDs, Not Objects 78 | 79 | Collections store: 80 | 81 | - order 82 | - pagination metadata 83 | - **entity IDs** 84 | 85 | They never store objects. 86 | 87 | Objects are always resolved from EntitiesStore. 88 | 89 | --- 90 | 91 | ### Rule 4 — Models Never Hold Strong References 92 | 93 | Models never reference other models directly. 94 | 95 | Relations are resolved lazily via `EntityGetter`. 96 | 97 | This guarantees: 98 | 99 | - no circular references 100 | - no memory leaks 101 | - safe cascading cleanup 102 | 103 | --- 104 | 105 | ## Consequences of This Model 106 | 107 | If an entity is updated: 108 | 109 | - all lists reflect the update 110 | - all records reflect the update 111 | - all UI using that entity re-renders 112 | 113 | If an entity becomes unused: 114 | 115 | - it is removed automatically 116 | - no manual cache cleanup required 117 | 118 | --- 119 | 120 | ## Comparison with Other Approaches 121 | 122 | ### Plain MobX 123 | 124 | - no normalization 125 | - stores own data 126 | - cleanup is manual 127 | 128 | ### Redux 129 | 130 | - normalized data possible 131 | - heavy boilerplate 132 | - reducers own state 133 | 134 | ### Query Libraries 135 | 136 | - data tied to requests 137 | - poor cross-request sharing 138 | - limited lifecycle control 139 | 140 | ### Nexigen 141 | 142 | - normalization is mandatory 143 | - entities are global 144 | - lifecycle is deterministic 145 | 146 | --- 147 | 148 | ## Mental Checklist 149 | 150 | Before writing code, ask: 151 | 152 | - Where does this data live? (Entity) 153 | - How is it accessed? (Record / Collection) 154 | - Who triggers the flow? (Store) 155 | - What owns lifecycle? (refSource) 156 | 157 | If you cannot answer these, revisit this document. 158 | -------------------------------------------------------------------------------- /docs/api/react-hooks.md: -------------------------------------------------------------------------------- 1 | # React Hooks Integration 2 | 3 | Nexigen provides a **typed React integration layer** 4 | built on top of the Root Store. 5 | 6 | Hooks are generated once and provide: 7 | 8 | - type-safe access to stores and services 9 | - full IDE autocompletion 10 | - MobX reactivity out of the box 11 | 12 | --- 13 | 14 | ## Creating Hooks 15 | 16 | Hooks are created from the Root Store type. 17 | 18 | ```ts 19 | import { rootStore } from '@core/state/rootStore'; 20 | import { createStoreHooks } from '@nexigen/entity-normalizer'; 21 | 22 | export const { useStores, useServices, useStore, useService, useCore } = 23 | createStoreHooks(); 24 | ``` 25 | 26 | This step should be done **once** in the application. 27 | 28 | --- 29 | 30 | ## Available Hooks 31 | 32 | ### useStores 33 | 34 | ```ts 35 | const stores = useStores(); 36 | ``` 37 | 38 | Returns all domain stores, fully typed. 39 | 40 | ```ts 41 | const { posts, viewer, auth } = useStores(); 42 | ``` 43 | 44 | --- 45 | 46 | ### useStore 47 | 48 | ```ts 49 | const posts = useStore('posts'); 50 | ``` 51 | 52 | Access a single store by key. 53 | 54 | --- 55 | 56 | ### useServices 57 | 58 | ```ts 59 | const services = useServices(); 60 | ``` 61 | 62 | Returns all registered services. 63 | 64 | --- 65 | 66 | ### useService 67 | 68 | ```ts 69 | const authService = useService('auth'); 70 | ``` 71 | 72 | Access a single service. 73 | 74 | --- 75 | 76 | ### useCore 77 | 78 | ```ts 79 | const core = useCore(); 80 | ``` 81 | 82 | Provides access to core infrastructure (entities, notifications, etc). 83 | 84 | --- 85 | 86 | ## React Usage Example 87 | 88 | ```ts 89 | import { useEffect } from 'react'; 90 | import { observer } from 'mobx-react-lite'; 91 | import { useStores } from '@core/state'; 92 | 93 | const GROUPS = { 94 | ACTIVE: 'active', 95 | }; 96 | 97 | const ExploreContainer = () => { 98 | const { 99 | posts: { 100 | fetchPosts: { [GROUPS.ACTIVE]: fetchPostsActive }, 101 | lists: { 102 | [GROUPS.ACTIVE]: { getList: activeList }, 103 | }, 104 | fetchMorePosts: { [GROUPS.ACTIVE]: fetchMorePostsActive }, 105 | }, 106 | } = useStores(); 107 | 108 | useEffect(() => { 109 | fetchPostsActive.run({ params: { group: GROUPS.ACTIVE } }); 110 | }, []); 111 | 112 | return ( 113 | 118 | fetchPostsActive.run({ 119 | params: { group: GROUPS.ACTIVE, force: true }, 120 | }) 121 | } 122 | getMorePosts={() => 123 | fetchMorePostsActive.run({ 124 | params: { group: GROUPS.ACTIVE }, 125 | }) 126 | } 127 | /> 128 | ); 129 | }; 130 | 131 | export default observer(ExploreContainer); 132 | ``` 133 | 134 | --- 135 | 136 | ## Reactivity Model 137 | 138 | - Hooks return MobX-backed objects 139 | - Components must be wrapped with `observer` 140 | - Reading observable fields creates subscriptions 141 | - Updates propagate automatically 142 | 143 | No selectors or memoization are required. 144 | 145 | --- 146 | 147 | ## Type Safety 148 | 149 | All hooks are: 150 | 151 | - fully inferred from `rootStore` 152 | - strongly typed 153 | - IDE-friendly 154 | 155 | Refactoring store keys or APIs 156 | will surface type errors immediately. 157 | 158 | --- 159 | 160 | ## Anti-Patterns 161 | 162 | ❌ Using hooks outside React components 163 | ❌ Mutating store state outside actions 164 | ❌ Using hooks without `observer` 165 | 166 | --- 167 | 168 | ## Guarantees 169 | 170 | - zero boilerplate 171 | - predictable reactivity 172 | - full type inference 173 | - safe store access 174 | --------------------------------------------------------------------------------