Writing Backend correctness

Save again, get another person

A later parent upsert does not fill the null you already froze on the child.

You hit save. The form shows Ada and 555-0100. The save succeeds. You change the note and save again. A second Ada is in the people table. Both child rows still point at nobody.

In this note, save again is a second edit-save on the same form, not a retry after an error. Another person is a second people-table row for the same on-screen name and phone, not a second human.

Upsert here means resolve-or-create the parent and return its id (use id if present, else match a normalized natural key). Attach means stamp that id onto the child at construct time.

Wrong model: Resolve the parent whenever. The child will pick up the id. One save means construction order does not matter.

Actual model: The child’s pointer is a value you already hold, not a field a later upsert will backfill.

Two paths sit on the same edit-save. Build-then-resolve assembles the child with whatever parent id you have now (often null), then resolve-or-creates the parent. The child never sees the returned id. Resolve-then-build resolve-or-creates the parent first, then assembles the child with that id.

On one edit-save, order decides the people table: whether the parent stays one row, and whether the child points at it.

Invariant: the child stores the id you stamp at construct time. A later upsert does not rewrite that freeze.

A successful save can still mint a person

The parent function returns a new id. You never write it onto the form or the child. The next save still has no parentId, so “no id” looks like “new person” again. Same name on screen. Same digits after normalize. Two people. Two children with a null stamp.

That is the inverse of minting a ghost so a required pointer is never empty. There you invent a person to satisfy the column. Here the column stays empty, and you invent a person on every save anyway.

The child is a snapshot

Once you build the child object, parentId is a value in that object. It is not a live slot that watches the parent upsert. Assemble first and you freeze form.parentId ?? null. The upsert can return a real id a line later. That id dies in the save function unless you stamp it.

An upsert that returns an id does not attach that id. Attachment is a write onto the child, not a side effect of resolve.

Two paths on one edit-save

I ran node lab/resolve-then-build.mjs in this repo on 8 September 2026. One in-memory store. Two saves: first save, then edit save. Ada both times. Phones 555-0100 then 5550100.

function saveBuildThenResolve(form) {
  const child = { parentId: form.parentId ?? null, note: form.note };
  insertIfNoId({ id: form.parentId, phone: form.phone, name: form.name });
  // returned id never written onto child
}

function saveResolveThenBuild(form) {
  const { person } = upsertPerson({
    id: form.parentId,
    phone: form.phone,
    name: form.name,
  });
  const child = { parentId: person.id, note: form.note };
}

Build-then-resolve left two people and [null, null] on the children. The second save did not reuse the first id. Resolve-then-build left one person, both children on that id, phone key 5550100. When the id was already present, the row was reused. Upsert-then-stale-build kept one person and still stored [null, null] on the children.

{
  "buildThenResolve": {
    "people": 2,
    "childParentIds": [null, null],
    "secondSaveReusedFirstId": false
  },
  "resolveThenBuild": {
    "people": 1,
    "sameId": true,
    "phoneKey": "5550100"
  },
  "idPresent": {
    "people": 1,
    "reusedId": true
  },
  "upsertThenStaleBuild": {
    "people": 1,
    "upsertMatchedSecond": true,
    "childParentIds": [null, null]
  },
  "allPassed": true
}

Resolved is not unique, and unique is not attached

The trap path calls a real upsert first, then builds the child from form.parentId anyway:

const { person } = upsertPerson({ id: form.parentId, phone: form.phone, name: form.name });
const child = { parentId: form.parentId ?? null };

The second save matches the normalized phone. The people table stays one row. Both children are still null. A unique person on the people table is not an attach. Calling upsert earlier is not the invariant. The stamp is.

Rejected: assemble-then-fill; treating the on-screen name and phone as identity; leaning on a unique index to “merge” while the child still stores null.

This note is the wrong tool when a child may be anonymous (a null pointer is allowed), or when the parent is picked from a list that already has an id. Require that id and skip match-on-phone.

A unique index without a stamp does not attach. A stale id plus an edited phone that belongs to someone else is a different collision. An empty natural key cannot match. Two writers on the same phone with no unique rule can still double-insert the parent.

Resolve the parent, then construct the child. The people table can look clean and the children can still point at nobody.

A public notebook

Notes from real delivery: racey quotas, sync when a device is offline, messaging APIs, and the gap between a clean local demo and production.

Not a product catalog, a tutorial syllabus, or a course funnel. If a post names a tool, I used it. If it describes a failure, it happened.

About Contact