jazz icon indicating copy to clipboard operation
jazz copied to clipboard

Feature request: `getOrCreateUnique` (or option on `upsertUnique`)

Open joeinnes opened this issue 2 months ago • 2 comments

Problem

Right now upsertUnique always mutates, meaning user modifications will be overwritten if the record exists.

There are common cases where the intended semantics are instead: create the record if it doesn’t exist, but if it does exist, just return it unchanged.


Option 1: Separate method

const learnJazzTask = await Task.getOrCreateUnique({
  value: {
    text: "Let's learn some Jazz!",
  },
  unique: "learning-jazz",
  owner: project.$jazz.owner,
});

Pros

  • Very explicit about semantics.
  • Matches naming conventions in other ORMs (getOrCreate, findOrCreate).
  • Keeps upsertUnique narrowly focused.

Cons

  • Duplicates a lot of upsertUnique implementation.
  • Naming is a bit unwieldy (getOrCreateUnique).

Option 2: Extend upsertUnique with a flag

const learnJazzTask = await Task.upsertUnique({
  value: {
    text: "Let's learn some Jazz!",
  },
  unique: "learning-jazz",
  owner: project.$jazz.owner,
  ifExists: "overwrite", // "overwrite" | "return" - defautlt 'overwrite'
});

Pros

  • Reuses existing API surface — no new top-level method.
  • Reduces code duplication internally.
  • Easier to document and maintain one concept instead of two.

Cons

  • Semantics are less immediately clear: upsertUnique is now doing two quite different things depending on an option.
  • Risk of confusion between “update-or-insert” vs. “insert-or-return.”

Option 3: Provide a thin alias (getOrCreateUnique)

const learnJazzTask = await Task.getOrCreate({
  value: {
    text: "Let's learn some Jazz!",
  },
  unique: "learning-jazz",
  owner: project.$jazz.owner,
});

Under the hood this would simply call:

Task.upsertUnique({ ..., ifExists: "return" });

Pros

  • Clearer semantics than Option 2.
  • Minimal implementation cost (just syntactic sugar).
  • Avoids code duplication (unlike Option 1).

Cons

  • Adds another method name to the API surface, which can cause slight cognitive overhead.
  • Still means implementing Option 2, but 'hiding it' behind a different API.

joeinnes avatar Oct 01 '25 08:10 joeinnes

After some intensive use of loadUnique we found that it is possible to get into some nasty race condition situations.

We should provide a loadOrCreateUnique API that:

  • doesn't accept a resolve param, as the failure on loading a child could lead to a double createUnique
  • handle race conditions when running multiple loadOrCreateUnique in parallel

We should also deprecate, and remove upsertUnique because it is better to focus in one safe API.

gdorsi avatar Oct 17 '25 17:10 gdorsi

I prefer load as prefix instead of get because it hints more on the async nature of the API.

gdorsi avatar Oct 17 '25 17:10 gdorsi