Repository navigation
Strict mode for upsertGraph() #429
Description
upsertGraph() infers what each entry of a graph means: the same { id: 10 } can update, relate, move or keep a row, depending on whether it exists, where it currently is, and option paths like relate: ['pets.siblings']. Rows can appear in several places, key props can contradict relations, and leaving a row out can unrelate or delete it. 4.0 makes the result deterministic, but the rules needed to resolve all of this are hard to predict without reading them.
A strict option would remove the ambiguities at the API level, while keeping the graph shape and the familiar options:
- One place per row. A row appears in one place only; other places refer to it with
#ref. A graph that repeats a row throws. - Key props never compete with relations. A key column that a relation of the same row sets must not be given as a prop, and a key prop that isn't written (e.g. under
noUpdate) throws instead of being ignored. - One option says what happens to rows left out, per relation:
missing: { pets: 'keep' | 'unrelate' | 'delete' }, replacing the combinations ofunrelate,noDeleteand friends.#unrelateand#deletestay for single rows. relatetakes relation paths only, so every relation that may move rows is named. It stays opt-in: graphs often come from clients, and relating any id they send would be a security risk.
Plan: introduce it as an opt-in option in 4.x, and consider making it the default in a later major. It fits the remaining refactor of the graph writer: loading all rows once, solving one value per key, and ordering the writes by foreign keys.
Each rule is a breaking change for graphs that rely on the current behaviour, so I'll decide them one by one before implementing anything.
Measured on 4.0: the one-place rule on its own, with rows still repeated by id or as #ref, removes no code from the writer, as these places still set the keys that its slots resolve, and it breaks graphs fetched with withGraphFetched() and sent back, which repeat a row with all its props, e.g. the same tag under several movies. The rules only make the writer simpler together: #ref models without data, keys only from relations, and one place per row including repeats by id. Strict mode also needs a way to send fetched graphs back, e.g. by accepting repeats whose data equals the row's place.
Decided:
- A key prop that the graph gives but doesn't write (under
noUpdateornoRelate, or at a#refplace of aBelongsToOneRelation) throws anInvalidGrapherror when it differs from the stored value. Withoutstrict, it stays ignored. - A graph that clears a key and uses the same key to place rows elsewhere throws an
InvalidGrapherror instead of writing a value that depends on which place is read. Done in 4.0 for all graphs, withoutstrict(Write upsertGraph() in one plan, with the relations of moved rows read where they end up #438).
Activity
Add strict mode with options per relation to insertGraph() and upsert…
Add strict mode with options per relation to insertGraph() and upsert…
Throw for rows that strict graphs don't write (#429) (#527)
Throw for key props that contradict their relations in strict graphs (#…
Throw for values that strict graphs don't write and that differ from …
Compare join extras, repeated rows and related rows that strict graph…
Make the defaults of strict graphs explicit and their comparisons exa…
Run strict insertGraph() and upsertGraph() in a transaction (#429, #477)
Run strict insertGraph() and upsertGraph() in a transaction (#429, #477…
lehni commentedon Oct 25, 2026
Implemented as an opt-in
strict: trueforinsertGraph()andupsertGraph()in #526–#533, for 4.3.0:Each entry of
relationsmaps a relation expression to settings for the four cases of the diff between the graph and the stored relation. Later entries override earlier ones key by key:removed: rows in the relation but not in the graph,'preserve' | 'unrelate' | 'delete', with no default: every relation the graph contains needs itadded: rows with an id that aren't in the relation,'relate' | 'insert' | 'relateOrInsert' | 'throw'(default'throw','insert'ininsertGraph())kept: rows with an id that end up in the relation,'patch' | 'update' | 'preserve'(default'patch')created: rows without an id,'insert' | 'throw'(default'insert')These replace the old per-path options, which throw in strict mode. Strict graphs also throw when copies of a row disagree, when a key prop contradicts its relation, or when a value they don't write differs from the stored row. Equal values pass, so fetched graphs can be sent back unchanged. Strict calls run in a transaction: their own, unless the query already runs in one.
Making strict mode the default in the next major is #534.