LiftLog persists on-device data in two places. Which one you use depends on what kind of data it is:
| Preferences | User data | |
|---|---|---|
| Written through | PreferenceService |
Drizzle ORM (db) |
| Backed by | One file per key in the app document directory (KeyValueStore) |
SQLite (db.db, via expo-sqlite) |
| Holds | Settings and small scalars the user toggles | Sessions, programs, exercises, feed state |
| Shape changes handled by | Hand-written defaults in the getter | Drizzle SQL migrations + JSON payload migrations (see Migrations.md) |
| Injected as | extra.preferenceService |
extra.db |
Both are built in app/src/services/index.ts (createServices) and are reachable from any Redux effect
via the extra bag:
addEffect(
setUseImperialUnits,
async (action, { stateAfterReduce, extra: { preferenceService } }) => {
if (stateAfterReduce.settings.isHydrated) {
await preferenceService.setUseImperialUnits(action.payload);
}
},
);Redux is the source of truth at runtime. Storage is written from effects, never from components or reducers: a component dispatches an action, the reducer updates state synchronously, and an effect mirrors the change to disk.
app/src/services/preference-service.ts wraps KeyValueStore
(app/src/services/key-value-store.ts), which stores each key as its own file under Paths.document.
Writes go to a temp file and are then moved over the target, so a crash mid-write can't leave a
half-written (or worse, half-overwritten) value.
Everything in the store is a string (or Uint8Array). Preferences are described declaratively in a
registry (app/src/store/settings/registry.ts); each entry pairs a default with a codec
(app/src/store/settings/codecs.ts) that owns the on-disk encoding:
restTimersEnabled: pref({ default: true, codec: boolCodec }), // 'True' / 'False', default true when unsetPreferenceService is a thin facade over the registry: getPreference(key) / setPreference(key, value)
read and write via the codec, and a few bespoke methods remain for keys with special storage
(getPreferredLanguage, the remote-backup cluster).
The add-setting-or-preference skill walks this end to end. In short: add one entry to
preferenceRegistry ({ default, codec }), then re-export the generated set<Name> action from
app/src/store/settings/index.ts and add the settings UI. The state field, default, action, hydration,
and isHydrated-guarded write-back are all derived from the registry entry - no PreferenceService
method or per-key effect. Keys with special needs use the persist: false / hydrate: 'manual' /
sync escape hatches on the descriptor.
getItemSync / getPreferredLanguage exist for the handful of values needed before the store exists
(language, for Tolgee setup). Prefer the async path everywhere else.
A few non-settings blobs skip PreferenceService and use extra.keyValueStore directly - the hidden
built-in exercise id list, the "built-in programs seeded" marker. That's the escape hatch for a value
that isn't a user-facing setting but is too small or too structurally awkward for a table. New
settings should go through PreferenceService.
The in-progress workout used to live here too, under CurrentSessionStateV1. It is now a row in the
session table like any other, flagged active;
store/stored-sessions/legacy-current-session.ts lifts a leftover blob into the table on first launch
and then deletes the keys.
Schema lives in app/src/db/schema.ts; generated SQL migrations in app/src/drizzle/. The database is
opened in components/smart/services-provider.tsx (openDatabaseAsync('db.db') → drizzle(expoDb))
and passed into createStore / createServices, so effects get it as extra.db (and the raw handle as
extra.expoDb, needed for backup/export).
Foreign keys are enforced from the end of DatabaseMigrationService.migrate(). The pragma is per
connection and is a no-op inside a transaction, and drizzle runs each batch of migrations in one, so
turning it on earlier would let a migration that rebuilds a parent table cascade-delete its children.
Most tables are the same shape - a text id primary key plus a payload JSON column typed with the
model's AnyVersion…JSON union:
export const programsSchema = sqliteTable("program", {
id: text().primaryKey(),
payload: text("payload", { mode: "json" }).$type<AnyVersionProgramBlueprintJSON>().notNull(),
});Sessions are the exception: they are relational - see Sessions are relational.
Four mechanisms change what is stored, and all of them matter:
- SQL migrations change tables/columns/indexes. Edit
db/schema.ts, then generate withnpx drizzle-kit generate(config:app/drizzle.config.ts) and commit the new file insrc/drizzle/. - JS migrations (
app/src/services/js-migrations/) move data in JS at a fixed point in the SQL sequence, for a reshape SQL cannot express - see JS migrations. - Data migrations (
app/src/services/data-migrations/, run byDatabaseImportService) bring in data from outside the database: the key-value files older versions stored everything in, and device preferences. They run once per device, after every SQL and JS migration, so they always read and write the latest tables. - Payload migrations change the shape of the JSON inside a row. Those are the versioned model
chains in
app/src/models/storage/versions/- see Migrations.md and theadd-storage-migrationskill. Rows are migrated on read (programBlueprintMigrations.migrate(row.payload)), not in bulk.
DatabaseMigrationService.migrate() runs the first three at startup, in that order. A restored
backup runs SQL and JS migrations too, but not data migrations: the device's key-value files and
preferences are not part of the backup.
A JS migration is { id, after, run }. after is the tag of the SQL migration it follows (the journal
entry in src/drizzle/meta/_journal.json): migrate() runs the SQL migrations up to and including
that one, then the JS migration, then the rest. That ordering lets it read a table that a later SQL
migration drops.
rungets a transaction that also recordsidindata_migration, so it is all-or-nothing and runs once per database. It must be synchronous (see Transactions are synchronous).- It runs on restored backups as well as the live database, so it may only touch the database it is given.
- Pin it to the SQL migration added in the same change. A database already past
afterwould run it against a later schema. - Declare the tables it touches inside the migration file, as they are at
after, rather than importing fromdb/schema.ts. A later column change to the live schema must not change what an old migration reads or writes. - Each one runs on its own drizzle instance over the same connection. drizzle caches column names per table name on each instance, so an old copy of a table would otherwise hide columns added since.
Read on hydration, dispatch into the slice:
const programs = (await db.select().from(programsSchema)).reduce(
toRecord(
(x) => x.id,
(row) => ProgramBlueprint.fromJSON(programBlueprintMigrations.migrate(row.payload)),
),
{},
);
dispatch(setSavedPlans(programs));Write with the upsert helper in app/src/db/helpers.ts, which does an
insert … onConflictDoUpdate on the id - the right call for our id+payload tables, and it takes a
transaction (tx) as well as db:
upsert(db, feedSentReactionsSchema, [{ id: action.payload.id, payload: action.payload.toJSON() }]);Use db.transaction((tx) => …) when several tables must move together. Drizzle's expo driver runs
every query synchronously, and db.transaction() commits as soon as its callback returns, so the
callback must be synchronous: end each statement with .run(), .all() or .get(), and do any async
work (preferences, key-value store, network) before the transaction.
db.transaction((tx) => {
tx.delete(programsSchema).run();
tx.insert(programsSchema).values(rows).run();
});An async callback typechecks but commits before its awaited statements run, so none of them are
covered by the transaction and a failure part way through cannot roll back
(app/src/db/transaction.spec.ts shows both cases). A synchronous transaction cannot interleave with any
other query, so transactions never nest.
Drizzle throws on an insert with no values. Inside a transaction that rolls back everything before it,
so guard a delete-then-reinsert with if (rows.length).
A state change that must also reach the network cannot share a transaction with the request, and sending first or writing first each leaves a gap where the app can die. So the network half is written as a row in the same transaction as the state change, and an effect sends it afterwards. The feed does this both ways (protocol details in FeedProcess.md):
- Outbox (
feed_outbox). Each friend change writes its friend rows and the already-encrypted inbox messages it sends together (commitFriendChangeinstore/feed/friend-effects.ts). ThenflushOutboxsends the rows in order, deletes each one once the server accepts it, and backs off on failure. Because sending can repeat, every message carries an id the receiver dedupes on. - Inbox queue (
feed_inbox_queue,feed_inbox_seen). Polled messages are written to the queue before they are acknowledged to the server. Each queued message is then handled in two parts: any async preparation (fetching a key, encrypting a reply), then one synchronous transaction that writes the resulting state and outbox rows and deletes the queue row. A crash at any point either leaves the message queued or has fully applied it.
Do async work before the transaction, re-check that the state it was based on still holds, and only dispatch to Redux after the transaction commits.
Effects that touch the DB use a real in-memory SQLite rather than a mock - openDatabaseAsync(':memory:'),
drizzle(...), then DatabaseMigrationService.migrate() to build the schema. See
app/src/store/program/effects.spec.ts for the pattern and utils/__test__/add-effect-testbed for
wiring effects to a test store.
app/test/shims/expo-sqlite.ts implements expo-sqlite's synchronous API on Node's built-in
node:sqlite, so tests run drizzle's real expo driver with the device's behaviour: synchronous queries
and transactions, and foreign keys off unless a connection turns them on.
A session is spread over four tables, so its history can be queried in SQL:
| Table | Holds |
|---|---|
session |
name, notes, date (indexed), bodyweight, the active flag, and its summary |
recorded_exercise |
one per exercise, keyed (sessionId, ord), with indexed movementKey and progressionKey, plus its blueprint: scalars as columns, lists as JSON, and its summary |
weighted_set |
one per potential set: target, weight, reps and completion time |
cardio_set |
one per cardio set: its set blueprint as columns, plus each recorded metric |
-
app/src/db/sessions.tsis the only code that touches these tables. It writes withwriteSession,deleteSessionandsetActiveSession, and reads with queries shaped for their callers:- whole sessions by id, by date range, or the active one;
readPreviousExercises(the latest finished performance of eachprogressionKey, leaving out the session on screen) andreadPreviousComparableSessionfor the workout screens, andreadExerciseHistory(every performance of onemovementKey) for the exercise history sheet;readSessionToContinueFromandreadLatestExercises(the most recently completed performance perprogressionKey), whichSessionServicebuilds upcoming sessions from;readStartedSessionVolumesandreadPersonalRecords, which read the whole history's summaries for the activity views. Each one has a spec that checks it against the JS it replaces (sessionVolume,findPersonalRecords) over generated sessions.
It maps through
SessionJSON, sotoJSON/fromJSONstay the source of truth for the shape. -
Effects write synchronously inside the dispatch that changed the store, so a query run from a later effect sees what the store holds.
-
writeSessionrewrites the whole session: it upserts thesessionrow, deletes its children and inserts them again. A new session is stored inactive and an existing one keeps its flag. Never write a session row withINSERT OR REPLACE: it deletes the row first, which cascades to its children. -
Reads that span the whole history use each session's summary rather than its sets, so their cost follows the number of sessions, not sets:
session.volumeKg: kilograms moved across its completed sets.session.lastCompletedAtMs: its latest completed set, weighted or cardio. Null when nothing is completed, so it also marks a started session. Sessions are ordered by it, falling back to the start of their date.recorded_exercise.bestOneRepMaxKg: the exercise's best estimated one-rep max, null when it has no set that counts.
writeSessioncomputes the summary in SQL from theeffective_weighted_setview once the children are written, so it can never disagree with the sets. Anything else that changes a stored set must callwriteSummariesfor the sessions it touched, asMIGRATE_NIL_WEIGHT_UNITSdoes. The0011_session_summarymigration fills it in for sessions stored before it existed, with the same expressions.
Use preferences for a single scalar the user sets and the app reads - a toggle, a colour, a token, a timestamp. Use SQLite for anything the user creates in quantity, anything queried or deleted by id, and anything that needs to survive versioned shape changes.
Startup order is initializeSettingsStateSlice → (once settings are hydrated)
initializeStoredSessionsStateSlice. Preferences are therefore available to DB hydration, but not the
other way round; stored-sessions/effects.ts asserts this explicitly.
The session history lives in SQLite, not in Redux. storedSessions.openSessions holds only the sessions
being edited: the workout in progress, and a past session while it is open in History. Startup loads the
active one and nothing else. activeSessionId points at the live one.
- Editing goes through the store. A screen addresses an open session by id
(
updateStoredSession({ sessionId, update }), mirroringupdateProgram), so two screens editing different sessions cannot collide, and effects write the result through to SQLite.putStoredSessionopens a session and writes it.openSessionopens one that came from the database, without writing it. History opens a session this way before pushing/history/edit.sessionFinishedcloses it again, except the workout in progress, which stays open until it is finished.selectSession(state, id)only finds an open session. To show one that may not be open, useuseSession(id)(hooks/useSession.ts), which falls back to reading it from SQLite.
- Reading history goes to SQLite. Screens read through
useSessionsQuery(read, key)(hooks/useSessionsQuery.ts):- A focused screen reads straight away. A screen that is not focused reads in
requestIdleCallback. Every tab renders at startup, so reading on mount would put the read on the launch path. Waiting for focus would show an empty tab instead, because a native tab switch is on screen before JS hears about it. - It reads again when
keychanges orsessionsRevisionmoves. - Effects call the
db/sessions.tsreads directly.
- A focused screen reads straight away. A screen that is not focused reads in
sessionsRevisionmoves when the set of finished sessions changes: a session finished, deleted, restored from a backup, or a change of active session. It does not move on each recorded set, so a screen mounted behind the workout does not re-read per tap. The effects bump it withsessionsChangedonly after their write has landed.- Whole-history aggregates are summarised, not held. The History and Feed tabs wrap their content in
OwnHistoryProvider. Once per revision it reads one volume per started session and the personal records, both from the stored session summaries, andownHistoryOfturns them into per-day counts and volume, the volume scale and the streak. NoSessionis built. The activity selectors take that summary as an argument and combine it with the feed. putStoredSession/updateStoredSessionmean "this session changed" and only write its content.sessionFinishedmeans "the user is done with it". It is what queues the feed publish, exports to the health aggregator, marks stats dirty, clears the active pointer and closes the session. Keep completion work onsessionFinished, or it fires once per set.
The active column has a single writer, the setActiveSessionId effect (setActiveSession), in a
transaction with a unique partial index (single_active_session) enforcing at most one - the same shape
the program table uses for the active plan. It writes the session's content as well, because it races
the content write it is dispatched alongside.