@puppybits/read-write
v0.0.1
Published
Synchronous Firestore
Downloads
1
Maintainers
Readme
Read/Write
[dark launch: pre-release]
The fastest, production-ready DX & UX for NoSQL/Firestore.
- Instant UI. Change data synchronously. No Thunks, no Sagas, no Axios, no GraphQL mutations/subscriptions.
- Zero-Redux Redux. Write Redux without Redux. No reducers, no slices, no selectors, no entity mappers, no normalization.
- The One Test. 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. Firestore ACID-compliant transactions with live subscriptions.
[![License][license-image]][license-url]
API Basics
Read
useRead({ path, ...query })
Query & load & subscribe to live updates from Firestore.
const tasks = useRead({
path: 'tasks',
where: [
['status', '==', 'done'],
['assignee', '==', myUID]
],
orderBy: ['createdAt', 'desc'],
});
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.
const archiveAction = createMutate({
action: 'ArchiveTask',
read: (taskId) => ({ taskId: () => taskId }),
write: ({ taskId }) => ({
path:'tasks',
id: taskId,
archived: true
}),
});
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.
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.
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.
it.each([{
payload: { taskId: 'not-valid-id' },
returned: new Error('Document not found.'),
}])(...shouldFail(archiveAction));
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
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));
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.
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));
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.
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?
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
Run yarn && yarn start
Alternatives
Looking for options to work with Firestore? Check out these other libraries:
Documentation
API Documentation
Code deep-dives
Design Fundamentals
Setup
- Add the libraries to your project.
yarn add read-write firebase @reduxjs/toolkit redux
- Include the firestore/firebase reducers and thunk middleware.
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
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { AppDispatch, RootState } from './store';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
- Initialize Firebase and pass store to your component's context using react-redux's
Provider
:
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