Skip to content

Database Schema & Migrations ​

Last updated: 2026-09-01

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

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 2/3: Owner-Write, 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
exercise_entry_lapsLap split intervals for workouts
exercise_entry_hr_zonesHeart-rate time-in-zone splits per workout
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, and circumference measurements
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, and temperature 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)
mood_entriesLogged mood and energy levels
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_symptomsCustom tracked health symptoms
symptom_entriesLogs of daily tracked symptom severity

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)
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, and chart display 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_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
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)
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
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 current database version on startup
  2. Applies pending migrations in order
  3. Tracks applied migrations in the migrations table
  4. 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 an automated sync PR; never edit it by hand

  4. Apply RLS policies (these are reapplied automatically on startup from db/rls_policies.sql)

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
  • Use transactions to ensure atomic migrations
  • Test migrations on development data first

Transaction Management ​

sql
BEGIN;
-- All migration statements here
-- If any statement fails, entire migration rolls back
COMMIT;

Index Creation ​

sql
-- Create indexes concurrently to avoid blocking
CREATE INDEX CONCURRENTLY idx_food_entries_user_date 
ON food_entries(user_id, created_at);

Data Migration ​

sql
BEGIN;
-- 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;

COMMIT;

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 migrations ORDER BY applied_at;

-- Manually mark migration as applied (if needed)
INSERT INTO migrations (version, applied_at) 
VALUES ('20240315142000', NOW());

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.