UNPKG

@oimdb/redux-adapter

Version:

Redux adapter for OIMDB - Create Redux reducers from OIMDB collections and indexes

613 lines (479 loc) 19.6 kB
# @oimdb/redux-adapter Production-ready Redux adapter for OIMDB that enables seamless integration between OIMDB's reactive in-memory database and Redux state management. This package allows you to gradually migrate from Redux to OIMDB or use both systems side-by-side with automatic two-way synchronization. ## 🚀 Installation ```bash npm install @oimdb/redux-adapter @oimdb/core redux ``` ## ✨ Key Features - **🔄 Two-Way Synchronization**: Automatic sync between OIMDB and Redux in both directions - **📦 Production Ready**: Battle-tested, optimized for large datasets with efficient change detection - **🔄 Gradual Migration**: Integrate OIMDB into existing Redux projects without breaking changes - **🎯 Flexible State Mapping**: Custom mappers for any Redux state structure - **⚡ Performance Optimized**: Efficient diffing algorithms and batched updates - **🔌 Redux Compatible**: Works seamlessly with existing Redux middleware and tools ## 🎯 Use Cases ### 1. **Replace Redux Entirely** Use OIMDB as your primary state management with Redux as a compatibility layer for existing code. ### 2. **Gradual Migration** Migrate from Redux to OIMDB incrementally, one collection at a time, without disrupting your application. ### 3. **Hybrid Approach** Use OIMDB for complex relational data and Redux for simple UI state, with automatic synchronization. ## 📦 What's Included - **OIMDBReduxAdapter**: Main adapter class for creating Redux reducers and middleware from OIMDB collections - **Automatic Middleware**: Built-in middleware for automatic event queue flushing after Redux actions - **Default Mappers**: RTK Entity Adapter-style mappers for collections and indexes - **Utility Functions**: `findUpdatedInRecord` and `findUpdatedInArray` for efficient change detection - **Type-Safe**: Full TypeScript support with comprehensive type definitions ## 🔧 Basic Usage ### Simple One-Way Sync (OIMDBRedux) ```typescript import { OIMDBReduxAdapter } from '@oimdb/redux-adapter'; import { OIMReactiveCollection, OIMEventQueue } from '@oimdb/core'; import { createStore, combineReducers, applyMiddleware } from 'redux'; interface User { id: string; name: string; email: string; } // Create OIMDB collection const queue = new OIMEventQueue(); const users = new OIMReactiveCollection<User, string>(queue, { selectPk: (user) => user.id }); // Create Redux adapter const adapter = new OIMDBReduxAdapter(queue); // Create Redux reducer from OIMDB collection const usersReducer = adapter.createCollectionReducer(users); // Create middleware for automatic flushing const middleware = adapter.createMiddleware(); // Create Redux store with middleware const store = createStore( combineReducers({ users: usersReducer, }), applyMiddleware(middleware) ); // Set store in adapter (can be done later) adapter.setStore(store); // OIMDB changes automatically sync to Redux users.upsertOne({ id: '1', name: 'John', email: 'john@example.com' }); queue.flush(); // Triggers Redux update // Redux state is automatically updated const state = store.getState(); console.log(state.users.entities['1']); // { id: '1', name: 'John', email: 'john@example.com' } ``` ### Two-Way Sync (OIMDB ↔ Redux) Enable bidirectional synchronization by providing a child reducer. The middleware automatically flushes the queue after each action, so manual `queue.flush()` is not needed: ```typescript import { OIMDBAdapter, TOIMDBReduxDefaultCollectionState, TOIMDBReduxCollectionReducerChildOptions } from '@oimdb/redux-adapter'; import { Action } from 'redux'; import { createStore, applyMiddleware } from 'redux'; // Child reducer handles custom Redux actions const childReducer = ( state: TOIMDBReduxDefaultCollectionState<User, string> | undefined, action: Action ): TOIMDBReduxDefaultCollectionState<User, string> => { if (state === undefined) { return { entities: {}, ids: [] }; } if (action.type === 'UPDATE_USER_NAME') { const { id, name } = action.payload; return { ...state, entities: { ...state.entities, [id]: { ...state.entities[id], name } } }; } return state; }; const childOptions: TOIMDBReduxCollectionReducerChildOptions<User, string, TOIMDBReduxDefaultCollectionState<User, string>> = { reducer: childReducer, getPk: (user) => user.id, // extractEntities is optional - default implementation handles TOIMDBReduxDefaultCollectionState // linkedIndexes is optional - automatically updates indexes when entity fields change }; // Create reducer with child const usersReducer = adapter.createCollectionReducer(users, childOptions); // Create store with middleware const store = createStore( usersReducer, applyMiddleware(adapter.createMiddleware()) ); adapter.setStore(store); // Redux actions automatically sync back to OIMDB // Middleware automatically flushes queue after dispatch store.dispatch({ type: 'UPDATE_USER_NAME', payload: { id: '1', name: 'John Updated' } }); // No manual queue.flush() needed - middleware handles it! // OIMDB collection is automatically updated const user = users.getOneByPk('1'); console.log(user?.name); // 'John Updated' ``` ### Linked Indexes Automatically update indexes when entity array fields change in **both directions**: - **Redux → OIMDB**: When Redux state changes via child reducer, linked indexes are updated - **OIMDB → Redux**: When OIMDB collection changes directly, linked indexes are updated automatically The entity's PK (obtained via `getPk`) becomes the index key, and the array field values become the index values. Index updates are triggered when the array field changes **by reference** (`===` comparison). No need to create separate index reducers: ```typescript import { OIMDBAdapter, TOIMDBReduxDefaultCollectionState, TOIMDBReduxCollectionReducerChildOptions } from '@oimdb/redux-adapter'; import { OIMReactiveIndexManualArrayBased } from '@oimdb/core'; interface Deck { id: string; cardIds: string[]; // Array of card IDs name: string; } const decksCollection = new OIMReactiveCollection<Deck, string>(queue, { selectPk: (deck) => deck.id }); // Use ArrayBased index for full array replacements (recommended for redux-adapter) // The adapter optimizes ArrayBased indexes by skipping diff computation const cardsByDeckIndex = new OIMReactiveIndexManualArrayBased<string, string>(queue); const childReducer = ( state: TOIMDBReduxDefaultCollectionState<Deck, string> | undefined, action: Action ): TOIMDBReduxDefaultCollectionState<Deck, string> => { if (state === undefined) { return { entities: {}, ids: [] }; } if (action.type === 'UPDATE_DECK_CARDS') { const { deckId, cardIds } = action.payload; const deck = state.entities[deckId]; if (deck) { return { ...state, entities: { ...state.entities, [deckId]: { ...deck, cardIds } // Update cardIds array } }; } } return state; }; const childOptions: TOIMDBReduxCollectionReducerChildOptions< Deck, string, TOIMDBReduxDefaultCollectionState<Deck, string> > = { reducer: childReducer, getPk: (deck) => deck.id, linkedIndexes: [ { index: cardsByDeckIndex, fieldName: 'cardIds', // Array field containing PKs }, ], }; const decksReducer = adapter.createCollectionReducer( decksCollection, childOptions ); // When deck.cardIds changes (by reference), the index is automatically updated: // - index[deck.id] = deck.cardIds // - Old values removed, new values added automatically // - Works in both directions: Redux → OIMDB and OIMDB → Redux // No need to create a separate index reducer! // Example: Update via Redux store.dispatch({ type: 'UPDATE_DECK_CARDS', payload: { deckId: 'deck1', cardIds: ['card1', 'card2', 'card3'] } }); // Index automatically updated: cardsByDeckIndex['deck1'] = ['card1', 'card2', 'card3'] // Example: Update via OIMDB decksCollection.upsertOne({ id: 'deck1', cardIds: ['card4', 'card5'], // New array reference name: 'Deck 1' }); queue.flush(); // Triggers OIMDB_UPDATE action // Index automatically updated: cardsByDeckIndex['deck1'] = ['card4', 'card5'] ``` ### Custom State Structure Use custom mappers for any Redux state structure: ```typescript // Array-based state type ArrayBasedState = { users: User[]; }; const arrayMapper = (collection: OIMReactiveCollection<User, string>) => { return { users: collection.getAll() }; }; const arrayReducer = adapter.createCollectionReducer(users, undefined, arrayMapper); // Custom extractor for array-based state const childOptions: TOIMDBReduxCollectionReducerChildOptions<User, string, ArrayBasedState> = { reducer: (state, action) => { // Your custom reducer logic return state; }, extractEntities: (prevState, nextState, collection, getPk) => { const prevIds = (prevState?.users ?? []).map(u => getPk(u)); const nextIds = nextState.users.map(u => getPk(u)); // Use utility function for efficient diffing const { findUpdatedInArray } = require('@oimdb/redux-adapter'); const diff = findUpdatedInArray(prevIds, nextIds); // Sync changes to OIMDB if (diff.added.length > 0 || diff.updated.length > 0) { const toUpsert = nextState.users.filter(u => diff.added.includes(getPk(u)) || diff.updated.includes(getPk(u)) ); collection.upsertMany(toUpsert); } if (diff.removed.length > 0) { collection.removeManyByPks(diff.removed); } }, getPk: (user) => user.id }; ``` ## 🔄 Migration Strategy ### Phase 1: Add OIMDB Alongside Redux Start by adding OIMDB for new features while keeping existing Redux code unchanged: ```typescript const adapter = new OIMDBReduxAdapter(queue); const middleware = adapter.createMiddleware(); const store = createStore( combineReducers({ // Existing Redux reducers ui: uiReducer, auth: authReducer, // New OIMDB-backed reducers users: adapter.createCollectionReducer(usersCollection), posts: adapter.createCollectionReducer(postsCollection), }), applyMiddleware(middleware) ); adapter.setStore(store); ``` ### Phase 2: Migrate Existing Redux Reducers Gradually replace Redux reducers with OIMDB collections, using child reducers to maintain compatibility: ```typescript // Old Redux reducer const oldUsersReducer = (state, action) => { // ... existing logic }; // New OIMDB-backed reducer with compatibility layer const adapter = new OIMDBReduxAdapter(queue); const newUsersReducer = adapter.createCollectionReducer( usersCollection, { reducer: oldUsersReducer, // Reuse existing reducer logic getPk: (user) => user.id } ); const store = createStore( newUsersReducer, applyMiddleware(adapter.createMiddleware()) ); adapter.setStore(store); ``` ### Phase 3: Full OIMDB Migration Once all collections are migrated, you can remove Redux entirely and use OIMDB directly with React hooks or other reactive patterns. ## 🛠️ Advanced Usage ### Custom Mappers ```typescript const customMapper: TOIMDBReduxCollectionMapper<User, string, CustomState> = ( collection, updatedKeys, currentState ) => { // Your custom mapping logic // Only process entities in updatedKeys for performance const entities: Record<string, User> = {}; const ids: string[] = []; if (currentState) { // Reuse existing state Object.assign(entities, currentState.entities); ids.push(...currentState.ids); } // Update only changed entities for (const id of updatedKeys) { const entity = collection.getOneByPk(id); if (entity) { entities[id] = entity; if (!ids.includes(id)) { ids.push(id); } } else { delete entities[id]; const index = ids.indexOf(id); if (index > -1) { ids.splice(index, 1); } } } return { entities, ids }; }; ``` ### Index Reducers #### Simple One-Way Sync (OIMDB → Redux) ```typescript import { OIMReactiveIndexManualArrayBased } from '@oimdb/core'; // Create index (ArrayBased recommended for redux-adapter - optimized performance) const userRolesIndex = new OIMReactiveIndexManualArrayBased<string, string>(queue); // Create reducer for index const adapter = new OIMDBReduxAdapter(queue); const rolesReducer = adapter.createIndexReducer(userRolesIndex); // Use in Redux store with middleware const store = createStore( combineReducers({ users: usersReducer, userRoles: rolesReducer, }), applyMiddleware(adapter.createMiddleware()) ); adapter.setStore(store); ``` #### Two-Way Sync (OIMDB ↔ Redux) for Indexes Enable bidirectional synchronization for indexes by providing a child reducer: ```typescript import { OIMDBAdapter, TOIMDBReduxDefaultIndexState, TOIMDBReduxIndexReducerChildOptions } from '@oimdb/redux-adapter'; import { Action } from 'redux'; import { createStore, applyMiddleware } from 'redux'; // Child reducer handles custom Redux actions const childReducer = ( state: TOIMDBReduxDefaultIndexState<string, string> | undefined, action: Action ): TOIMDBReduxDefaultIndexState<string, string> => { if (state === undefined) { return { entities: {} }; } if (action.type === 'UPDATE_INDEX_KEY') { const { key, ids } = action.payload; return { ...state, entities: { ...state.entities, [key]: { id: key, ids } } }; } return state; }; const childOptions: TOIMDBReduxIndexReducerChildOptions< string, string, TOIMDBReduxDefaultIndexState<string, string> > = { reducer: childReducer, // extractIndexState is optional - default implementation handles TOIMDBReduxDefaultIndexState }; // Create reducer with child const indexReducer = adapter.createIndexReducer(userRolesIndex, childOptions); // Create store with middleware const store = createStore( indexReducer, applyMiddleware(adapter.createMiddleware()) ); adapter.setStore(store); // Redux actions automatically sync back to OIMDB // Middleware automatically flushes queue after dispatch store.dispatch({ type: 'UPDATE_INDEX_KEY', payload: { key: 'role1', ids: ['user1', 'user2', 'user3'] } }); // No manual queue.flush() needed - middleware handles it! // OIMDB index is automatically updated const pks = userRolesIndex.index.getPksByKey('role1'); // Returns TPk[] for ArrayBased console.log(pks); // ['user1', 'user2', 'user3'] ``` ## 📊 Performance The adapter is optimized for large datasets: - **Efficient Diffing**: Uses optimized algorithms to detect changes - **Batched Updates**: Changes are coalesced and applied in batches - **Selective Updates**: Only changed entities are processed - **Memory Efficient**: Reuses state objects when possible ### Index Performance Optimization The adapter intelligently handles different index types for optimal performance: - **ArrayBased Indexes** (using `setPks`): When linked indexes use ArrayBased indexes, the adapter directly sets the new array without computing diffs. This eliminates unnecessary array comparisons and `getPksByKey` calls, providing significant performance improvements, especially with many linked indexes. - **SetBased Indexes** (using `addPks`/`removePks`): For SetBased indexes, the adapter computes diffs to apply incremental updates, which is more efficient than full replacements for Set-based data structures. **Recommendation**: For redux-adapter, prefer **ArrayBased indexes** for linked indexes, as they provide the best performance when replacing entire arrays (which is the common pattern when syncing from Redux state). **Example:** ```typescript // ArrayBased index - direct assignment (fast, recommended for redux-adapter) const cardsByDeckIndex = new OIMReactiveIndexManualArrayBased<string, string>(queue); // When deck.cardIds changes, adapter simply does: // index.setPks(deckId, newCardIds) - no diff computation needed! // SetBased index - incremental updates (efficient for Sets) const tagsByUserIndex = new OIMReactiveIndexManualSetBased<string, string>(queue); // When user.tags changes, adapter computes diff and uses: // index.addPks(userId, toAdd) and index.removePks(userId, toRemove) ``` ## 🔍 Utility Functions ### `findUpdatedInRecord` Efficiently find differences between two entity records (dictionaries): ```typescript import { findUpdatedInRecord } from '@oimdb/redux-adapter'; const oldEntities = { '1': user1, '2': user2 }; const newEntities = { '1': user1Updated, '3': user3 }; const diff = findUpdatedInRecord(oldEntities, newEntities); // diff.added = Set(['3']) // diff.updated = Set(['1']) // diff.removed = Set(['2']) // diff.all = Set(['1', '2', '3']) ``` ### `findUpdatedInArray` Efficiently find differences between two arrays of primary keys: ```typescript import { findUpdatedInArray } from '@oimdb/redux-adapter'; const oldIds = ['1', '2', '3']; const newIds = ['1', '3', '4']; const diff = findUpdatedInArray(oldIds, newIds); // diff.added = ['4'] // diff.updated = ['1', '3'] // diff.removed = ['2'] // diff.all = ['1', '2', '3', '4'] ``` ## 🎨 TypeScript Support Full type safety with comprehensive TypeScript definitions: ```typescript import type { TOIMDBReduxCollectionMapper, TOIMDBReduxIndexMapper, TOIMDBReduxDefaultCollectionState, TOIMDBReduxDefaultIndexState, TOIMDBReduxCollectionReducerChildOptions, TOIMDBReduxLinkedIndex, TOIMDBReduxIndexReducerChildOptions, TOIMDBReduxUpdatedEntitiesResult, TOIMDBReduxUpdatedArrayResult, } from '@oimdb/redux-adapter'; ``` ## 📚 API Reference ### `OIMDBReduxAdapter` Main adapter class for integrating OIMDB with Redux. Creates Redux reducers from OIMDB collections and provides middleware for automatic event queue flushing. #### Methods - `createCollectionReducer<TEntity, TPk, TState>(collection, child?, mapper?)`: Create reducer for a collection - `createIndexReducer<TIndexKey, TPk, TState>(index, child?, mapper?)`: Create reducer for an index (supports both SetBased and ArrayBased indexes) - `createMiddleware()`: Create Redux middleware that automatically flushes the event queue after each action - `setStore(store)`: Set Redux store (can be called later) - `flushSilently()`: Flush the event queue without triggering OIMDB_UPDATE dispatch (used internally by middleware) #### Constructor Options - `defaultCollectionMapper`: Default mapper for all collections - `defaultIndexMapper`: Default mapper for all indexes #### Automatic Flushing The middleware created by `createMiddleware()` automatically calls `flushSilently()` after every Redux action. This ensures that: - Events triggered by child reducers are processed synchronously - No manual `queue.flush()` is needed when updating OIMDB from Redux - OIMDB_UPDATE dispatch is not triggered unnecessarily (preventing loops) ## 🤝 Contributing Contributions are welcome! Please feel free to submit a Pull Request. ## 📄 License MIT