@puppybits/read-write
TypeScript icon, indicating that this package has built-in type declarations

0.0.1 • Public • Published

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'],
});

@see 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.

const archiveAction = createMutate({ 
  action: 'ArchiveTask', 

  read: (taskId) => ({ taskId: () => taskId }), 
  
  write: ({ taskId }) => ({ 
    path:'tasks', 
    id: taskId, 
    archived: true 
  }),
});

@see 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.

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));

@see Jest Test

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));

@see Jest Test

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));

@see Storybook Test

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

Read/Write Notes

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

  1. Add the libraries to your project.
yarn add read-write firebase @reduxjs/toolkit redux
  1. 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;
  1. 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

Package Sidebar

Install

npm i @puppybits/read-write

Weekly Downloads

1

Version

0.0.1

License

MIT

Unpacked Size

3.22 MB

Total Files

171

Last publish

Collaborators

  • puppybits