biceps

Biceps is a nutrition and fitness app

Stars
6
Committers
1

Biceps is a local-first personal nutrition planning app.

Technical details

Stack

There's no server, everything runs in the browser.

Frontend

Data stack "backend"

Everything below runs in the browser but the stack has been insipired by traditional backend architecture. CRDT leveraged for local-first data storage and later for potentially syncing.

Starting from back towards front:

  • cr-sqlite for data storage and local-first support

    • cr-sqlite has a built-in automigration system, but it didn't work for all scenarios. For that reason, a custom migration system was implemented in src/migrations.ts.
  • Custom entity database model system that uses SQLite under the hood

    Starting from back towards front:

    • Types are defined as zod schemas. Example: src/db/schemas/RecipeSchema.ts

      Normally, we'd in addition have SQL schema to guarantee constraints, but due to CRDT not all constraints can be used.

      These entity schemas are the backbone of everything else. They allow building all other layers and abstractions with strict types.

    • src/db/interface/databaseMethods.ts wraps the lower level SQLite API with a few useful primitives similar to slonik

    • src/db/interface/entityInterface.ts defines the public API contract that each entity must implement. In addition, every entity can implement any custom method outside the interface for more complicated operations (for example see Recipe.findManyByProductIds).

    • src/db/interface/entityMethods.ts implements the API that entityInterface.ts specifies. Takes the entity schema and database table name as input.

    • The actual entity implementations are at src/db/entities. For example src/db/entities/Recipe.ts.

    • To ensure each entity implementation actually conforms the interface, there's src/scripts/autoGenerateEntitiesIndex.ts to generate the index file that exports all entities.

      The index file looks like this:

      import { createEntity } from 'src/db/interface/entityInterface'
      
      // This createEntity call ensures the interface compatibility at TypeScript level
      const AppStateEntity = createEntity({
        find: findAppState,
        // ... all methods
      })
      

      In addition to type-safety, the index file provides as a convenient import point for all entities.

  • Resolver layer

    This is an alternative approach to relationship mapping and JOIN queries usually defined in ORM. There's pros and cons in each approach.

    The layer consists of:

    • DataLoader to avoid N+1 queries in the resolvers.

    • Custom resolvers for each entity model. Defined at src/db/resolvers/resolversPerEntity.ts.

      The resolver layer is greatly inspired by GraphQL (which enables even more powerful resolving layer). It allows building simple entity models that are mostly separated from each other but also combining them to joint models that are more convenient for the frontend.

  • React Query for data fetching via APIs or SQLite database

  • Data access layer abstracted behind src/hook/useDatabase.ts hook

Decision log

Storage layer options considered:

Settled on cr-sqlite for the following reasons:

  • Data is stored in SQLite which means the app can leverage the full power of SQL
  • Supported React out of the box
  • CRDT implemented at database level
  • Data can be exported easily as IndexedDB export if needed
  • Data can be later synced to other devices via relay server

Acknowledgements