Add a field to an existing table
Example used to test this page: items.priority, an integer that defaults
to 0, readable in the API and settable on create and update.
Steps
Write the migration. Copy the last file in
backend/migrations/and save it as the next number,NNN_<name>.ts. Migrations run in file-name order, so the number is what places yours last. For adding a column, follow the shape ofbackend/migrations/005_worker_claimed_at.ts:export async function up(knex: Knex): Promise<void> { await knex.schema.alterTable('user_deletion_queue', (t) => { t.string('status').notNullable().defaultTo('pending'); t.timestamp('claimed_at', { useTz: true }).nullable();The safe column is either
.nullable()or.notNullable()with a.defaultTo(...). ANOT NULLcolumn with no default fails the migration on any table that already has rows.up()never drops or rewrites existing data. To rename or retype a column, add the new one, copy the values across, and drop the old one in a later release.down()reverses only whatup()added.Add the column to the row interface in
backend/src/db/types.ts(ItemRow,UserRow, ...). snake_case,| nullif the column is nullable.Fix every spec fixture typed as that row. A spec that builds a row literal, such as
baseRowinitems.controller.spec.ts, no longer compiles until it carries the new field, and jest fails the suite.npx tsc --noEmit -p tsconfig.jsoninbackend/lists them.Add the field to the zod schemas in the feature's
*.schemas.ts(backend/src/items/items.schemas.tsfor items): the response schema, the create and update request schemas if clients may set it, and the extractor at the bottom of the file. camelCase on the wire:export const extractItemDtoFromRow = (row: ItemRow): ItemDto => ({ id: row.id, name: row.name, description: row.description,Skip the extractor and the field never reaches the API, with no error.
Pass it through the service and controller if clients write it. In items,
CreateItemArgs/UpdateItemArgsinitems.service.ts, the insert and theupdateobject there, and the explicit argument list inItemsController.create. A field missing here is accepted by the schema and then silently dropped.The deletion-policy rule. Find the table's entry in
backend/src/users/deletion-policy.ts. That file is the current list of which tables survive account deletion. An entry without acolumnsblock is a table whose rows are deleted outright, and needs nothing. An entry withcolumnsis a table whose rows survive: list the new column undererasedorkept.kept: the column holds nothing that identifies the person.erased: it does. The eraser named in the entry'serases.bymust overwrite it, in its existingupdateon that same table. Forusersandemail_jobsthat eraser isghostDeleteinbackend/src/users/users.service.ts.user_deletion_queueis the exception amongerasesentries: its eraser,processDeletionQueueinuser-deletion.worker.ts, only deletes the row and has no update the spec can check. Do not store personal data in the queue row at all.last_errorshows the pattern: the worker scrubs it before writing, and it is listed askept.- An entry with
keepsinstead oferaseshas no eraser, so anerasedcolumn there fails the spec. For thebilling_*tables, do not add personal columns at all: they are written only by the billing code (billing_subscriptionsonly byBillingSubscriptionStore), and deletion reaches them throughdeleteCustomerData; see Account deletion. If you must, list the column underkeptand name the call that erases it in the entry'skeepsreason, as the header ofdeletion-policy.spec.tsprescribes. For any otherkeepstable, change the entry toerasesand add the update to an eraser.
What fails if you skip it:
- Not listed:
deletion-policy.integration.spec.tsfails with"users.timezone is unlisted: add it to erased or kept". - Listed as
erasedbut not written by the named eraser:deletion-policy.spec.tsfails ("claims a column erasure only where the eraser writes it").
Format.
npm run formatinbackend/;format:checkis part of verify.Regenerate the contract and the frontend types, from
backend/:npx ts-node -r tsconfig-paths/register scripts/dump-openapi.ts cp openapi.runtime.json ../openapi.json cd ../frontend && npm run generate-typesCommit
openapi.jsonandfrontend/types/api.d.tswith the change. Skip this and verify fails at theopenapistage ("runtime spec matches committed openapi.json"), or at the frontend's type drift check.Use it in the frontend if a page shows it. The generated types already carry the field (
item.priority), so a typo is a build error.Run
./verify.sh. It applies every migration to a fresh database, so a migration that fails on an empty table fails here.
What the test run found
- With
items.priorityadded end to end: verify all green. - Adding
users.timezone(nullable) without a policy entry: verify failed indeletion-policy.integration.spec.tswith the message above. Listing it aserasedwithout touchingghostDeletefaileddeletion-policy.spec.ts. Addingtimezone: nullto theghostDeleteupdate made verify green again.
Related
- What Ghost Deletion Erases
- Define the persistence layer - the same conventions for a new table