Skip to main content

Feature: Database Schema

Version: 1.3.1 Last Reviewed: 2026-02-12 Status: Approved

User Story​

As a developer, I have a well-designed database schema that supports all MVP features with proper indexes, constraints, and audit trails so the application is performant and data integrity is guaranteed.

Overview​

PostgreSQL 16 with Prisma 7.x ORM (Driver Adapters via @prisma/adapter-pg). The schema is designed around the core entities: users, children, and four tracking types (feedings, diapers, sleep, notes). Multi-caregiver access is modeled via a join table. All changes are logged to an append-only audit table.

Entity Relationship Diagram​

User ──< Session
User ──< ApiToken
User ──< ChildAccess >── Child
User ──< AuditLog
Child ──< Feeding
Child ──< Diaper
Child ──< Sleep
Child ──< Note
Child ──< ShareLink

Models​

User​

Core identity. Name + email + password auth.

ColumnTypeConstraintsNotes
idUUIDPK, auto-generated
emailVARCHARUNIQUE, NOT NULLLowercase, trimmed
nameVARCHAR(100)NOT NULLDisplay name, trimmed. 1-100 chars. Shown in access lists, timeline, multi-caregiver UI.
passwordVARCHARNOT NULLbcrypt hash (12 rounds)
timezoneVARCHARNOT NULL, default "America/New_York"IANA timezone string, used for "today" queries
day_start_timeTIMENOT NULL, default 07:00PostgreSQL TIME type (no date component). Configurable day boundary for dashboard/timeline "today" calculations.
created_atTIMESTAMPTZNOT NULL, auto
updated_atTIMESTAMPTZNOT NULL, auto

Session​

Web/app login sessions. Token-based.

ColumnTypeConstraintsNotes
idUUIDPK
user_idUUIDFK → users, CASCADE
tokenVARCHARUNIQUE, NOT NULLcrypto.randomBytes(32).hex
expires_atTIMESTAMPTZNOT NULL30 days from last use (rolling)
last_verified_atTIMESTAMPTZNOT NULLUpdated hourly on authenticated requests
created_atTIMESTAMPTZNOT NULL, auto

Indexes: token (lookup on every authenticated request)

Multiple Sessions: No unique constraint on user_id — a user can have multiple active sessions (e.g., iPhone + iPad). Each login creates a new session. Logout invalidates only that session. Password change invalidates ALL other sessions (see user-profile.md). Expired sessions (>30 days since last_verified_at) are cleaned up lazily — deleted when encountered during auth checks.

ApiToken​

Long-lived tokens for programmatic access (Home Assistant, scripts).

ColumnTypeConstraintsNotes
idUUIDPK
user_idUUIDFK → users, CASCADE
tokenVARCHARUNIQUE, NOT NULLSHA-256 hash of the plain token
nameVARCHARNOT NULL, default "API Token"User-provided label
last_usedTIMESTAMPTZNULLUpdated on each use
created_atTIMESTAMPTZNOT NULL, auto

Indexes: token (lookup on every authenticated request)

Child​

Baby profile. All tracking data hangs off this.

ColumnTypeConstraintsNotes
idUUIDPK
nameVARCHARNOT NULL
date_of_birthDATENOT NULLDate only, no time component
created_atTIMESTAMPTZNOT NULL, auto
updated_atTIMESTAMPTZNOT NULL, auto

ChildAccess​

Join table for multi-caregiver access. Determines who can see/edit a child's data.

ColumnTypeConstraintsNotes
idUUIDPK
child_idUUIDFK → children, CASCADE
user_idUUIDFK → users, CASCADE
roleVARCHARNOT NULL, default "owner""owner" or "caregiver"
granted_atTIMESTAMPTZNOT NULL, auto

Unique constraint: (child_id, user_id) - one access record per user per child

Invitation tokens for sharing child access.

ColumnTypeConstraintsNotes
idUUIDPK
child_idUUIDFK → children, CASCADE
tokenVARCHARUNIQUE, NOT NULLcrypto.randomBytes(32).hex
created_byUUIDNOT NULLUser who generated the link
used_atTIMESTAMPTZNULLWhen the link was accepted
used_byUUIDNULLUser who accepted the link
created_atTIMESTAMPTZNOT NULL, auto

Indexes: token (lookup when accepting a share link)

Feeding​

Breast or bottle feeding events.

ColumnTypeConstraintsNotes
idUUIDPK
child_idUUIDFK → children, CASCADE
timestampTIMESTAMPTZNOT NULLWhen the feeding occurred
typeVARCHARNOT NULL"breast" or "bottle"
started_sideVARCHARNULL"left" or "right" (breast only - which side feeding began on)
both_sidesBOOLEANNOT NULL, default falseTrue when fed from both sides (breast only)
duration_minutesINTNULLOptional, manual entry. Max 180.
amount_mlFLOATNULLStored in mL, displayed as oz (1 oz ≈ 29.5735 mL). Max 500. Bottle only.
created_byUUIDNOT NULL
created_atTIMESTAMPTZNOT NULL, auto
updated_byUUIDNOT NULL
updated_atTIMESTAMPTZNOT NULL, auto

Indexes: (child_id, timestamp) - chronological queries per child Constraints: Application-level: started_side required when type=breast, amount_ml only when type=bottle. Max: amount_ml ≤ 500, duration_minutes ≤ 180.

Diaper​

Diaper change events.

ColumnTypeConstraintsNotes
idUUIDPK
child_idUUIDFK → children, CASCADE
timestampTIMESTAMPTZNOT NULL
typeVARCHARNOT NULL"pee", "poop", or "both"
created_byUUIDNOT NULL
created_atTIMESTAMPTZNOT NULL, auto
updated_byUUIDNOT NULL
updated_atTIMESTAMPTZNOT NULL, auto

Indexes: (child_id, timestamp)

Sleep​

Sleep session events with start and end times.

ColumnTypeConstraintsNotes
idUUIDPK
child_idUUIDFK → children, CASCADE
start_timeTIMESTAMPTZNOT NULL
end_timeTIMESTAMPTZNULLNULL = active sleep timer running. Must be after start_time when set.
duration_minutesINTNULLCalculated when end_time is set: Math.round((end - start) / 60000)
created_byUUIDNOT NULL
created_atTIMESTAMPTZNOT NULL, auto
updated_byUUIDNOT NULL
updated_atTIMESTAMPTZNOT NULL, auto

Indexes: (child_id, start_time) Constraints: Application-level: end_time > start_time

Note​

Free-form catch-all for events not yet supported as dedicated features.

ColumnTypeConstraintsNotes
idUUIDPK
child_idUUIDFK → children, CASCADE
timestampTIMESTAMPTZNOT NULL
categoryVARCHARNOT NULL"pumping", "health", "medication", "other"
textTEXTNOT NULLFree-form content
created_byUUIDNOT NULL
created_atTIMESTAMPTZNOT NULL, auto
updated_byUUIDNOT NULL
updated_atTIMESTAMPTZNOT NULL, auto

Indexes: (child_id, timestamp), (child_id, category)

AuditLog​

Append-only log of every data change for debugging and accountability.

ColumnTypeConstraintsNotes
idUUIDPK
entity_typeVARCHARNOT NULL"feeding", "diaper", "sleep", "note", "child", "user", "session", "api_token", "share_link", "child_access"
entity_idUUIDNOT NULLID of the changed entity
actionVARCHARNOT NULL"create", "update", "delete"
changesJSONBNOT NULLBefore/after snapshot or full entity on create/delete
user_idUUIDFK → users, CASCADEWho made the change
timestampTIMESTAMPTZNOT NULL, auto

Indexes: (entity_type, entity_id), user_id

changes JSONB Structure:

  • CREATE: Full snapshot of the new entity (all fields except id, created_at, updated_at)
  • UPDATE: { "before": { ...changed fields only }, "after": { ...changed fields only } } — only fields that actually changed, not the full entity
  • DELETE: Full snapshot of the deleted entity (for recovery reference)
  • Sensitive fields NEVER stored: Passwords, tokens, hashes are redacted. For password changes: { "before": { "password": "[redacted]" }, "after": { "password": "[redacted]" } }
  • Auth events: Login/logout/register are logged as session create/delete with changes: { "user_id": "uuid" } (no token values)

Design Decisions​

  1. UUIDs everywhere: Prevents ID enumeration attacks. Slightly larger than integers but acceptable for this scale.
  2. TIMESTAMPTZ for all times: Everything stored in UTC. Client timezone handled at query time via AT TIME ZONE.
  3. Audit fields on tracking entities: created_by, created_at, updated_by, updated_at on feedings/diapers/sleep/notes. Separate AuditLog table for change history.
  4. No soft deletes: Hard deletes with AuditLog capturing the deleted entity. Keeps queries simple.
  5. Cascade deletes on child: Deleting a child removes all tracking data. AuditLog entries remain (user FK, not child FK).
  6. ChildAccess join table: Decouples user-child relationship. Supports multiple caregivers per child and multiple children per user.
  7. Share links are single-use: Once accepted, the link is marked used. Generate a new one for another caregiver.
  8. API token hashing: Store SHA-256 hash, not plain token. Plain token shown only once on creation.
  9. duration_minutes on Sleep is denormalized: Could be calculated from start/end, but storing it avoids repeated computation in dashboard/timeline queries. NULL when sleep timer is active (end_time is NULL).
  10. Configurable day boundary: User.day_start_time (default "07:00") defines when "today" starts for dashboard/timeline. A 10PM-6AM sleep stays as one record attributed to the day whose boundary contains the start_time. Never split sleep records at boundaries.
  11. Feeding amounts in mL: Stored as Float in mL for precision. Max 500 mL (~17 oz). iOS converts to oz for display (1 oz ≈ 29.5735 mL). Avoids rounding artifacts when converting back.
  12. Feeding side tracking: started_side (left/right) tracks which side the feeding began on. both_sides (boolean) indicates if both sides were used. This enables reliable side alternation suggestions: always suggest the opposite of started_side, regardless of whether both sides were used.
  13. Nullable Sleep.end_time: Supports two-step timer flow (start sleep → stop sleep). Active sleep sessions have end_time=NULL and duration_minutes=NULL.
  14. User.name required: Display name for multi-caregiver UX. Shows "Johnny" instead of "parent@example.com" in access lists, timeline entries, and audit displays. Required at registration, 1-100 chars, trimmed.

Migration Strategy​

  • Initial migration creates all tables in one migration file
  • Future schema changes get their own migration files
  • prisma migrate dev for local development
  • prisma migrate deploy in production (via deploy.sh)

Acceptance Criteria​

  • All tables created via Prisma migration
  • All indexes present (verified via \d+ tablename in psql)
  • Foreign key constraints enforce referential integrity
  • Unique constraints prevent duplicate ChildAccess records
  • Cascade delete on child removes all related tracking data
  • AuditLog entries survive child deletion
  • Schema matches this spec exactly

Test Cases​

  1. Migration runs cleanly: prisma migrate dev on empty DB succeeds
  2. Cascade delete: Create child with feedings/diapers/sleep/notes, delete child, verify all removed
  3. Unique constraint: Attempt duplicate ChildAccess (same child+user), verify error
  4. Index verification: Query plan for WHERE child_id = X AND timestamp > Y uses index scan
  5. Timezone handling: Insert TIMESTAMPTZ, query with different timezone, verify correct conversion

Prisma Implementation Notes​

  • VARCHAR(100) fields: User.name and Child.name must use @db.VarChar(100) in Prisma schema. Default String maps to TEXT (unlimited), which won't enforce the 100-char limit at DB level.
  • UNIQUE columns already create indexes: PostgreSQL creates an implicit index for every UNIQUE constraint. The token columns on Session, ApiToken, and ShareLink have UNIQUE constraints, so explicit @@index([token]) is NOT needed (would be redundant). The "Indexes" notes in the model definitions above refer to the implicit indexes from UNIQUE, not additional explicit indexes.
  • TIME type: day_start_time uses @db.Time(0) in Prisma for PostgreSQL TIME without timezone.

Connection Pool​

  • Prisma's default pool size is num_physical_cpus * 2 + 1 (typically 5-9 on an LXC container).
  • When all connections are in use, new queries queue internally with a 10-second timeout (configurable via ?connection_limit=N&pool_timeout=N in DATABASE_URL).
  • On timeout, Prisma throws P2024 error — the API returns 500, no crash.
  • For MVP (2-3 concurrent users, single API server), the default pool is more than sufficient.
  • Post-MVP: if scaling, configure explicitly in DATABASE_URL: ?connection_limit=10&pool_timeout=15.

Boundaries​

  • Schema supports MVP features only. Post-MVP features (growth tracking, custom categories) will require migrations.
  • No database-level enum types - using VARCHAR with application-level validation for flexibility.
  • No triggers or stored procedures - all logic in application layer.