objection 4.0.0-rc.3 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/CheckViolationError/parser.js +2 -9
  2. package/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/ForeignKeyViolationError/parser.js +2 -9
  3. package/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/NotNullViolationError/parser.js +4 -2
  4. package/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/UniqueViolationError/parser.js +4 -2
  5. package/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/constraintMessage.js +16 -0
  6. package/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/parser.js +2 -1
  7. package/lib/dbErrors/parsers/sqlite/DBError/parser.js +2 -1
  8. package/lib/model/modelJsonAttributes.js +15 -6
  9. package/lib/objection.js +1 -0
  10. package/lib/queryBuilder/InternalOptions.js +4 -0
  11. package/lib/queryBuilder/StaticHookArguments.js +4 -2
  12. package/lib/queryBuilder/graph/GraphNode.js +8 -0
  13. package/lib/queryBuilder/graph/GraphPlan.js +13 -0
  14. package/lib/queryBuilder/graph/GraphSchedule.js +92 -40
  15. package/lib/queryBuilder/graph/GraphUpsert.js +2 -0
  16. package/lib/queryBuilder/graph/GraphWriter.js +159 -18
  17. package/lib/queryBuilder/graph/graphChecks.js +40 -3
  18. package/lib/queryBuilder/graph/graphKeys.js +146 -61
  19. package/lib/queryBuilder/jsonDialects/JsonDialect.js +6 -0
  20. package/lib/queryBuilder/operations/AsArrayOperation.js +10 -0
  21. package/lib/queryBuilder/operations/UpdateOperation.js +20 -7
  22. package/lib/queryBuilder/operations/fieldExpressions.js +15 -4
  23. package/lib/relations/RelationProperty.js +11 -0
  24. package/package.json +11 -4
  25. package/typings/objection/index.d.ts +191 -184
@@ -1,19 +1,12 @@
1
1
  import { CheckViolationError } from '../../../../../errors/CheckViolationError.js';
2
-
3
- const REGEX = /SQLITE_CONSTRAINT: CHECK constraint failed/;
2
+ import { getConstraintMessage } from '../constraintMessage.js';
4
3
 
5
4
  export const checkViolationErrorParser = {
6
5
  error: CheckViolationError,
7
6
 
8
7
  parse: (err) => {
9
- const match = REGEX.exec(err.message);
10
-
11
- if (!match) {
12
- return null;
13
- }
14
-
15
8
  // No way to extract anything reliably.
16
- return {};
9
+ return getConstraintMessage(err, 'CHECK') ? {} : null;
17
10
  },
18
11
 
19
12
  subclassParsers: [],
@@ -1,19 +1,12 @@
1
1
  import { ForeignKeyViolationError } from '../../../../../errors/ForeignKeyViolationError.js';
2
-
3
- const REGEX = /SQLITE_CONSTRAINT: FOREIGN KEY constraint failed/;
2
+ import { getConstraintMessage } from '../constraintMessage.js';
4
3
 
5
4
  export const foreignKeyViolationErrorParser = {
6
5
  error: ForeignKeyViolationError,
7
6
 
8
7
  parse: (err) => {
9
- const match = REGEX.exec(err.message);
10
-
11
- if (!match) {
12
- return null;
13
- }
14
-
15
8
  // No way to extract anything reliably.
16
- return {};
9
+ return getConstraintMessage(err, 'FOREIGN KEY') ? {} : null;
17
10
  },
18
11
 
19
12
  subclassParsers: [],
@@ -1,12 +1,14 @@
1
1
  import { NotNullViolationError } from '../../../../../errors/NotNullViolationError.js';
2
+ import { getConstraintMessage } from '../constraintMessage.js';
2
3
 
3
- const NOT_COLUMN_REGEX = /SQLITE_CONSTRAINT: NOT NULL constraint failed: (.+)$/;
4
+ const NOT_COLUMN_REGEX = /^NOT NULL constraint failed: (.+)$/s;
4
5
 
5
6
  export const notNullViolationErrorParser = {
6
7
  error: NotNullViolationError,
7
8
 
8
9
  parse: (err) => {
9
- const colMatch = NOT_COLUMN_REGEX.exec(err.message);
10
+ const message = getConstraintMessage(err, 'NOT NULL');
11
+ const colMatch = message && NOT_COLUMN_REGEX.exec(message);
10
12
 
11
13
  if (!colMatch) {
12
14
  return null;
@@ -1,12 +1,14 @@
1
1
  import { UniqueViolationError } from '../../../../../errors/UniqueViolationError.js';
2
+ import { getConstraintMessage } from '../constraintMessage.js';
2
3
 
3
- const UNIQUE_COLUMNS_REGEX = /SQLITE_CONSTRAINT: UNIQUE constraint failed: (.+)$/;
4
+ const UNIQUE_COLUMNS_REGEX = /^UNIQUE constraint failed: (.+)$/s;
4
5
 
5
6
  export const uniqueViolationErrorParser = {
6
7
  error: UniqueViolationError,
7
8
 
8
9
  parse: (err) => {
9
- const colsMatch = UNIQUE_COLUMNS_REGEX.exec(err.message);
10
+ const message = getConstraintMessage(err, 'UNIQUE');
11
+ const colsMatch = message && UNIQUE_COLUMNS_REGEX.exec(message);
10
12
 
11
13
  if (!colsMatch) {
12
14
  return null;
@@ -0,0 +1,16 @@
1
+ // Return the text of the SQLite error `err` from `<kind> constraint failed` on,
2
+ // e.g. 'UNIQUE constraint failed: t.a', if it's a constraint error of that kind,
3
+ // or else `null`. `better-sqlite3` names the kind in its extended code, e.g.
4
+ // `SQLITE_CONSTRAINT_UNIQUE`, `sqlite3` only after `SQLITE_CONSTRAINT: `. knex
5
+ // puts the SQL with its values in front, so the last match is SQLite's own.
6
+ export function getConstraintMessage(err, kind) {
7
+ const text = `${kind} constraint failed`;
8
+ // A duplicate primary key is a unique violation, with its own code:
9
+ const codes = kind === 'UNIQUE' ? ['UNIQUE', 'PRIMARYKEY'] : [kind.replace(' ', '')];
10
+ const isKind =
11
+ codes.some((code) => err.code === `SQLITE_CONSTRAINT_${code}`) ||
12
+ (err.code === 'SQLITE_CONSTRAINT' &&
13
+ /.*SQLITE_CONSTRAINT: (.*)$/s.exec(err.message)?.[1].startsWith(text));
14
+
15
+ return isKind ? err.message.slice(err.message.lastIndexOf(text)) : null;
16
+ }
@@ -8,7 +8,8 @@ export const constraintViolationErrorParser = {
8
8
  error: ConstraintViolationError,
9
9
 
10
10
  parse: (err) => {
11
- if (err.code === 'SQLITE_CONSTRAINT') {
11
+ // `better-sqlite3` uses the extended codes, e.g. `SQLITE_CONSTRAINT_UNIQUE`:
12
+ if (/^SQLITE_CONSTRAINT(_|$)/.test(err.code)) {
12
13
  return {};
13
14
  } else {
14
15
  return null;
@@ -5,10 +5,11 @@ export const dbErrorParser = {
5
5
  error: DBError,
6
6
 
7
7
  parse: (err) => {
8
+ // `sqlite3` gives its errors an `errno`, `better-sqlite3` names them:
8
9
  if (
9
10
  typeof err.code === 'string' &&
10
11
  err.code.startsWith('SQLITE_') &&
11
- typeof err.errno === 'number'
12
+ (typeof err.errno === 'number' || err.name === 'SqliteError')
12
13
  ) {
13
14
  return {
14
15
  nativeError: err,
@@ -1,12 +1,12 @@
1
1
  import { asArray, isObject, flatten, isString } from '../utils/objectUtils.js';
2
2
  import { isQueryProp } from './modelQueryProps.js';
3
- import { isJsonParsedByDriver } from '../utils/knexUtils.js';
3
+ import { isJsonParsedByDriver, isMsSql, isSqlite } from '../utils/knexUtils.js';
4
4
 
5
5
  export function parseJsonAttributes(json, modelClass, knex) {
6
6
  const jsonAttr = modelClass.getJsonAttributes();
7
7
  const format = getJsonColumnFormat(modelClass, knex);
8
8
 
9
- if (jsonAttr.length && format !== 'parsed') {
9
+ if (jsonAttr.length && format !== 'json') {
10
10
  // JSON attributes may be returned as strings depending on the database and
11
11
  // the database client. Convert them to objects here.
12
12
  for (let i = 0, l = jsonAttr.length; i < l; ++i) {
@@ -85,14 +85,23 @@ function getPropertyTypes(prop) {
85
85
  return types;
86
86
  }
87
87
 
88
- // Resolve 'auto' to 'parsed' for drivers that return JSON columns parsed, or
89
- // else to `null`: JSON attributes may be text then, and are parsed leniently.
88
+ // Resolve 'auto' by the driver: the PostgreSQL drivers return JSON columns
89
+ // parsed, SQLite and SQL Server return JSON text, and MySQL's drivers and others
90
+ // may return JSON or plain text, e.g. from MariaDB, which stores JSON as text,
91
+ // and which knex can't tell apart from MySQL. Without a driver, rows passed by
92
+ // hand may hold either.
90
93
  function getJsonColumnFormat(modelClass, knex) {
91
94
  const { jsonColumnFormat } = modelClass;
92
95
 
93
96
  if (jsonColumnFormat === 'auto') {
94
- return knex && isJsonParsedByDriver(knex) ? 'parsed' : null;
95
- } else if (jsonColumnFormat === 'text' || jsonColumnFormat === 'parsed') {
97
+ return !knex
98
+ ? 'mixed'
99
+ : isJsonParsedByDriver(knex)
100
+ ? 'json'
101
+ : isSqlite(knex) || isMsSql(knex)
102
+ ? 'text'
103
+ : 'mixed';
104
+ } else if (['json', 'text', 'mixed'].includes(jsonColumnFormat)) {
96
105
  return jsonColumnFormat;
97
106
  } else {
98
107
  throw new Error(
package/lib/objection.js CHANGED
@@ -12,6 +12,7 @@ export {
12
12
  ConstraintViolationError,
13
13
  CheckViolationError,
14
14
  DataError,
15
+ wrapError,
15
16
  } from './dbErrors/index.js';
16
17
  export { QueryBuilderBase } from './queryBuilder/QueryBuilderBase.js';
17
18
  export { QueryBuilderOperation } from './queryBuilder/operations/QueryBuilderOperation.js';
@@ -6,6 +6,9 @@ export class InternalOptions {
6
6
  this.skipRelationFilter = false;
7
7
  // The join table extras that related updates write, all of them if null:
8
8
  this.joinTableExtras = null;
9
+ // The path updates that `upsertGraph()` writes the keys in json columns of
10
+ // updated rows with, by column:
11
+ this.jsonKeyPatches = null;
9
12
  this.debug = false;
10
13
  this.schema = undefined;
11
14
  }
@@ -18,6 +21,7 @@ export class InternalOptions {
18
21
  copy.isInternalQuery = this.isInternalQuery;
19
22
  copy.skipRelationFilter = this.skipRelationFilter;
20
23
  copy.joinTableExtras = this.joinTableExtras;
24
+ copy.jsonKeyPatches = this.jsonKeyPatches;
21
25
  copy.debug = this.debug;
22
26
  copy.schema = this.schema;
23
27
 
@@ -1,4 +1,5 @@
1
1
  import { asArray, isObject } from '../utils/objectUtils.js';
2
+ import { AsArrayOperation } from './operations/AsArrayOperation.js';
2
3
 
3
4
  const BUILDER_SYMBOL = Symbol();
4
5
 
@@ -26,12 +27,13 @@ export class StaticHookArguments {
26
27
  // ones installed by `throwIfNotFound()` and `traverse()`) are meant for
27
28
  // its result, not for the result of this find query. The same goes for
28
29
  // the value it was resolved with, e.g. the 0 of an update that only
29
- // changes the join rows of a many-to-many relation.
30
+ // changes the join rows of a many-to-many relation. The query resolves to
31
+ // an array also for single-result writes, unless the hook adds `first()`:
30
32
  return this[BUILDER_SYMBOL].toFindQuery()
31
33
  .clear('runAfter')
32
34
  .clearResolve()
33
35
  .clearWithGraphFetched()
34
- .runAfter(asArray);
36
+ .addOperation(new AsArrayOperation('asArray'), []);
35
37
  };
36
38
  }
37
39
 
@@ -191,6 +191,14 @@ export class GraphNode {
191
191
 
192
192
  for (const node of nodes) {
193
193
  if (node.isReference) {
194
+ // A `#ref` places its model in a relation, which a root doesn't have:
195
+ if (!node.parent) {
196
+ throw node.modelClass.createValidationError({
197
+ type: ValidationErrorType.InvalidGraph,
198
+ message: `the reference at ${formatPath(node.dataPathKey)} has no relation to place its model in`,
199
+ });
200
+ }
201
+
194
202
  node.referencedNode = getNode(node.reference);
195
203
  }
196
204
 
@@ -91,6 +91,19 @@ export class GraphPlan {
91
91
  );
92
92
  }
93
93
 
94
+ // Return true if the graph relates the row of `node` by the key of its
95
+ // BelongsToOneRelation alone, without an id or a reference.
96
+ isRelatedByKey(node) {
97
+ return (
98
+ this.actions.get(node) === 'relate' &&
99
+ node.relation.keySide === 'owner' &&
100
+ !node.hasId &&
101
+ !node.isReference &&
102
+ !node.isDbReference &&
103
+ node.propertyReferences.length === 0
104
+ );
105
+ }
106
+
94
107
  // Return true if the model of the `#ref` model `node` writes what `node`
95
108
  // would write to the row: The model is inserted, or it's written and its path
96
109
  // allows any write that the path of `node` allows.
@@ -1,17 +1,23 @@
1
1
  import { hasOwnerKey, hasSameColumns, hasSameKey } from './graphKeys.js';
2
2
  import { groupBy } from '../../utils/objectUtils.js';
3
+ import { ValidationErrorType } from '../../model/ValidationError.js';
4
+ import { formatPath } from './GraphNode.js';
3
5
 
4
6
  // Order the writes of a planned graph in steps that keep keys and constraints
5
7
  // intact (#214), which `GraphWriter` writes:
6
8
  // 1. Move the rows that the graph relates to existing owners of HasMany and
7
9
  // HasOne relations, as they may still sit below rows that the next step
8
- // deletes. Rows that wait for inserts are related in step 3 or 4.
10
+ // deletes. Rows that wait for inserts or changed keys are related in step 3
11
+ // or 4.
9
12
  // 2. Remove rows that reference their owners, before the inserts that may need
10
13
  // their unique keys.
11
14
  // 3. Insert the new rows in the order of their keys and references, then the
12
15
  // keys deferred to break cycles and the join rows. Rows that move out of
13
16
  // the slots of HasOneRelations that new rows go into are related in the
14
- // same order, as the foreign key may be unique.
17
+ // same order, as the foreign key may be unique, and so are the patches and
18
+ // relates that change the keys that these rows read or the values that
19
+ // their `#ref{}` copies, as a foreign key may reference them (#428, #491).
20
+ // Copies of values that are no keys wait unless that closes a cycle.
15
21
  // 4. Patch the changed rows, the other relates, and the keys of owners whose
16
22
  // BelongsToOne rows were inserted, related or removed.
17
23
  // 5. Unrelate the rows that their own to-many relation on their own key leaves
@@ -25,7 +31,9 @@ export class GraphSchedule {
25
31
  this.plan = plan;
26
32
  this.keys = keys;
27
33
  // The nodes that each node waits for, through its keys, its references and
28
- // the rows that move out of the slots that it goes into:
34
+ // the rows that move out of the slots that it goes into. `isKeyChange`
35
+ // marks the patches and relates that change the keys it reads or the values
36
+ // that its `#ref{}` copies, and `isCopy` the latter:
29
37
  this.dependencies = createDependencies(plan, this.keys);
30
38
  // Dependencies that aren't waited for, to break cycles. Deferred keys are
31
39
  // written after the inserts:
@@ -36,9 +44,8 @@ export class GraphSchedule {
36
44
  ? []
37
45
  : plan.activeNodes.filter((node) => this._isMovedFirst(node)),
38
46
  );
39
- // Relates that move the rows out of the slots of HasOneRelations that new
40
- // rows go into, and that aren't moved first:
41
- this.slotMovedNodes = this._getSlotMovedNodes();
47
+ // Relates and patches that aren't moved first but written in the inserts:
48
+ this.earlyNodes = this._getEarlyNodes();
42
49
  this.steps = this._planSteps();
43
50
  }
44
51
 
@@ -91,10 +98,9 @@ export class GraphSchedule {
91
98
  activeNodes.filter((node) => {
92
99
  const action = actions.get(node);
93
100
  return (
94
- (action === 'patch' ||
95
- (action === 'relate' &&
96
- !this.movedNodes.has(node) &&
97
- !this.slotMovedNodes.has(node))) &&
101
+ ['patch', 'relate'].includes(action) &&
102
+ !this.movedNodes.has(node) &&
103
+ !this.earlyNodes.has(node) &&
98
104
  // The row of a `#ref` model is written once, by its model:
99
105
  !this._isWrittenByModel(node)
100
106
  );
@@ -140,6 +146,7 @@ export class GraphSchedule {
140
146
  !handledNodes.has(dependency.node) &&
141
147
  (dependency.isSlotMove ||
142
148
  dependency.isKeyWait ||
149
+ dependency.isKeyChange ||
143
150
  !hasRow(dependency.node) ||
144
151
  this.readsFilledProps(dependency)),
145
152
  );
@@ -160,12 +167,14 @@ export class GraphSchedule {
160
167
 
161
168
  // Break a cycle by deferring one BelongsToOne key that points to a `#ref`
162
169
  // model: Its owner is inserted without the key, which is patched in once
163
- // the referenced model is inserted. Without `#ref`, the graph is a tree,
164
- // and only waits for the rows that move out of slots can close a cycle,
165
- // like rows that swap slots. One of these isn't waited for then.
170
+ // the referenced model is inserted. Without `#ref` models, the graph is a
171
+ // tree, and only `#ref{}` copies of changed values and the waits for the
172
+ // rows that move out of slots can close a cycle, like rows that swap slots.
173
+ // One of these isn't waited for then, the copies first.
166
174
  const breakCycle = () => {
167
175
  for (const canDefer of [
168
176
  (dependency, node) => this._canDefer(dependency, node),
177
+ (dependency) => dependency.isCopy,
169
178
  (dependency) => dependency.isSlotMove,
170
179
  ]) {
171
180
  for (const node of nodes) {
@@ -212,7 +221,25 @@ export class GraphSchedule {
212
221
  }
213
222
  }
214
223
  } else if (!breakCycle()) {
215
- throw new Error('the object graph contains cyclic references');
224
+ // A patch that changes a key that the rows it waits for read can be
225
+ // written neither before nor after them:
226
+ const { node: changingNode } =
227
+ nodes
228
+ .filter((node) => !handledNodes.has(node))
229
+ .flatMap(getPendingDependencies)
230
+ .find(
231
+ ({ node, dependent, isKeyChange }) => isKeyChange && isBlockedBy(node, dependent),
232
+ ) || {};
233
+
234
+ if (changingNode) {
235
+ const { modelClass, dataPathKey } = changingNode;
236
+ throw modelClass.createValidationError({
237
+ type: ValidationErrorType.InvalidGraph,
238
+ message: `the graph changes a key of the ${modelClass.name} at ${formatPath(dataPathKey)} that rows read which it also waits for; write the key in a separate query first`,
239
+ });
240
+ } else {
241
+ throw new Error('the object graph contains cyclic references');
242
+ }
216
243
  }
217
244
  }
218
245
 
@@ -245,9 +272,10 @@ export class GraphSchedule {
245
272
 
246
273
  // Return true if the HasMany or HasOne relate of `node` moves its row to an
247
274
  // existing owner before the removals, see the steps above. `#ref` models may
248
- // get their ids from the inserts, rows that wait for the inserts are written
249
- // once they're known, and a relate into the slot of a HasOne row waits for
250
- // the removal of that row, or its move, as its key may be unique.
275
+ // get their ids from the inserts, rows that wait for the inserts or for
276
+ // changed keys are written after them, and a relate into the slot of a HasOne
277
+ // row waits for the removal of that row, or its move, as its key may be
278
+ // unique.
251
279
  _isMovedFirst(node, visitedNodes = new Set()) {
252
280
  const { actions } = this.plan;
253
281
  visitedNodes.add(node);
@@ -257,7 +285,7 @@ export class GraphSchedule {
257
285
  !node.isReference &&
258
286
  node.relation.keySide === 'related' &&
259
287
  node.parent.existing &&
260
- !this._waitsForInserts(node)
288
+ !this._waitsForWrites(node)
261
289
  ) {
262
290
  const slotNode = getSlotNode(node);
263
291
  const moverNode = getSlotMover(this.plan, node);
@@ -271,24 +299,32 @@ export class GraphSchedule {
271
299
  }
272
300
  }
273
301
 
274
- // Find the relates that move the rows out of the slots of HasOneRelations
275
- // that new rows go into, and that aren't moved first. They're related in the
276
- // inserts, along with the relates that move the rows out of their own slots,
277
- // as the foreign key may be unique.
278
- _getSlotMovedNodes() {
302
+ // Find the relates and patches that are written in the inserts, as the rows
303
+ // written there wait for them: The relates that move the rows out of the
304
+ // slots of HasOneRelations that new rows go into, along with the relates that
305
+ // move the rows out of their own slots, as the foreign key may be unique, and
306
+ // the patches and relates that change the keys that these rows or join rows
307
+ // read, as a foreign key may reference them (#428).
308
+ _getEarlyNodes() {
309
+ const { actions } = this.plan;
279
310
  const nodes = new Set();
280
- const addMoverNodes = (node) => {
281
- for (const { node: moverNode, isSlotMove } of this.dependencies.get(node) || []) {
282
- if (isSlotMove && !this.movedNodes.has(moverNode) && !nodes.has(moverNode)) {
283
- nodes.add(moverNode);
284
- addMoverNodes(moverNode);
311
+ const addEarlyNodes = (node) => {
312
+ for (const dependency of this.dependencies.get(node) || []) {
313
+ const { node: earlyNode, isSlotMove, isKeyChange } = dependency;
314
+ if (
315
+ (isSlotMove || isKeyChange) &&
316
+ !this.movedNodes.has(earlyNode) &&
317
+ !nodes.has(earlyNode)
318
+ ) {
319
+ nodes.add(earlyNode);
320
+ addEarlyNodes(earlyNode);
285
321
  }
286
322
  }
287
323
  };
288
324
 
289
325
  for (const node of this.plan.activeNodes) {
290
- if (this.plan.actions.get(node) === 'insert') {
291
- addMoverNodes(node);
326
+ if (actions.get(node) === 'insert' || (node.relation?.keySide === 'join' && !node.current)) {
327
+ addEarlyNodes(node);
292
328
  }
293
329
  }
294
330
 
@@ -317,19 +353,21 @@ export class GraphSchedule {
317
353
  }
318
354
 
319
355
  // Return true if `node` waits for the keys or references of inserted models,
320
- // also through the models that it references (#356), and through the models
321
- // that it waits for to get its keys.
322
- _waitsForInserts(node, visitedNodes = new Set()) {
356
+ // or for keys that patches change (#428), also through the models that it
357
+ // references (#356), and through the models that it waits for to get its
358
+ // keys.
359
+ _waitsForWrites(node, visitedNodes = new Set()) {
323
360
  const { actions } = this.plan;
324
361
  visitedNodes.add(node);
325
362
  return (this.dependencies.get(node) || []).some((dependency) => {
326
- const { node: dependencyNode, relation } = dependency;
363
+ const { node: dependencyNode, relation, isKeyWait, isKeyChange } = dependency;
327
364
  const referencedNode = dependencyNode.referencedNode || dependencyNode;
328
365
  return (
329
366
  actions.get(referencedNode) === 'insert' ||
330
- ((!relation || dependency.isKeyWait) &&
367
+ isKeyChange ||
368
+ ((!relation || isKeyWait) &&
331
369
  !visitedNodes.has(referencedNode) &&
332
- this._waitsForInserts(referencedNode, visitedNodes))
370
+ this._waitsForWrites(referencedNode, visitedNodes))
333
371
  );
334
372
  });
335
373
  }
@@ -458,18 +496,32 @@ function createDependencies(plan, keys) {
458
496
 
459
497
  // A place waits for the inserts and `#ref{}` values that bind the slots of
460
498
  // its keys. A dependency on the same row through the same key becomes such
461
- // a wait, and stays deferrable to break cycles. The other waits aren't:
462
- for (const { node: waitedNode, prop } of keys.waitsFor(node)) {
499
+ // a wait, and stays deferrable to break cycles. The other waits aren't. A
500
+ // key change is waited for at the node that writes it, not at a `#ref`
501
+ // model of its row, which writes nothing:
502
+ for (const { node: waitedNode, prop, isKeyChange } of keys.waitsFor(node)) {
463
503
  const dependency = (dependencies.get(node) || []).find(
464
504
  (dependency) =>
465
- getRow(dependency.node) === getRow(waitedNode) &&
505
+ (isKeyChange
506
+ ? dependency.node === waitedNode
507
+ : getRow(dependency.node) === getRow(waitedNode)) &&
466
508
  (!dependency.relation || getKeyProps(dependency, node).includes(prop)),
467
509
  );
468
510
 
469
511
  if (dependency) {
470
512
  dependency.isKeyWait = true;
513
+ dependency.isKeyChange ||= isKeyChange;
471
514
  } else {
472
- addDependency(node, { node: waitedNode, isKeyWait: true });
515
+ addDependency(node, { node: waitedNode, isKeyWait: true, isKeyChange });
516
+ }
517
+ }
518
+
519
+ // A `#ref{}` that copies a value that the graph changes waits for the
520
+ // change, as a foreign key may reference it, like the keys (#491). As the
521
+ // copied value may be no key, the wait is deferred first to break cycles:
522
+ for (const reference of node.propertyReferences) {
523
+ for (const changingNode of keys.getChangingNodes(node, reference)) {
524
+ addDependency(node, { node: changingNode, isKeyChange: true, isCopy: true });
473
525
  }
474
526
  }
475
527
 
@@ -9,6 +9,7 @@ import {
9
9
  checkChangedIds,
10
10
  checkGraph,
11
11
  checkJoinTables,
12
+ checkKeyRelates,
12
13
  checkMissingModels,
13
14
  checkPlacements,
14
15
  checkReadKeys,
@@ -100,6 +101,7 @@ export class GraphUpsert {
100
101
  }
101
102
 
102
103
  checkChangedIds(builder, plan);
104
+ checkKeyRelates(builder, plan);
103
105
  checkReferences(builder, plan);
104
106
  checkJoinTables(builder, plan);
105
107
  await new GraphWriter(new GraphSchedule(plan, keys)).write(builder);