Skip to content

Database Schema & Migrations ​

Last updated: 2026-10-20

This document covers the database structure, schema design, and migration process for SparkyFitness.

Note:

  • For authoritative table definitions, see db_schema_backup.sql (SQL source) or shared/src/schemas/database/ (TypeScript Zod schemas).
  • For security tier classifications and permission mappings, see Database Security Tiers.

Database Overview ​

SparkyFitness uses PostgreSQL 15+ with Row Level Security (RLS) to ensure data isolation between users. The database follows a normalized design with clear relationships between entities.


Schema Design Principles ​

Naming Conventions ​

  • Tables: Snake case, plural (e.g., food_entries, user_preferences)
  • Columns: Snake case (e.g., created_at, user_id, total_calories)
  • Foreign Keys: {table_name}_id format (e.g., user_id, food_item_id)
  • Indexes: Descriptive names (e.g., idx_food_entries_user_date, idx_measurements_user_type)

Standard Fields ​

All tables include these audit fields:

  • id - UUID primary key using gen_random_uuid()
  • created_at - Timestamp with timezone, defaults to NOW()
  • updated_at - Timestamp with timezone, updated via triggers

Row Level Security (RLS) ​

Every user-specific table has RLS policies to enforce data isolation. PostgreSQL context functions (initialized via public.set_app_context(userId, authenticatedUserId) on every connection) control access:

sql
-- Example: diary logs are readable by delegates with diary read access and
-- writable by delegates with can_manage_diary. rls_policies.sql usually applies
-- this via the create_diary_policy('food_entries') generator, which emits:
ALTER TABLE food_entries ENABLE ROW LEVEL SECURITY;
CREATE POLICY select_policy ON public.food_entries FOR SELECT TO PUBLIC
  USING (has_diary_read_access(user_id));
CREATE POLICY modify_policy ON public.food_entries FOR ALL TO PUBLIC
  USING (has_diary_access(user_id))
  WITH CHECK (has_diary_access(user_id));

Key functions:

  • current_user_id() — active profile context (may differ during family delegation; from app.user_id)
  • authenticated_user_id() — the true logged-in actor (never changes; from app.authenticated_user_id)
  • can_access_user_data(target_user_id, permission_type, authenticated_user_id()) — resolves a logical permission (diary, checkin, medications, reports, plus *_read variants) against the family_access grant. Domain shortcuts wrap it: has_diary_read_access, has_diary_access, has_checkin_read_access, has_medication_access, has_family_access.

Cycle and pregnancy tables are owner-only (no delegation). See SparkyFitnessServer/db/rls_policies.sql for the complete policy set and the create_*_policy generators.


Table Index ​

Quick reference of all tables by domain and purpose. For detailed security tier, permission type, and access rules, see Database Security Tiers.

Authentication & Identity (Tier 1: Owner-Only) ​

TablePurpose
userAccount identity, password hash, email
sessionActive authentication sessions
api_keyUser-generated API keys for external access
passkeyPasswordless login credentials
two_factor2FA recovery codes and secrets
verificationEmail verification tokens
accountAuth credentials and email accounts

MCP OAuth (System-Only) ​

TablePurpose
jwksOAuth signing key pairs
oauth_clientRegistered OAuth clients and redirect/authentication metadata
oauth_resourceProtected resources, scopes, token lifetimes, and signing policy
oauth_client_resourceClient-to-resource associations
oauth_refresh_tokenRefresh tokens, rotation state, scopes, and session/user links
oauth_access_tokenAccess tokens, scopes, resource grants, and revocation state
oauth_consentUser consent grants
oauth_authorization_revocationPer-user/client OAuth JWT revocation watermarks
oauth_client_assertionClient assertion replay-protection records

These tables are managed by Better Auth and are not exposed through family sharing or profile delegation. Better Auth's Kysely adapter serializes its string[] fields as JSON text, so OAuth list columns are PostgreSQL text values containing JSON arrays rather than native text[] columns.

Food & Nutrition (Tier 2/3: Owner-Write, Delegate-Read/Write) ​

TablePurpose
foodsCustom food items created by user
food_variantsServing size options for foods
food_entriesLogged meals/calories for the day
food_entry_mealsMeal details associated with logged entries
mealsCustom meal templates
meal_foodsIngredients assigned to meals
meal_typesCustom meal type definitions (breakfast, lunch, etc.)
meal_plansWeekly meal planning schedules
meal_plan_templatesReusable meal plan templates (supports multiple active plans per user)
meal_plan_template_assignmentsScheduled meal templates to calendar
meal_plan_assignment_setsSets within assigned meal plans

Exercise & Workouts (Tier 1/2/3: Owner-Only, Owner-Write, or Delegate-Read/Write) ​

TablePurpose
exercisesCustom exercises created by user
exercise_entriesLogged exercises for the day
exercise_preset_entriesLogged workout presets for the day
exercise_entry_setsReps, weights, and sets completed
exercise_entry_activity_detailsHeart rate, distance, activity data
shoesRunning-shoe inventory with retirement mileage; linked from exercise_entries.shoe_id
plate_inventoriesBars and plates a person owns, for the plate calculator (one row per user)
action_tagsNFC tags: a random tag code plus the saved action a tap runs (owner-only)
exercise_entry_lapsLap split intervals for workouts
exercise_entry_hr_zonesHeart-rate time-in-zone splits per workout
exercise_entry_distance_seriesDistance-against-time rows for an indoor entry with no route, behind its pace chart. One row per workout (points is JSONB)
exercise_entry_gps_pointsGPS trackpoints & second-by-second telemetry. One row per workout (the points column holds the whole track as JSONB), not one row per trackpoint
workout_presetsCustom workout/preset templates
workout_preset_exercisesExercises assigned to presets
workout_preset_exercise_setsReps/sets configured in presets
workout_plan_templatesTemplates for weekly and sequential workout schedules
workout_plan_template_assignmentsScheduled workout templates (weekday or ordered sequence)
workout_plan_assignment_setsSets within assigned workout plans
workout_feedbackSession / per-exercise workout feedback (difficulty, pain flag and note) behind adaptive suggestions

Measurements & Health (Tier 1/3: Owner-Only or Delegate-Write) ​

TablePurpose
check_in_measurementsWeight, body composition, BMR, circumference measurements, and resting heart rate
check_in_photosProgress photos
custom_measurementsUser-defined custom measurement types
custom_categoriesUser-defined measurement categories
water_intakeTotal water logged for the day
water_intake_entriesIndividual logged water cups
water_containersConfigured container sizes
sleep_entriesSleep logs (bedtime, wake time)
sleep_entry_stagesSleep stage breakdowns (REM, Deep, Light)
sleep_need_calculationsAI sleep need calculations
daily_sleep_needSleep goals calculated for the day
health_metric_samplesIntraday wearable telemetry — heart rate, HRV, respiration, SpO2, stress, body battery, skin temperature. One JSONB-bucketed row per user/metric/day/provider (not one row per sample)
vitals_entriesBlood pressure, blood glucose, temperature, and pulse logs
daily_health_metricsDaily automated wearable summary, scores, and source-timed total calories

Fasting, Mood, Medications, & Symptoms (Tier 1/3: Owner-Only or Delegate-Write) ​

TablePurpose
fasting_logsFasting timeline logs (start/end fast)
user_fasting_preferencesIntermittent fasting targets, auto-calculation config, and alert timing
mood_entriesLogged mood check-ins, several per day: optional mood, energy and focus ratings, tags and journal notes, with a timestamp and a source (manual, garmin or import)
mindfulness_sessionsLogged mindfulness, meditation, and breathwork sessions
oral_care_sessionsBrushing, flossing, mouthwash, tongue cleaning and custom ("other") oral care sessions, logged in Sparky (manual, timer) or synced from Apple Health; a synced sample upserts on (user_id, provider, external_id). The daily brushing goal is user_preferences.oral_care_daily_target / oral_care_target_seconds
lab_markersA user's own lab markers with optional default reference range (built-in markers are defined in @workspace/shared)
lab_resultsLab results with their own unit and reference range, for built-in or custom markers
lab_reportsOne imported lab report (a PDF or photo read into results); results link to it with report_id, and the report itself is not stored. client_request_id makes a retried import save once
user_custom_moodsUser-defined mood tags (icon/color)
medicationsCustom medication inventory lists
medication_schedulesReminders and schedules for medications
medication_entriesLogs of medications taken
medication_pensTrackers for medication delivery pens
medication_titration_stepsAutomated titration dosage plans
injection_entriesInjection logs (site, time, etc.)
user_custom_symptomsSymptom definitions (template, sections, custom fields)
symptom_entriesSymptom quick logs and episodes (also holds cycle-hub symptoms, source = 'cycle')
user_symptom_optionsPick-list library: locations, qualities, associated symptoms, triggers, relief methods
symptom_entry_treatmentsMedications and relief methods used for a symptom entry, with effectiveness
symptom_entry_photosPhotos attached to a symptom entry
symptom_free_daysExplicit "no symptoms today" markers

Cycle & Pregnancy (Tier 1: Owner-Only) ​

TablePurpose
cyclesMenstrual cycle history records
cycle_settingsCycle hub settings (mode, parameters)
cycle_daily_entriesPer-day cycle logs (flow, BBT, mood, etc.)
cycle_test_entriesOvulation and pregnancy test logs
pregnanciesPregnancy records (due date, status)
run_programsA person's run program, their place in it, its Diary schedule, goal date, adjustments to it and which diary entries its workouts were done as
run_goalsA person's weekly running distance target and the race they are counting down to
custom_run_programsA person's own run or walk plans: a name, the activity and an ordered list of workouts, each a list of timed steps. Starting one copies it into run_programs
fitness_testsA person's running fitness tests (5K time trial, 30-minute run, Cooper test): measurements, whether it was run outdoors or on a treadmill (and the incline), result, the training paces derived from them and the recorded activity it was run as
routesA person's saved routes: name, activity, source (drawn, GPX, activity), distance, elevation gain, a simplified track of points and optional turn cues
pregnancy_kick_sessionsFetal kick-counter sessions
pregnancy_contractionsContraction timer logs
pregnancy_photosBump photo journal
pregnancy_checklist_stateWeekly pregnancy checklist completion
health_appointmentsPrenatal and other health appointments

User Preferences & Settings (Tier 2: Owner-Only Write, Delegate-Read) ​

TablePurpose
profilesUser full name, height, display metrics
user_preferencesUnit, formatting, chart display, and heart rate zone preferences
user_nutrient_display_preferencesNutrient column display preferences
user_meal_visibilitiesVisibility settings for meals
user_goalsActive daily calorie/macro goals
user_custom_nutrientsCustom nutrient definitions
user_nutrient_goal_preferencesPer-user minimum/maximum/target goal direction override per nutrient (predefined or custom)
user_water_containersConfigured container sizes
user_calorie_rule_historyCalorie settings in force from each change on, so past days keep their target
user_dashboard_layoutsRearranged dashboard widget positions
user_medication_display_preferencesGLP-1/Medication display preferences
user_mood_display_preferencesMood picker visibility settings
user_cycle_display_preferencesCycle dashboard tile visibility
user_allergen_preferencesAllergen preferences
user_ignored_updatesRecords of skipped release updates

AI & Chat (Tier 1: Owner-Only) ​

TablePurpose
sparky_chat_historyAI Assistant chat messages and history
food_assistant_preferencesExplicit lasting food preferences, independently editable and forgettable
food_assistant_tasksVersioned food tasks, recoverable recipe ingredients and source evidence
food_assistant_operationsIdempotent task operations and before/after checkpoints
ai_service_settingsUser-defined custom assistant configurations

Admin & System (Tier 1: Admin-Only or Public) ​

TablePurpose
global_settingsApplication feature flags and config
sso_providerActive Single Sign-On providers
oidc_providersOpenID Connect integration settings
external_provider_typesSearch provider configurations (FatSecret, USDA, Canadian Nutrient File, Livsmedelsverket)
external_data_providersConfigured API integration credentials, including personal or global Open Food Facts accounts
openfoodfacts_product_read_rate_limitSingleton lease and cooldown coordinating Open Food Facts product reads across server instances
openfoodfacts_sync_queueDormant revision-aware automatic upload state and retained history; unused by manual contributions
rate_limitSign-in rate limit counters shared across server instances
system.rate_limit_windowsPer-address limits of the server's own routes (middleware/ipRateLimit.ts) shared across server instances
system.scheduled_jobsThe latest tick of each scheduled job and the server instance that ran it (services/scheduledJobs.ts), so each tick runs on one instance
system.bulk_importsProgress of the latest Canadian Nutrient File and Livsmedelsverket import per user (services/bulkImports.ts), so any server instance reports it
system.rls_policy_stateThe last rls_policies.sql apply (utils/applyRlsPolicies.ts), so startup skips an unchanged one
medication_typesMedication categories lookup
medication_route_typesMedication administration routes lookup
medication_schedule_typesMedication scheduling frequencies lookup
admin_activity_logsAdmin action audits
day_classification_cacheDaily summary caching logs

Internal & Shared (Tier 2: Owner-Only or System) ​

TablePurpose
onboarding_dataInitial user onboarding metrics
onboarding_statusUser onboarding status
family_accessSharing rules and delegation credentials
backup_settingsAutomated database backup settings

Provider Credentials (external_data_providers) ​

Integration credentials are stored encrypted, not in the plaintext columns. This trips up most people writing their first provider.

external_data_providers has both plaintext (app_id, app_key) and encrypted (encrypted_app_id / app_id_iv / app_id_tag, and the matching app_key triplet) columns. The plaintext columns are legacy and stay NULL — reading them returns nothing even for a provider that is configured and syncing correctly, which surfaces as a misleading "email is missing" or "API key is missing" error on a row that genuinely has one.

Open Food Facts contributions in the first release use explicit owner-only preview and confirmation endpoints at /api/v2/foods/:foodId/openfoodfacts/preview and /api/v2/foods/:foodId/openfoodfacts/contribute. The server-wide allow_openfoodfacts_contributions gate makes this available; it does not grant publication consent. The authenticated actor must own the food, have an active personal account or eligible global fallback, and confirm the exact data and own photo. A short-lived signed preview binds the food, outgoing fields, sanitized image, product language, provider configuration and public product revision.

Eligibility excludes Open Food Facts and proprietary-provider imports, requires a non-empty name and checksum-valid non-internal GTIN, and only permits a metric-convertible default variant. Photo upload precedes the structured data write. If the photo succeeds but the data result cannot be confirmed, the response reports that partial result without assuming the data was not saved.

Automatic queue functions and openfoodfacts_sync_queue remain dormant for future work. The release disables automatic worker/scheduler execution, installs no food enqueue or preference-backfill triggers, and does not let an old auto_contribute_openfoodfacts preference activate uploads. Saving a product, provider or setting therefore creates no public contribution or backfill.

Product Opener reads from barcode lookup and manual preview/confirmation share the database-coordinated permit in openfoodfacts_product_read_rate_limit. It is held through the response and enforces at least five seconds between product reads across server instances; search endpoints are not throttled by this limiter.

Encryption is AES-GCM via security/encryption.ts, keyed by SPARKY_FITNESS_API_ENCRYPTION_KEY. Each value needs all three of its columns (ciphertext, IV, auth tag) to be decryptable.

Reading a credential ​

Two supported paths, depending on what you need:

  • A single provider's secret, inside an integration. Query the encrypted triplet directly and decrypt() it. See integrations/hevy/hevyService.ts (getHevyApiKey) for the reference shape — note it uses getSystemClient(), since a background sync has no user request context.
  • A user's providers, for the UI or a service. Use externalProviderRepository.getExternalDataProviders(userId). It decrypts on the way out and returns the plaintext on the app_id / app_key properties of the returned object — so those field names are populated even though the columns of the same name are NULL. Failed decryption is logged and yields null rather than throwing, so treat a null credential as "unusable", not "absent".

Writing a credential ​

Never INSERT/UPDATE the plaintext columns. Encrypt first and write the triplet — externalProviderRepository handles this for the standard create/update paths.

What this means for a new provider ​

A provider whose required_fields include app_id/app_key is storing a user's real third-party credential. Where that credential is an account password rather than a revocable API key, note that the encryption is necessarily reversible: the server must recover the plaintext to log in, so a database dump plus SPARKY_FITNESS_API_ENCRYPTION_KEY yields working credentials. Prefer providers that issue scoped, revocable API keys, and set is_strictly_private = TRUE on any provider type holding a personal credential so it is never exposed through family sharing.


Database Migrations ​

SparkyFitness uses a custom migration system that runs automatically on server startup.

Migration Process ​

The migration system:

  1. Checks which migrations are applied on startup
  2. Applies pending migrations in order, each file in one transaction together with its record in system.schema_migrations
  3. Logs migration results for debugging

Migration Structure ​

Migrations are stored in SparkyFitnessServer/db/migrations/ with the naming pattern:

YYYYMMDDHHMMSS_description.sql

Example: 20240315103000_add_exercise_tracking.sql

Creating a New Migration ​

  1. Create the migration file in the migrations directory:

    bash
    cd SparkyFitnessServer/db/migrations/
    touch 20240315142000_add_meal_planning.sql
  2. Write the migration SQL — see the migration checklist in the repo's agent-docs folder (for developers and AI tools)

  3. db_schema_backup.sql updates itself — after merge, CI regenerates it from the migrations and opens a sync PR that merges itself; never edit it by hand

  4. Apply RLS policies (startup applies db/rls_policies.sql again whenever the file or the policies in the database changed; it records what it applied in system.rls_policy_state)

Migration Best Practices ​

Backwards Compatibility ​

  • Add columns with default values to avoid breaking existing code
  • Create new tables rather than modifying existing ones when possible
  • Test migrations on development data first

Transaction Management ​

Each migration file runs in one transaction with its system.schema_migrations record, so if any statement fails, nothing in the file is applied or recorded. A file therefore contains no BEGIN;/COMMIT; of its own; a test fails if it does. To commit steps separately, such as adding a constraint NOT VALID and validating it afterwards, write two migration files.

Index Creation ​

sql
-- Plain CREATE INDEX: CONCURRENTLY cannot run inside a transaction
CREATE INDEX IF NOT EXISTS idx_food_entries_user_date
ON food_entries(user_id, created_at);

Statements that cannot run inside a transaction block, such as CREATE INDEX CONCURRENTLY or VACUUM, are not supported in migrations; startup stops with an error naming the file.

Data Migration ​

sql
-- Create new table
CREATE TABLE new_table (id UUID PRIMARY KEY, ...);

-- Migrate existing data
INSERT INTO new_table (...)
SELECT ... FROM old_table;

-- Drop old table (after verifying migration)
-- DROP TABLE old_table;

Rollback Strategy ​

While not automated, rollback migrations can be created:

  1. Document rollback steps in migration comments
  2. Create rollback scripts for complex migrations
  3. Test rollback procedures in development
  4. Backup database before applying production migrations

Troubleshooting Migrations ​

Migration Fails ​

  1. Check migration logs in application startup
  2. Verify database connection and permissions
  3. Check for syntax errors in migration SQL
  4. Ensure migration dependencies are met

Migration Tracking Issues ​

sql
-- Check applied migrations
SELECT * FROM system.schema_migrations ORDER BY applied_at;

-- Manually mark migration as applied, only after confirming every change in
-- the file is already in the database; startup never runs a recorded file
INSERT INTO system.schema_migrations (name)
VALUES ('20240315142000_add_meal_planning.sql');

Database State Issues ​

sql
-- Check table structure
\d table_name

-- Check RLS policies
SELECT * FROM pg_policies WHERE tablename = 'table_name';

-- Check indexes
SELECT indexname, indexdef FROM pg_indexes 
WHERE tablename = 'table_name';

Database Maintenance ​

Performance Monitoring ​

  • Query performance: Use EXPLAIN ANALYZE for slow queries
  • Index usage: Monitor index usage with pg_stat_user_indexes
  • Connection monitoring: Track connection pool usage

Regular Maintenance ​

  • VACUUM: Regular vacuuming for performance
  • ANALYZE: Update table statistics
  • Index maintenance: Rebuild indexes if needed
  • Log rotation: Rotate and archive database logs

Released under the GPL-3.0 License.