Comma
Architecture

Database

The phone app stores all data in a local SQLite database, defined with Drizzle ORM and reached only through query helpers.

Schema: src/database/schema.ts (the source of truth) Queries: src/database/queries/ Client and migrations: src/database/client.ts

Every column below is taken from the schema. Eleven tables carry the two sync columns and participate in cloud sync; four (settings, locationPoints, tempNativePoints, syncOverwriteLog) are deliberately device-local.

shiftsshiftPlatformsexpensesmerchantsvehiclesmaintenanceLogsvehicleTaxProfilesgoalsplatformstaxHistoryprofile
The eleven synced tables. Each row carries syncUpdatedAt (the last-write-wins clock) and syncDeletedAt (the tombstone) — every read filters the tombstones out.

shifts

One row per shift.

ColumnTypeNotes
idtext (PK)
vehicleIdtext (FK → vehicles)Nullable
platformtextPrimary platform id (comma-joined for multi-platform shifts)
startTimetimestamp
endTimetimestamp
grossRevenuerealBase pay, default 0
tipsRevenuerealTips, default 0
bonusAmountrealBonuses and promotions, default 0
trackedMileagerealDeprecated — superseded by activeMileage, kept for backward compatibility
deadMileagerealGPS distance while not on a delivery, default 0
activeMileagerealGPS delivery distance, default 0
durationSecondsintegerTotal elapsed shift time, default 0
pausedSecondsintegerPaused time; net active time = durationSecondspausedSeconds
notestextNullable
routePathtextEncoded route (JSON array of simplified lat/lng/timestamp points)
reconciliationStatustexttracking | pending_reconciliation | reconciled (default reconciled)
startOdometerintegerOptional manual odometer reading
endOdometerintegerOptional manual odometer reading
distanceSourcetextgps_only (default) | odometer | manual
syncUpdatedAtintegerSync clock
syncDeletedAtintegerTombstone

shiftPlatforms

Per-platform sub-record for a shift run across more than one platform. Cascades on shift delete.

ColumnTypeNotes
idtext (PK)
shiftIdtext (FK → shifts, cascade)
platformtextPlatform id
platformOnlineSecondsintegerOnline time on this platform, default 0
platformActiveSecondsintegerOn-delivery time on this platform, default 0
grossRevenuerealEarnings from this platform, default 0
tipsRevenuerealTips from this platform, default 0
tripsCountintegerDeliveries, default 0
syncUpdatedAtinteger
syncDeletedAtinteger

expenses

ColumnTypeNotes
idtext (PK)
shiftIdtext (FK → shifts)Optional link to a shift
categorytextExpense category key
amountrealTotal cost
datetimestamp
isDeductiblebooleanDefault true
deductiblePctreal0–100; deductible amount = amount × deductiblePct / 100 (default 100)
vehicleIdtext (FK → vehicles)Optional
notestextNullable
receiptUritextLocal file URI for a receipt photo
isRecurringbooleanDefault false
recurringIntervaltextweekly | monthly | yearly
merchanttextDisplay name, default empty
merchantNormalizedtextNormalized for grouping, default empty
syncUpdatedAtinteger
syncDeletedAtinteger

merchants

Normalized merchant names for expense grouping.

ColumnTypeNotes
idtext (PK)
nametext (unique)Display name
normalizedNametextLowercased, stripped for matching
syncUpdatedAtinteger
syncDeletedAtinteger

vehicles

ColumnTypeNotes
idtext (PK)
nametextDisplay name
typetextVehicle type key, e.g. car, scooter, ebike
isActivebooleanDefault true
createdAttimestamp
maketextOptional
modeltextOptional
yearintegerOptional
fuelTypetextgas | electric | hybrid | other
licensePlatetextOptional
currentOdometerintegerRunning total, default 0
syncUpdatedAtinteger
syncDeletedAtinteger

maintenanceLogs

ColumnTypeNotes
idtext (PK)
vehicleIdtext (FK → vehicles)
typetextoil_change | tire | brake | fuel | wash | other
costreal
odometerrealOptional reading at time of service
datetimestamp
notestext
syncUpdatedAtinteger
syncDeletedAtinteger

vehicleTaxProfiles

Per-vehicle, per-year tax method. Cascades on vehicle delete.

ColumnTypeNotes
idtext (PK)
vehicleIdtext (FK → vehicles, cascade)
taxYearintegere.g. 2026
countrytextCountry id
deductionMethodtextstandard_mileage | actual_expenses
standardRatePrimaryrealFirst-tier per-distance rate
standardRateSecondaryrealSecond-tier rate (e.g. Canada above the km threshold)
rateThresholdrealDistance at which the rate steps down
beginningYearOdometerintegerReading at the start of the year
endingYearOdometerintegerReading at the end of the year
syncUpdatedAtinteger
syncDeletedAtinteger

goals

ColumnTypeNotes
idtext (PK)
labeltext
targetValuereal
unittextcurrency | hours | shifts | mileage
periodtextdaily | weekly | monthly | yearly
isActivebooleanDefault true
createdAttimestamp
syncUpdatedAtinteger
syncDeletedAtinteger

platforms

The user's platform configuration. Comma ships Canada only, so country holds CA in practice, though the column can carry other codes.

ColumnTypeNotes
idtext (PK)Platform key, e.g. doordash
labeltextDisplay name
colortextHex color
textColortextText color on a badge
countrytextCountry id
isActivebooleanUser has activated it (default false)
hourlyRatetextTarget hourly rate (default 20)
mileageRatetextPer-distance rate (default 0.62)
sortPriorityintegerOrder in pickers (default 1)
logoEmojitextOptional
syncUpdatedAtinteger
syncDeletedAtinteger

taxHistory

Append-only log of tax region and rate changes.

ColumnTypeNotes
idtext (PK)
oldRegiontextPrevious region
oldRaterealPrevious rate
newRegiontextNew region
newRaterealNew rate
changedAttimestamp
syncUpdatedAtinteger
syncDeletedAtinteger

profile

A synced key-value table: one row per key, each carrying the sync columns, so the record-level engine gives per-key last-write-wins for free. This is the profile data that travels with the user — name, country, province, units, currency, goals, withholding rate, onboarding-complete — which is why signing a fresh device into sync brings it up already configured. Values are JSON-encoded. Both apps bridge their local profile storage into and out of this table around each sync.

ColumnTypeNotes
keytext (PK)Profile key
valuetextJSON-encoded value
syncUpdatedAtinteger
syncDeletedAtinteger

settings (device-local)

A key-value store for configuration that stays on the device and does not sync: sync cursors, the demo flag, the active-shift snapshot, and scratch. No sync columns.

ColumnTypeNotes
keytext (PK)
valuetextJSON-encoded value

Example keys: onboarding_completed, profile, app_config, demo_mode, active_platform_filter, preferred_vehicle_id, active_shift_state, shift_templates.


locationPoints (device-local)

Filtered GPS points from a tracked shift, for route replay and recalculation. Not synced.

ColumnTypeNotes
idtext (PK)
sessionIdtextShift session identifier
shiftIdtext (FK → shifts)
latitudereal
longitudereal
altitudereal
accuracyrealHorizontal accuracy (meters)
speedrealm/s at the point
timestamptimestamp
sourcetextgps (default)
isFilteredbooleanTrue = discarded by the jitter filter

tempNativePoints (device-local)

Staging table the native Kotlin module writes raw GPS points into; useActiveShift reads it on shift end. Not synced.

ColumnTypeNotes
idinteger (PK, autoincrement)
latreal
lonreal
timestampintegerepoch ms

syncOverwriteLog (device-local)

Append-only recovery log for the sync merge engine. When a last-write-wins merge overwrites a financial row (expenses, taxHistory, shifts, shiftPlatforms) that had real local edits, the superseded version is recorded here first, so a number changed on another device is never lost silently. Deliberately has no sync columns and is not itself synced.

ColumnTypeNotes
idtext (PK)
tableNametextTable that had the conflict
rowIdtextThe row's primary key
supersededRowtextJSON of the local row that lost
winnerRowtextJSON of the incoming row that won
mergedAtintegerepoch ms of the merge

Sync columns

Every synced table carries two columns, spread from a shared definition:

syncUpdatedAt: integer // epoch ms of the last local mutation — the last-write-wins clock (default 0)
syncDeletedAt: integer // epoch ms of a soft delete, else null — the tombstone

A delete is a soft delete: the row is stamped with syncDeletedAt and kept, so the deletion can propagate to other devices instead of being resurrected by them. Every read filters WHERE syncDeletedAt IS NULL; every write touches syncUpdatedAt. The default of 0 means any pre-sync row is treated as oldest, so an incoming change wins until that row is next edited locally.

The eleven synced tables are: shifts, shiftPlatforms, expenses, merchants, vehicles, maintenanceLogs, vehicleTaxProfiles, goals, platforms, taxHistory, and profile.

See Cloud Sync for the merge design.


Conventions

  • All queries live in src/database/queries/, one file per domain. No raw SQL in screens or hooks.
  • No JavaScript-level filtering of data that a WHERE clause could filter.
  • Mutations go through syncedInsert / syncedUpdate / syncedDelete (src/database/syncedWrites.ts) so sync columns are stamped automatically. Direct writes are only acceptable on the tables that are not synced.

On this page