Skip to content

Strict mode for upsertGraph() #429

Closed

Description

@lehni

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 of unrelate, noDelete and friends. #unrelate and #delete stay for single rows.
  • relate takes 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 noUpdate or noRelate, or at a #ref place of a BelongsToOneRelation) throws an InvalidGraph error when it differs from the stored value. Without strict, it stays ignored.
  • A graph that clears a key and uses the same key to place rows elsewhere throws an InvalidGraph error instead of writing a value that depends on which place is read. Done in 4.0 for all graphs, without strict (Write upsertGraph() in one plan, with the relations of moved rows read where they end up #438).

Activity

  1. added and removed on Oct 14, 2026
  2. lehni commented on Oct 25, 2026

    @lehni
    Author

    Implemented as an opt-in strict: true for insertGraph() and upsertGraph() in #526–#533, for 4.3.0:

    await Person.query().upsertGraph(graph, {
      strict: true,
      root: { kept: 'preserve' },
      relations: {
        '*': { removed: 'preserve' },
        movies: { added: 'relate', kept: 'update' },
      },
    });

    Each entry of relations maps 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 it
    • added: rows with an id that aren't in the relation, 'relate' | 'insert' | 'relateOrInsert' | 'throw' (default 'throw', 'insert' in insertGraph())
    • 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions