Skip to content

Commit d8d8e89

Browse files
dmealingclaude
andcommitted
docs(features): generated mutations — partial patch + @autoset stamping (FR-035, #198/#203 Phase 6)
Tier-2 feature doc for the generated mutation surface: the cross-port PATCH-1..7 contract (subset / absent≠null tristate / settable set / validation / empty-patch no-op / result / verbs), the per-port surface (TS typed <Entity>Patch fn, Kotlin repo.patch{} Exposed builder, Java repository.patch(<Entity>Patch), Python present-key route + patch-model validation, C# per-field merge loop), and @autoset timestamp stamping (create stamps all; update/patch stamps onUpdate + skips onCreate; insertPreserving escape hatch). Leads with the "never hand-write a single-column UPDATE" guidance. The Phase-6 residual for #198 + #203. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeGSV3StPCcJGZNNJ4ZfAb
1 parent f840baf commit d8d8e89

1 file changed

Lines changed: 123 additions & 0 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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

Comments
 (0)