Add a column to a table
A new field on a task touches seven places, from the SQLite file on every phone to the Postgres
mirror. This guide is a checklist in the order that keeps old installs, old backup files and old
clients working. It uses a hypothetical estimateMinutes: Int? on Task as the running
example. Substitute your own field.
Prerequisites
Section titled “Prerequisites”- You’ve read the persistence notes in the database reference. The three rules that matter most are restated below.
- You can run the test suite, and docker for
neon/tests/run.shif the field syncs. - Decide first: is this a column at all? A new recurrence-rule field is not a column. It
extends
RecurrenceCodec’s packedv1;key=value;…string (and itsVERSIONhandling) instead. Device-only state, like window bounds or per-device settings, belongs in a settings store, not in a synced table.
1. Append the column to the table
Section titled “1. Append the column to the table”-
In
core/src/commonMain/sqldelight/.../Task.sq, add the column at the end ofCREATE TABLE taskRow, aftertagIds, with a comment saying which migration adds it:tagIds TEXT,-- Estimated effort in minutes, or null. Appended by `5.sqm` — last for the reason-- `sectionId` explains.estimateMinutes INTEGER); -
In the same file, add the column to the
SETlist of all three full-row updates:updateRow,updateIfOlderandreviveIfDeleted.insertIfAbsentbinds the whole row withVALUES ?, so it needs no edit.
2. Add the migration
Section titled “2. Add the migration”-
Create the next migration file beside the
.sqfiles. The chain today is1.sqmthrough4.sqm(schema version 5), so yours is5.sqm, which migrates 5 → 6:-- 5 → 6: a task's estimate. One appended column, nothing rebuilt.ALTER TABLE taskRow ADD COLUMN estimateMinutes INTEGER;Nothing else is needed to wire it up. SQLDelight derives
CadenceDatabase.Schema.versionfrom the migration files. Android’sAndroidSqliteDriverruns the migration from its upgrade callback. The desktop’sDatabaseDriverFactorycomparesPRAGMA user_versionwith the schema version and migrates.
3. Map it in exactly one place each way
Section titled “3. Map it in exactly one place each way”In core/src/jvmShared/.../data/db/SqlDelightStores.kt:
-
Task.toRow(): domain → column. Every conversion (epoch day, seconds of day, packing) happens here and nowhere else:tagIds = TagIdsCodec.encode(tagIds),estimateMinutes = estimateMinutes?.toLong(),) -
toTask(...): column → domain. Add the parameter last, sinceSELECT *hands the mapper columns in table order:tagIds: String?,estimateMinutes: Long?,) = Task(// …estimateMinutes = estimateMinutes?.toInt(), -
Forward the new row field in the three wrappers that call the named-parameter updates:
TaskQueries.upsertRow(updateRow) andTaskQueries.mergeRow(reviveIfDeletedandupdateIfOlder). These only forwardrow.estimateMinutesuntouched. No conversion belongs in a wrapper’s argument list.
4. Add the field to the domain model
Section titled “4. Add the field to the domain model”- In
domain/model/Task.kt, addval estimateMinutes: Int? = null. A default keeps every existingTask(...)call compiling.
Remember that smart casts don’t cross a module boundary. In :ui, write
task.estimateMinutes?.let { … } or bind a local, not if (task.estimateMinutes != null) task.estimateMinutes + 1.
5. Carry it in the backup file (optional field, no version bump)
Section titled “5. Carry it in the backup file (optional field, no version bump)”In domain/backup/BackupCodec.kt:
- Add
val estimateMinutes: Int? = nulltoBackupTask. - Set it in
Task.toBackup()and read it inBackupTask.toDomain(). - Leave
BackupCodec.VERSIONalone. Older readers ignore unknown keys (ignoreUnknownKeys = true), and a higherversionmakes them refuse the whole file. Bump it only for a change an older reader would misread, and keep the old shape readable when you do.
6. Carry it on the sync wire
Section titled “6. Carry it on the sync wire”In data/sync/RemoteRecords.kt:
- Add
@SerialName("estimate_minutes") val estimateMinutes: Int? = nulltoRemoteTask(snake_case, matching the Postgres column). - Set it in
Task.toRemote()and read it inRemoteTask.toDomain().
SyncJson has encodeDefaults = true, so the key is always sent, even as null. That’s
load-bearing: an upsert only updates the columns it names, and PostgREST rejects a batch whose
objects don’t all carry the same keys (PGRST102).
7. Add the column to the Neon mirror
Section titled “7. Add the column to the Neon mirror”-
Add the next file in
neon/migrations/. Today’s last is0002_lock_bookkeeping.sql, so yours is0003_task_estimate.sql. Make it idempotent, because these files also get pasted into the Neon console’s SQL editor:-- A task's estimate in minutes (see core's 5.sqm). Nullable, so an install that predates it-- can keep upserting rows without the key.alter table public.tasks add column if not exists estimate_minutes integer; -
No grant is needed for a column on an existing table:
0001grants the four tables at table level, and that covers new columns. A new table is different. Since0002_lock_bookkeeping.sqlrevoked Neon’s default privileges, a table added topublicis not reachable through the Data API until your migration grants it (and enables row-level security with an owner policy, the way0001does for the four tables). Theneon/CLAUDE.mdnotes explain the default-privileges gotcha that made this necessary. -
Apply it:
CADENCE_NEON_DB_URL='postgresql://…' bash neon/migrate.sh.
Tests to add
Section titled “Tests to add”- The migration: a case in
DatabaseDriverFactoryTestmodelled onthe 4 to 5 migration adds tags without disturbing the columns before them: build a version-5 file, open it, and check the row survives with the new column null. - The round trip through SQLite: extend
SqlDelightStoreTest’sa task survives an insert and read backwith the new field set. This catches a column added totoRowbut not totoTask, or not forwarded inupdateRow. - The merge path: make sure an edit arriving through
mergeRow(a pull or an import) carries the field.SqlDelightSyncStoreTestis the place. - The backup file: a round trip in
BackupCodecTest, plus decoding a file without the key. - The wire: extend
a task survives the round trip through the wire shapeinRemoteRecordsTest. - The server:
bash neon/tests/run.sh. It applies your migration twice, which proves it’s idempotent. Add a check if the column carries a rule (a default, a constraint).
Verify
Section titled “Verify”./gradlew :core:testDebugUnitTest :ui:testDebugUnitTest \ :core:jvmTest :ui:jvmTest :app-desktop:test :ui:compileKotlinJvmbash neon/tests/run.shThen check an upgrade by hand. Run the desktop app from main once, so its database is at version
5, switch to your branch and run it again. Your existing tasks should still be there, and the
database should now be at version 6:
sqlite3 ~/.local/share/primico/cadence.db 'PRAGMA user_version;' # Linux path6Related
Section titled “Related”- Database reference: every table, column and query.
- Backup format and sync wire: the two published shapes.
- Self-hosting sync: applying Neon migrations to your own project.
- ADR 0002 and ADR 0005: why the mirror has no foreign keys and why timestamps are milliseconds.