|
| 1 | +# Generated mutations — create, partial patch, delete, and `@autoSet` stamping |
| 2 | + |
| 3 | +The generated data-access surface gives you `create`, a **partial-update patch**, |
| 4 | +and `delete` per entity — driven entirely by the metadata, so a renamed or dropped |
| 5 | +column is a build error, not a silent write. This page is the durable **contract** |
| 6 | +(identical in every port) plus the per-port shape it takes. |
| 7 | + |
| 8 | +> The one rule that saves the most hand-written code: **never hand-write a |
| 9 | +> single-column `UPDATE`.** Use the generated patch. A hand-maintained setter is a |
| 10 | +> place the column list can typo, drift from the metadata, or silently revert a |
| 11 | +> concurrent writer's column. The generated patch touches only what you assign. |
| 12 | +
|
| 13 | +## Why partial patch, not full-row update |
| 14 | + |
| 15 | +A full-row `update(model)` writes *every* column, which forces a read-modify-write |
| 16 | +and is a **lost-update hazard**: between the read and the write, another writer's |
| 17 | +change to an *unrelated* column is silently reverted, because the full row carries |
| 18 | +stale values for every column you didn't touch. Partial patch removes both |
| 19 | +problems — the SQL `SET` clause contains only the columns you assigned (a single |
| 20 | +statement, no read-first), so disjoint concurrent writes don't clobber each other. |
| 21 | + |
| 22 | +## The patch contract (identical in every port) |
| 23 | + |
| 24 | +The surface syntax is per-port idiomatic (native codegen, not the shared engine), |
| 25 | +but the semantics are one contract — call them **PATCH-1..7** (FR-035): |
| 26 | + |
| 27 | +- **PATCH-1 (subset).** A patch is a set of `field → value` assignments over an |
| 28 | + entity's *settable* fields. Exactly the assigned columns are written; **absent |
| 29 | + fields are untouched.** |
| 30 | +- **PATCH-2 (tristate — absent ≠ null).** An explicit `null` sets the column NULL |
| 31 | + and is valid only on a non-`@required` field (else a validation error). An |
| 32 | + *omitted* field is left alone. This is JSON-Merge-Patch present-key semantics |
| 33 | + with `null` meaning *set null*, not *delete*. |
| 34 | +- **PATCH-3 (settable set).** Never settable: the primary key, the TPH |
| 35 | + discriminator, `@readOnly` fields, `origin.*`-derived fields. `@autoSet:onUpdate` |
| 36 | + columns are stamped server-side regardless of the caller; `@autoSet:onCreate` |
| 37 | + columns are not patch-settable. At the HTTP tier an unknown or non-settable key |
| 38 | + is a `400 {"error":"validation"}`. |
| 39 | +- **PATCH-4 (validation).** Assigned values run the same per-field validation and |
| 40 | + the same write codec as `create`. |
| 41 | +- **PATCH-5 (empty patch).** Zero assignments is a no-op — the current row is read |
| 42 | + back and returned, never an empty-`SET` SQL error. |
| 43 | +- **PATCH-6 (result).** The patch returns the full updated row (`RETURNING`, or a |
| 44 | + same-transaction re-read); a missing row is the port's not-found idiom |
| 45 | + (`null` / `Optional.empty` / `404`). |
| 46 | +- **PATCH-7 (verbs).** At the HTTP tier both `PATCH` and `PUT` route to the update |
| 47 | + handler with partial-merge semantics. True PUT-as-full-replace is out of scope. |
| 48 | + |
| 49 | +Optimistic concurrency (a `WHERE … AND version = ?` guard) is **not** a separate |
| 50 | +vocabulary item: an [`@autoSet`](#autoset-timestamp-stamping) field already models |
| 51 | +"stamps on every write," which is the natural lock column (owner ruling, |
| 52 | +2026-07-13 — a dedicated `@rowVersion` attribute was declined per ADR-0023). |
| 53 | + |
| 54 | +## The generated surface, per port |
| 55 | + |
| 56 | +All of these are **generated output** — what an adopter calls. |
| 57 | + |
| 58 | +**TypeScript** — the update function is already partial; it is typed against |
| 59 | +`<Entity>Patch` (a compile-safe partial shape) and validates through |
| 60 | +`<Entity>UpdateSchema`: |
| 61 | + |
| 62 | +```ts |
| 63 | +export async function updateAuthor(db: Db, id: number, patch: AuthorPatch): Promise<Author | null> { |
| 64 | + const validated = AuthorUpdateSchema.parse(patch); |
| 65 | + if (Object.keys(validated).length === 0) return findAuthorById(db, id); // PATCH-5 |
| 66 | + const [author] = await db.update(authors).set(validated).where(eq(authors.id, id)).returning(); |
| 67 | + return author ?? null; |
| 68 | +} |
| 69 | + |
| 70 | +await updateAuthor(db, 42, { bio: "…" }); // writes ONE column; a renamed field is a compile error |
| 71 | +``` |
| 72 | + |
| 73 | +**Kotlin** — the generated repository ships both a full `update(id, dto)` and a |
| 74 | +`patch` that takes the Exposed statement lambda, so a renamed/dropped column is a |
| 75 | +compile error at the call site: |
| 76 | + |
| 77 | +```kotlin |
| 78 | +repo.patch(id) { it[Authors.bio] = "…" } // touches only what you set |
| 79 | +``` |
| 80 | + |
| 81 | +**Java** — the repository interface gains `Optional<Dto> patch(PkType id, <Entity>Patch patch)`; |
| 82 | +the controller binds the partial JSON body via `<Entity>Patch.fromJson` (presence-tracked) |
| 83 | +and delegates to `repository.patch`, so an omitted key is never written. |
| 84 | + |
| 85 | +**Python** — the generated FastAPI route binds the raw JSON body (the set of |
| 86 | +*present* keys — the tristate signal) and validates the present values against the |
| 87 | +generated `<Entity>Patch` model before delegating to the runtime `update`, which |
| 88 | +`SET`s only those keys. |
| 89 | + |
| 90 | +**C#** — the generated route runs a per-field merge loop over the present JSON keys |
| 91 | +(the app's configured `JsonSerializerOptions`), writing only the columns the body |
| 92 | +carried; omitted columns are untouched. |
| 93 | + |
| 94 | +## `@autoSet` timestamp stamping |
| 95 | + |
| 96 | +Mark a `field.timestamp` with `@autoSet: onCreate` or `onUpdate` and the generated |
| 97 | +CRUD stamps `now()` for you — so adopters stop hand-writing `now()` in every |
| 98 | +repository. The contract (cross-port): |
| 99 | + |
| 100 | +- **create** stamps every `onCreate` *and* `onUpdate` column with `now()`; the |
| 101 | + model's value is ignored (a fresh row's `updatedAt` equals its `createdAt`). |
| 102 | +- **update / patch** stamps `onUpdate` columns with `now()` and **skips `onCreate` |
| 103 | + entirely** — `createdAt` is never rewritten (omitting this is the latent |
| 104 | + lost-update bug the feature closes). On a patch, `onUpdate` is stamped even when |
| 105 | + the caller assigns nothing else, so a partial update still bumps `updatedAt`. |
| 106 | +- **`insertPreserving`** — an escape hatch that writes the `@autoSet` columns |
| 107 | + verbatim (import / restore / replication); generated only for entities that |
| 108 | + declare `@autoSet` fields. |
| 109 | + |
| 110 | +```jsonc |
| 111 | +{ "field.timestamp": { "name": "updatedAt", "@autoSet": "onUpdate" }} |
| 112 | +``` |
| 113 | + |
| 114 | +## See also |
| 115 | + |
| 116 | +- [`docs/features/api-contract.md`](api-contract.md) — the REST contract these |
| 117 | + mutations mount into (routes, verbs, error shapes) |
| 118 | +- [`docs/features/codegen-data-shapes.md`](codegen-data-shapes.md) — the |
| 119 | + create / update / patch input shapes per port |
| 120 | +- [`docs/features/field-types.md`](field-types.md) — `field.timestamp`, |
| 121 | + `@autoSet`, and per-subtype wire encoding |
| 122 | +- `docs/superpowers/specs/2026-07-13-generated-mutation-surface-design.md` — the |
| 123 | + full FR-035 design (prior art, per-port plan, the owner rulings) |
0 commit comments