2026-09-30-persisted-row-id
title: "A row's id is int; an insert takes the generated draft NewRow"
affects:
dartway_orm: "0.21.0-dev.12"
dartway_generator: "0.21.0-dev.12"
dartway_core_server: "0.21.0-dev.12"
Who is affected
Every project with a row class. DwTableRow.id is int now — a row is what the database stored,
so its id is never missing — and a row not stored yet is a different value: its draft,
New<Entity>Row, which dartway generate writes beside the row class. insert, tryInsert,
insertAll and upsert take drafts and answer stored rows; update takes a row as before.
An insert with an id of one's own is gone. dartway check (dartway_cli 0.13.0) fails a server or
shared package whose analysis_options.yaml does not raise unnecessary_non_null_assertion to an
error (redundantBangAllowed).
What to change
Five steps, in this order; the first four are mechanical.
1. Every row class declares its id required and non-null — required, not with a default:
- const InvoiceRow({this.id, required this.amountCents, …});
+ const InvoiceRow({required this.id, required this.amountCents, …});
@override
- final int? id;
+ final int id;
then dartway generate. The generator refuses a row class whose id is int?, or int with a
default (this.id = 0 is the sentinel a row no longer needs), and one whose constructor default it
cannot repeat in the draft. What it writes changes too:
- the row's
copyWithno longer takesid— a copy of a stored row is the same row; - beside it, the draft
New<Entity>Row: the row's constructor withoutidand with the same defaults, acopyWith,withId(id), and==,hashCodeandtoStringby its columns — two drafts with the same columns are equal, as two rows are.
2. The server and the shared package's analysis_options.yaml raise the analyzer's diagnostic
for a ! that means nothing — dartway check fails until they do:
analyzer:
errors:
unnecessary_non_null_assertion: error
3. Every ! on a row's id goes. The analyzer now reports each one, and fixes them all:
dart fix --apply --code=unnecessary_non_null_assertion
in the server package. A ! on a data object's int? id (a "create or save" command) is not
reported and stays.
4. Every construction of a row without an id: becomes its draft — the analyzer reports it as
a missing required argument id:
- await ctx.db.invoices.insert(InvoiceRow(amountCents: 100, …));
+ await ctx.db.invoices.insert(NewInvoiceRow(amountCents: 100, …));
A list, a variable or a helper that holds rows before insertAll is typed by the draft:
List<NewInvoiceRow>, NewInvoiceRow draftOf(…).
5. What the compiler still reports is a place that mixed the two, and each one is a choice rather than a rename:
-
"Create or save" by an optional id — one row built with
id: command.id, theninsertorupdate.update(draft.withId(id))only when the command carries every column of the row (an admin form over a table with no owner, no creation time, nothing another command sets):- final row = ClubServiceRow(id: command.id, title: command.title, …);- final saved = command.id == null- ? await ctx.db.clubServices.insert(row)- : await ctx.db.clubServices.update(row);+ final draft = NewClubServiceRow(title: command.title, …);+ final saved = switch (command.id) {+ null => await ctx.db.clubServices.insert(draft),+ final id => await ctx.db.clubServices.update(draft.withId(id)),+ };Otherwise — the usual case — read the stored row by its id and its owner, and change what the command owns;
withIdwould overwrite the rest with the draft's defaults:final current = await ctx.db.invoices.findFirst(where: (t) => t.id.equals(id) & t.ownerProfileId.equals(me.id),lock: DwRowLock.forUpdate,);if (current == null) ctx.refuse(DwCoreRefusal.notFound);await ctx.db.invoices.update(current.copyWith(note: DwFieldPatch.set(command.note)));A row keyed by a unique column (
(current ?? defaults).copyWith(…), then insert or update) isupsert(draft, conflictOn: (t) => [t.profileId]), one statement. -
A function that takes a row and is called only before the insert (a notice, a check, a helper building the candidate) takes the draft.
-
A function called both before and after the insert — an extension computing from the columns, a policy reading stored settings or their defaults (
row ?? const New…Row(…)) — needs one type for both: give it the draft when it only reads values (build one from a stored row where needed), or split the two call paths. -
A test that builds a row in memory to feed a mapper or a policy gives it an id:
InvoiceRow(id: 1, …). -
An insert with an explicit id (seeding fixed ids) inserts drafts and reads the ids back: a written id left the
bigserialsequence behind the rows and failed the next insert. -
row.id == nullchecks are now always false:unnecessary_null_comparisonanddead_codepoint at them; delete the branch. -
row.copyWith(id: …)is gone. A new row with the same values is a draft, built from the fields it needs.
How to check
dartway generate --check is clean, dartway check reports no redundantBangAllowed,
dart analyze in the server package reports nothing, and
grep -rn '\.id!' lib test bin
lists only data objects' ids. No schema change: the tables are the same, so no migration is written.