UNPKG

@taraai/read-write

Version:

Synchronous NoSQL/Firestore for React

397 lines (289 loc) 11.8 kB
# Read/Write **[dark launch: pre-release]** ## Synchronous NoSQL/Firestore for React - **[Instant UI.](#read)** Change data synchronously. No Thunks, no Sagas, no Axios, no GraphQL mutations/subscriptions. - **[Zero-Redux Redux.](#write)** Write Redux without Redux. No reducers, no slices, no selectors, no entity mappers, no normalization. - **[The One Test.](#testing)** The One test rules them all. Why write seperate unit, integration, visual/storybook & property-based tests? _The One_ test validates each layer seperately & together. No boilerplate, no stubs, no mocks, no spys. - **[Offline-first NoSQL.](#documentation)** Firestore ACID-compliant transactions with live subscriptions. [![License][license-image]][license-url] <!-- TODO: insert badges here [![NPM version][npm-image]][npm-url] [![NPM downloads][npm-downloads-image]][npm-url] [![Code Style][code-style-image]][code-style-url] [![Code Coverage][coverage-image]][coverage-url] --> # API Basics ## Read `useRead({ path, ...query })` Query & load & subscribe to live updates from Firestore. ```ts const tasks = useRead({ path: 'tasks', where: [ ['status', '==', 'done'], ['assignee', '==', myUID] ], orderBy: ['createdAt', 'desc'], }); ``` [@see Advanced Read](./docs/read.md#advanced-read) ## Write `createMutate({ action, read, write })` Create a Redux action creator to create, update & delete data. Mutations synchronously update the Redux store. This makes React components feel instant while data persistence are eventually consistent. ```ts const archiveAction = createMutate({ action: 'ArchiveTask', read: (taskId) => ({ taskId: () => taskId }), write: ({ taskId }) => ({ path:'tasks', id: taskId, archived: true }), }); ``` [@see Advanced Write](./docs/write.md#advanced-write) `createMutate` returns an Action Creator. When the action creator is dispatched it return a promise that will execute when Firestore accepts or rejects the mutation. ```ts import { archiveAction } from './mutations'; const ReactComponent = () => { return <div role="button" onClick={() => { useDispatch(archiveAction('task-one')) .then(() => alert('task archived.')); }} /> } ``` # Testing ## Unit Tests `it.each([{ payload, results }])(...shouldPass)` Zero bolierplate testing. No mocks or spies; just data. ```ts import { archiveAction } from '../mutations'; it.each({ setup: [{ path: 'tasks', id: '99', archived: false, }], payload: { taskId: '99' }, results: [{ path: 'tasks', id: '99', archived: true, }], })(...shouldPass(archiveAction)); ``` `it.each([{ payload, returned }])(...shouldFail)` Switch `results` for `returned` to run failure checks. ```ts it.each([{ payload: { taskId: 'not-valid-id' }, returned: new Error('Document not found.'), }])(...shouldFail(archiveAction)); ``` [@see Jest Test](./docs/test.md#jest) ## Intergration Tests Automatically upgrade unit tests to intergration with just a boolean. Integration tests loads setup data into the database, tests access rules then validates the final mutation in the database. _(Coming Soon): Parallelized intergation tests_ ```ts import { archiveAction } from '../mutations'; const RUN_AS_INTEGRATION = true; // or use the env var READWRITE_INTEGRATION=true it.each({ setup: [{ path: 'tasks', id: '99', archived: false, }], payload: { taskId: '99' }, results: [{ path: 'tasks', id: '99', archived: true, }], })(...shouldPass(archiveAction, RUN_AS_INTEGRATION)); ``` [@see Jest Test](./docs/test.md#jest) ## StoryBook Tests `it.each([{ payload, component, results }])(...shouldPass)` Unit tests can generate storybook tests with a pre and a post for the mutation by adding a `component` property to the test. ```ts import { archiveAction } from '../mutations'; it.each({ setup: [{ path: 'tasks', id: '99', archived: false, }], payload: { taskId: '99' }, component: 'path/to/component', results: [{ path: 'tasks', id: '99', archived: true, }], })(...shouldPass(archiveAction)); ``` [@see Storybook Test](./docs/test.md#storybook) ## Typescript QuickCheck Tests `it.each([{ payload, results }])(...shouldPass)` Test for the unknown. Unit tests have one major flaw, they can only test the _known; not unknown_ cases. It's impossible for you to find the unknown. _But it is possible to let the unknowns find you._ This is what Haskell-style QuickCheck does. QuickCheck systems are analogous to property-based testing or fuzzing. When data is generated on each run a single test can test and validate all possible permutations of pass/fail conditions for a mutation. When a test fails it will throw with the exact data to expose new cases that the code didn't handle. ```ts import { generate } from 'TypescriptDecoder'; import { archiveAction } from '../mutations'; const task = generate('Task', { archived: false }); it.each({ setup: [task], payload: { taskId: task.id }, results: [{ ...task, archived: true, }], })(...shouldPass(archiveAction)); ``` [@coming-soon: TypeCheck Tests]() _Working to extract it from our codebase_ ## The One Test (QuickType + Unit + Intergration + Storybook) Why write multiple tests when you could write one? ```ts import { generate } from 'TypescriptDecoder'; import { archiveAction } from '../mutations'; const task = generate('Task', { archived: false }); it.each({ setup: [task], payload: { taskId: task.id }, component: 'path/to/component', results: [{ ...task, archived: true, }], })(...shouldPass(archiveAction, true)); ``` [@coming-soon: Docs for The One Tests]() # Example Project [Read/Write Notes](./examples/notes) Run `yarn && yarn start` # Alternatives Looking for options to work with Firestore? Check out these other libraries: - [Offical Google SDK](https://github.com/firebase/firebase-js-sdk) - [ReactFire](https://github.com/FirebaseExtended/reactfire) - [redux-firestore](https://github.com/prescottprue/redux-firestore) # Documentation **API Documentation** - [Read](./docs/read.md) - [Write](./docs/write.md) - [Test](./docs/test.md) - [Setup](./docs/getting-started.md) - [Coming from GraphQL?](./docs/graphql.md) **Code deep-dives** - [Breaking down Optimistic Updates](./docs/cache-reducer.md) - [Translating Firesore to Mutations](./docs/mutate.md) - [No-Redux Redux](./docs/performance.md) **Design Fundamentals** - [What's DoD (Data Oriented Design)?](https://gamesfromwithin.com/data-oriented-design) - [Code Is Data; Data Is Code](https://medium.com/linebyline/choosing-clojure-part-1-code-is-data-8932f333e734) - [Fact-based Queries](https://docs.datomic.com/on-prem/query/query.html) - [Transducers](https://clojure.org/guides/faq#transducers_vs_seqs) # Setup 1. Add the libraries to your project. ``` yarn add read-write firebase @reduxjs/toolkit redux ``` 2. Include the firestore/firebase reducers and thunk middleware. ```javascript import { configureStore, ThunkAction, Action } from '@reduxjs/toolkit'; import { getFirebase, getFirestore, firebaseReducer, firestoreReducer, } from 'read-write'; import firebase from 'firebase/compat/app'; // Create store with reducers and initial state export const store = configureStore({ // Add Firebase to reducers reducer: combineReducers({ firebase: firebaseReducer, firestore: firestoreReducer, }), middleware: (getDefaultMiddleware) => getDefaultMiddleware({ thunk: { extraArgument: { getFirestore, getFirebase }, }, }), }); export type AppDispatch = typeof store.dispatch; export type RootState = ReturnType<typeof store.getState>; export type AppThunk<ReturnType = void> = ThunkAction< ReturnType, RootState, unknown, Action<string> >; ``` `hooks.ts` ```ts import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; import type { AppDispatch, RootState } from './store'; export const useAppDispatch = () => useDispatch<AppDispatch>(); export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector; ``` 3. Initialize Firebase and pass store to your component's context using [react-redux's `Provider`](https://github.com/reactjs/react-redux/blob/master/docs/api.md#provider-store): ```js import React from 'react'; import { render } from 'react-dom'; import App from './App'; import { store } from './app/store'; import { Provider } from 'react-redux'; import { ReactReduxFirebaseProvider, createFirestoreInstance, } from 'read-write'; import firebase from 'firebase/compat/app'; import 'firebase/compat/firestore'; import 'firebase/compact/auth'; const firebaseApp = firebase.initializeApp({ authDomain: process.env.REACT_APP_FIREBASE_authDomain, databaseURL: process.env.REACT_APP_FIREBASE_databaseUrl, projectId: process.env.REACT_APP_FIREBASE_projectId, }); render( <Provider store={store}> <ReactReduxFirebaseProvider firebase={firebaseApp} dispatch={store.dispatch} createFirestoreInstance={createFirestoreInstance} > <App /> </ReactReduxFirebaseProvider> </Provider>, document.querySelector('body'), ); ``` # Future Roadmap **v1.0 - in progress** - [x] lib: read & write data with optimistic commits - [x] lib: 100% support for all Firestore features - [x] lib: hooks return query results from firestore - [x] lib: hooks return picks & partials of firestore data - [x] lib: hooks alternative solution for createSelector - [x] lib: hooks validatin of rendering performance - [x] lib: cache reducer synchronous, optimistic reads - [x] lib: cache reducer synchronous, optimistic database writes - [x] lib: cache reducer synchronous updates all affected queries upon mutation - [x] lib: cache reducer performance speed ups by minimizing Immer changes - [x] lib: mutation support Redux enhancers for global data - [x] lib: mutation support basic writes for Firestore - [x] lib: mutation support nested field updates - [x] lib: mutation force read providers to be idempotent - [x] lib: mutation support for all FieldValue types (top-level only) - [x] lib: mutation batches accept an infinite number of writes, chunk into 500 and fold in results - [x] lib: mutation transactions run synchronous, optimistic and are ACID-compliant (online-only) - [x] tests: support data-driven unit tests - [x] tests: data-driven intergration tests with Firestore emulator - [x] tests: data-driven storybook tests are written to disk - [ ] **todo** tests: switch intergration tests to run parallelized - [x] DX: add _readwrite:cache_ profiling for Redux store changes - [x] DX: add _readwrite:profile_ profiling for data load phases timings - [ ] **in progress** docs: document public API layer - [ ] **todo** testing: increase code coverage from 90% to 100% **future** - docs: document internal processes - lib: remove redux-firebase, redux, redux-toolkit dependencies - lib: create redux-compatable layer but don't use Redux - lib: reduce lib deployment size - lib: support CSP channel streaming reducers with max runtime buffer - lib: support hard delete - lib: remove deprecated populates - lib: support custom returned results in dispatch promise - lib: queries support Firestore Document Refs in pagination - lib: allow custom config - lib: cache reducer performance boost on reprocessing by exclude on where clauses - lib: refactor cache reduce & mutation to be agnostic for any NoSQL - tests: export Typescript Decoders from our interal project - tests: move test cases to pure JSON - tests: add auth into intergration tests for Firestore rules - tests: create API to better integrate visual tests into storybook - tests: setup support for standard redux reducers and selectors - tests: QuickType support relational ids - _add your feature request here_