Skip to main content

Persistence: Drift/SQLite

All data is stored locally in SQLite via Drift. There is no remote database. Rationale:
  • Offline-first by default - no network required, ever
  • Typed query DSL with compile-time safety
  • Codegen for table classes and query results
  • First-class migration support with tested upgrade paths
  • Mature ecosystem and good Flutter integration
Trade-offs noted:
  • No cross-device sync (intentional - user data stays on their device)
  • Full-text search requires FTS5 extension (not needed now)
  • Large event logs may require pagination over time

Migration strategy

Drift uses an integer schemaVersion on the database class. Migrations are defined as a sequence of from/to steps in MigrationStrategy.onUpgrade. Each migration must have a test that creates an in-memory database at the old schema version, seeds it with representative data, runs the migration, and asserts the resulting schema and data are correct. Never ship a migration without a passing test.

Tables

Dart field names are camelCase. Drift maps them to snake_case column names in SQLite automatically (e.g. dueDatetimedue_datetime).

Tasks

Stores the definition of a task - its cost parameters and scheduling rules. manaCost is not stored - it is computed at read time from relativeCost, durationMinutes, personalCoefficient, and axisFactor. See Task costing and budget for the derivation.
FieldTypeNotes
idTEXT (UUID)Primary key
titleTEXTUser-provided label
detailsTEXT?Further (optional) information on this task. Used as fallback when an occurrence has no per-instance details.
relativeCostREALEffort relative to this user’s baseline; canonical replacement for historical netMet.
durationMinutesINTEGERUser-provided or template default
bodyWeightREAL (0-1)Physical vs cognitive split (body component)
mindWeightREAL (0-1)Physical vs cognitive split (mind component)
typeTEXTtemplate or custom
templateRefTEXT?Compendium ID if based on a template; null for fully custom
isEssentialBOOLEANIf true, task remains visible even when over pool.
timingTypeTEXTnone, deadline, scheduled
recurrenceRuleTEXT?RFC 5545 RRule string. Nullable for non-recurring tasks.
recoveryWeightREALDefault 1.0. Multiplier capturing post-exertional cost. Range [0.8, 2.0]. Deviates from 1.0 only after RECOVERY_MIN_OBSERVATIONS.
recoveryObservationCountINTEGERDefault 0. Number of check-in deltas attributed to this task.
createdAtDateTimeRequired

TaskOccurrences

Canonical per-instance schedule/state for a task. One task has many occurrences. For recurring tasks, due slots are generated from recurrenceRule at query time, and persisted rows represent concrete instance state/overrides (completion, deferral, cancellation, details override).
FieldTypeNotes
idINTEGERPrimary key (auto-increment local surrogate ID)
taskIdTEXT (UUID)FK to Tasks
dueDatetimeDateTimeCanonical due date/time for this instance
detailsTEXT?Per-occurrence detail override. If null, UI falls back to parent task’s details
deferredUntilDateTime?Nullable
completedAtDateTime?Nullable
createdAtDateTimeRequired

CheckIns

One row per daily check-in.
FieldTypeNotes
idTEXT (UUID)Primary key
bodyINTEGER (1-5)Physical axis
mindINTEGER (1-5)Cognitive axis
moodINTEGER (1-5)Emotional axis
checkInTierTEXTfull, quick, momentum, none
checkInDateDateTimeLocal calendar day identity, normalized to local midnight
recordedAtDateTimeRequired
dayTypeTEXTnormal, packed, blocked, unusual

Events

Append-only audit log. Events are never deleted. See the Event log section below. An index on (entityId, entityType, occurredAt) is required. All engine queries filter by entity - without this index, replaying history for a given task or check-in degrades linearly with total event count.

Lightweight persistence: shared_preferences

Settings and feature flags are stored via shared_preferences, not in SQLite. Why not SQLite:
  • SQLite is encrypted and wiped on nuclear reset. Settings and flags have no relation to user health data and must not be caught in that wipe.
  • shared_preferences maps directly to NSUserDefaults (iOS) and SharedPreferences (Android) - appropriate for small, non-sensitive key-value data.

What lives here

KeyTypeNotes
onboarding_completeboolSurvives wipe - prevents re-running onboarding unintentionally
notification_enabledboolUser preference
notification_timestringHH:mm, user preference
themestringsystem, light, dark
ff_*stringFeature flag values, prefixed ff_
Feature flags are re-fetched from remote config on launch. Losing them (e.g. on a fresh install) is acceptable - the app falls back to hardcoded defaults until the fetch completes.

What survives a nuclear reset

StorageWiped on reset
SQLite databaseYes
Encryption key (keystore)Yes
shared_preferencesNo
Settings intentionally survive. A user who wipes their data to start fresh should keep their notification preferences and accessibility settings.

Event log

The Events table is an append-only record of everything that has happened. It is the source of truth for all derived state.
FieldTypeNotes
idTEXT (UUID)Primary key
typeEventType enumStored as camelCase string via Drift’s textEnum
entityIdTEXT?ID of the entity this event concerns (task definition, instance, check-in)
entityTypeTEXT?taskDefinition, taskOccurrence, checkIn, system
occurredAtDateTimeMust be monotonically non-decreasing within a session
payloadTEXT (JSON)Fields vary by event type; unknown keys ignored on read

Typed payload

type is a Dart enum (EventType). Each value has a corresponding sealed payload class. Callers switch exhaustively on type to deserialize payload into the correct typed object - the compiler enforces all cases are handled. Current domain event read/write model types:
  • EventRecord for read queries
  • EventWrite for append operations
  • EventPayload for typed payload deserialization/serialization
These are domain model names (not prefixed with Domain).

Event types

TypePayload fieldsDescription
taskCreatedtaskId, title, relativeCost, durationMinutes, bodyWeight, mindWeight, type, timingType, isEssentialNew task added
taskEditedtaskId, changes: {}Field-level diff only
taskRemovedtaskIdTask deleted
taskOccurrenceCreatedoccurrenceId, taskId, dueDatetimeNew occurrence row created
taskOccurrenceCompletedoccurrenceId, manaCostAtCompletionOccurrence marked done; completion snapshot includes manaCostAtCompletion for deterministic replay
taskOccurrenceUncompletedoccurrenceIdCompletion reversed
taskOccurrenceDeferredoccurrenceId, deferredUntilOccurrence snoozed
taskOccurrenceRemovedoccurrenceIdOccurrence removed
taskCostCalibratedtaskId, previousRelativeCost, newRelativeCost, statedCost, axisFactorAtCalibrationUser corrected perceived cost after first completion
taskRecoveryWeightUpdatedtaskId, previousWeight, newWeight, observationCountRecovery EWMA moved the stored weight
checkInRecordedbody, mind, mood, dayType, checkInTierMorning check-in
dayTypeSetdate, dayType, opportunityWeightEnd-of-day day type. Current default mapping: normal=1.0, packed=0.25, blocked=0.0, unusual=0.0.
pemDetectedtriggerMagnitude, recentMagnitude, confidenceBeforePEM event fired
pemResolvedresolutionReasonstateRecovered, crossover, or escapeValve
Constraints:
  • Events are never updated or deleted
  • occurredAt must be monotonically non-decreasing within a session
  • payload is JSON; unknown keys are ignored on read (forward compatibility)