objection 4.0.0-rc.2 → 4.0.0-rc.4

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.
@@ -1,27 +1,14 @@
1
- import { hasSameColumns, hasSameKey } from './graphKeys.js';
1
+ import { hasOwnerKey, hasSameColumns, hasSameKey } from './graphKeys.js';
2
+ import { groupBy } from '../../utils/objectUtils.js';
2
3
 
3
4
  // What a graph insert or upsert does with each model of the graph and each row
4
5
  // of the current graph: The rules that decide it, and the pruning of branches
5
6
  // that nothing is written in.
6
7
  export class GraphPlan {
7
- constructor({ nodes, graphOptions, keptNodes = new Map(), laterNodes = new Set() }) {
8
+ constructor({ nodes, graphOptions }) {
8
9
  this.nodes = nodes;
9
- // The nodes that an earlier pass left to this one before writing them:
10
- this.laterNodes = laterNodes;
11
- this.currentNodes = [];
12
- this.deferredNodes = new Set();
13
- // The nodes that earlier passes of the graph decided to keep or inserted,
14
- // by row:
15
- this.earlierKeptNodes = keptNodes;
16
10
  this.graphOptions = graphOptions;
17
- this.actions = null;
18
- // The decisions only change while the current graph is loaded, and are
19
- // cached once it is, see `cacheDecisions()`:
20
- this._intents = null;
21
- this._nodeActions = null;
22
- this._activeNodes = null;
23
- this._activeCurrentNodes = null;
24
- this._keptNodesByRowKey = null;
11
+ this.reset();
25
12
  }
26
13
 
27
14
  get activeNodes() {
@@ -34,16 +21,24 @@ export class GraphPlan {
34
21
  ));
35
22
  }
36
23
 
37
- // Leave `nodes` to a later pass of the graph.
38
- defer(nodes) {
39
- this.deferredNodes = new Set(nodes);
40
- this.nodes = this.nodes.filter((node) => !this.deferredNodes.has(node));
41
- this._activeNodes = null;
42
- }
24
+ // Forget the current graph and what was decided with it, before it's loaded.
25
+ // The decisions only change while it's loaded, and are cached once it is,
26
+ // see `plan()`.
27
+ reset() {
28
+ for (const node of this.nodes) {
29
+ node.current = null;
30
+ node.row = null;
31
+ node.isMissing = false;
32
+ node.isPruned = false;
33
+ }
43
34
 
44
- cacheDecisions() {
45
- this._intents = new Map();
46
- this._nodeActions = new Map();
35
+ this.currentNodes = [];
36
+ this.actions = null;
37
+ this._intents = null;
38
+ this._nodeActions = null;
39
+ this._activeNodes = null;
40
+ this._activeCurrentNodes = null;
41
+ this._keptNodesByRowKey = null;
47
42
  }
48
43
 
49
44
  // What the data asks to do with `node`, before the options that disable it:
@@ -65,23 +60,48 @@ export class GraphPlan {
65
60
  _getIntent(node) {
66
61
  const insertMissing = this.graphOptions.has(node, 'insertMissing');
67
62
 
68
- return node.toBeUnrelatedOrDeleted
69
- ? 'remove'
70
- : node.isReference
71
- ? 'relate'
72
- : node.isMissing
73
- ? insertMissing
74
- ? 'insert'
75
- : this.findInsertingNode(node)
76
- ? 'relate'
77
- : 'missing'
78
- : node.isDbReference || this._canRelate(node)
79
- ? 'relate'
80
- : node.current
81
- ? 'patch'
82
- : !node.hasId || insertMissing
83
- ? 'insert'
84
- : 'missing';
63
+ if (node.toBeUnrelatedOrDeleted) {
64
+ return 'remove';
65
+ } else if (node.isReference) {
66
+ return 'relate';
67
+ } else if (node.isMissing) {
68
+ return insertMissing ? 'insert' : this.findInsertingNode(node) ? 'relate' : 'missing';
69
+ } else if (node.isDbReference || this._canRelate(node)) {
70
+ return 'relate';
71
+ } else if (node.current) {
72
+ return 'patch';
73
+ } else if (!node.hasId || insertMissing) {
74
+ return 'insert';
75
+ } else {
76
+ return this._isRelatedByOthers(node) ? 'relate' : 'missing';
77
+ }
78
+ }
79
+
80
+ // Return true if another model of the graph relates the row of `node` to the
81
+ // same place, the same relation of the same row, as below the owner that a
82
+ // moved row names.
83
+ _isRelatedByOthers(node) {
84
+ return this.nodes.some(
85
+ (other) =>
86
+ other !== node &&
87
+ other.rowKey === node.rowKey &&
88
+ other.relation === node.relation &&
89
+ other.parent?.rowKey === node.parent?.rowKey &&
90
+ this._canRelate(other),
91
+ );
92
+ }
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
+ );
85
105
  }
86
106
 
87
107
  // Return true if the model of the `#ref` model `node` writes what `node`
@@ -182,13 +202,11 @@ export class GraphPlan {
182
202
 
183
203
  // Leave out the branches that require no writes. We never do anything below
184
204
  // models that are removed, and we never delete recursively.
185
- prune() {
205
+ _prune() {
186
206
  const isInsertOnly = this.graphOptions.isInsertOnly();
187
- const passNodes = new Set([...this.nodes, ...this.currentNodes]);
188
- // The rows that were fetched for pruned nodes are left alone too. Nodes of
189
- // later passes aren't pruned here:
207
+ // The rows that were fetched for pruned nodes are left alone too:
190
208
  const pruneNodes = (nodes) => {
191
- for (const node of nodes.filter((node) => passNodes.has(node))) {
209
+ for (const node of nodes) {
192
210
  node.isPruned = true;
193
211
 
194
212
  for (const row of node.row ? [node.row, ...node.row.descendants] : []) {
@@ -210,10 +228,7 @@ export class GraphPlan {
210
228
  (node) =>
211
229
  !node.current &&
212
230
  this.intentFor(node) !== 'insert' &&
213
- !(
214
- this.actionFor(node) === 'relate' &&
215
- (node.row || (node.isReference && !this.graphOptions.isInsertOnly()))
216
- ),
231
+ !(this.actionFor(node) === 'relate' && (node.row || (node.isReference && !isInsertOnly))),
217
232
  ),
218
233
  );
219
234
 
@@ -289,14 +304,16 @@ export class GraphPlan {
289
304
  pruneDescendants(markedNodes);
290
305
  pruneDescendants(markedNodes.map((node) => node.current).filter(Boolean));
291
306
  }
292
-
293
- return this;
294
307
  }
295
308
 
296
- // Decide what to do with each node once, before the first write, since the
297
- // writes change the models that the rules read. Only call this after
298
- // pruning.
309
+ // Decide what to do with each node once the current graph is loaded, and
310
+ // before the first write, since the writes change the models that the rules
311
+ // read: Cache the decisions, leave out the branches that require no writes,
312
+ // and set `actions`.
299
313
  plan() {
314
+ this._intents = new Map();
315
+ this._nodeActions = new Map();
316
+ this._prune();
300
317
  const actions = new Map();
301
318
 
302
319
  for (const node of this.activeNodes) {
@@ -308,7 +325,6 @@ export class GraphPlan {
308
325
  }
309
326
 
310
327
  this.actions = actions;
311
- return this;
312
328
  }
313
329
 
314
330
  _canRelate(node) {
@@ -336,14 +352,13 @@ export class GraphPlan {
336
352
  );
337
353
  }
338
354
 
339
- // A row that the graph keeps in another place, or that an earlier pass
340
- // inserted, is unrelated here, unless one of these places sets the same key
341
- // column, by relating, patching or inserting it through such a relation, or
342
- // through its own BelongsToOneRelation, e.g. `parent` for `children`: Then
343
- // that place writes the key, which may not be nullable. A has-one row that
344
- // its own BelongsToOneRelation or a later pass keeps is still unrelated if
345
- // this pass fills its slot with another row, as the foreign key may be
346
- // unique (#398).
355
+ // A row that the graph keeps in another place is unrelated here, unless one
356
+ // of these places sets the same key column, by relating, patching or
357
+ // inserting it through such a relation, or through its own
358
+ // BelongsToOneRelation, e.g. `parent` for `children`: Then that place writes
359
+ // the key, which may not be nullable. A has-one row that its own
360
+ // BelongsToOneRelation keeps is still unrelated if the graph fills its slot
361
+ // with another row, as the foreign key may be unique (#398).
347
362
  _isUnrelatedWhenKept(currentNode, keptNode) {
348
363
  const { relation } = currentNode;
349
364
  const isKeptInRelation = hasSameKey(relation, keptNode.relation);
@@ -358,11 +373,11 @@ export class GraphPlan {
358
373
  return (
359
374
  relation.keySide !== 'related' ||
360
375
  !(isKeptInRelation || isKeptByOwner) ||
361
- ((isKeptByOwner || this.deferredNodes.has(keptNode)) && this._hasNewRowInSlot(currentNode))
376
+ (isKeptByOwner && this._hasNewRowInSlot(currentNode))
362
377
  );
363
378
  }
364
379
 
365
- // Return true if this pass inserts or relates a row into the slot of the
380
+ // Return true if the graph inserts or relates a row into the slot of the
366
381
  // has-one relation that `currentNode` is in.
367
382
  _hasNewRowInSlot(currentNode) {
368
383
  return (
@@ -376,51 +391,33 @@ export class GraphPlan {
376
391
  );
377
392
  }
378
393
 
379
- // The nodes that keep the row of `node` in other places of the graph, by
380
- // relating or patching it there, or that an earlier pass inserted.
381
- getKeptNodes(node) {
382
- return this.keptNodes.get(node.rowKey) || [];
383
- }
384
-
385
- // The nodes that keep their rows in the next passes, once this one is
386
- // written: the ones that it decided to keep before its writes, and the ones
387
- // that it inserts.
388
- getNextKeptNodes() {
389
- const keptNodes = new Map(this.keptNodes);
390
-
391
- for (const node of this.activeNodes) {
392
- if (this.actions.get(node) === 'insert') {
393
- addKeptNode(keptNodes, node);
394
- }
395
- }
396
-
397
- return keptNodes;
394
+ // The BelongsToOne relations whose current rows `node` removes with `null`,
395
+ // `#unrelate` or `#delete`, and whose keys it clears.
396
+ getRemovedOwnerRelations(node) {
397
+ return (node.existing?.activeChildren || [])
398
+ .filter(
399
+ (currentChild) =>
400
+ hasOwnerKey(currentChild.relation) &&
401
+ (node.obj[currentChild.relation.name] === null ||
402
+ currentChild.node?.toBeUnrelatedOrDeleted) &&
403
+ this.actions.get(currentChild) !== 'keep',
404
+ )
405
+ .map(({ relation }) => relation);
398
406
  }
399
407
 
400
- // The nodes that keep their rows by relating or patching them, by row, also
401
- // the ones that earlier passes decided to keep or inserted. A row may be kept
402
- // in several places.
403
- get keptNodes() {
404
- if (!this._keptNodesByRowKey) {
405
- this._keptNodesByRowKey = new Map(this.earlierKeptNodes);
406
-
407
- for (const node of [...this.activeNodes, ...this.deferredNodes]) {
408
- const action = this.actionFor(node);
409
-
410
- if (node.obj.$hasId() && (action === 'relate' || action === 'patch')) {
411
- addKeptNode(this._keptNodesByRowKey, node);
412
- }
413
- }
414
- }
415
-
416
- return this._keptNodesByRowKey;
408
+ // The nodes that keep the row of `node` in other places of the graph, by
409
+ // relating or patching it there. A row may be kept in several places.
410
+ getKeptNodes(node) {
411
+ this._keptNodesByRowKey ??= groupBy(
412
+ this.activeNodes.filter(
413
+ (node) => node.obj.$hasId() && ['relate', 'patch'].includes(this.actionFor(node)),
414
+ ),
415
+ (node) => node.rowKey,
416
+ );
417
+ return this._keptNodesByRowKey.get(node.rowKey) || [];
417
418
  }
418
419
  }
419
420
 
420
- function addKeptNode(keptNodes, node) {
421
- keptNodes.set(node.rowKey, [...(keptNodes.get(node.rowKey) || []), node]);
422
- }
423
-
424
421
  // For `HasMany` and `HasOne` relations, the relate prop is the id of the
425
422
  // related model. Use the id state of the input graph, since the parent's
426
423
  // insert copies the foreign key into the related model, which can complete
@@ -1,20 +1,27 @@
1
- import { hasOwnerKey, hasSameKey } from './graphKeys.js';
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, as a foreign key may
19
+ // reference them (#428).
15
20
  // 4. Patch the changed rows, the other relates, and the keys of owners whose
16
21
  // BelongsToOne rows were inserted, related or removed.
17
- // 5. Remove the BelongsToOne rows that the owners no longer reference.
22
+ // 5. Unrelate the rows that their own to-many relation on their own key leaves
23
+ // out, e.g. a pet in its own `siblings`, after their own writes.
24
+ // 6. Remove the BelongsToOne rows that the owners no longer reference.
18
25
  // Each step writes a row that goes into the slot of a HasOneRelation after the
19
26
  // row that moves out of it, unless that closes a cycle.
20
27
  // `insertGraph()` only inserts.
@@ -23,27 +30,20 @@ export class GraphSchedule {
23
30
  this.plan = plan;
24
31
  this.keys = keys;
25
32
  // The nodes that each node waits for, through its keys, its references and
26
- // the rows that move out of the slots that it goes into:
33
+ // the rows that move out of the slots that it goes into. `isKeyChange`
34
+ // marks the patches and relates that change the keys it reads:
27
35
  this.dependencies = createDependencies(plan, this.keys);
28
- // Dependencies that aren't waited for, to break cycles, and the ones of
29
- // owners that earlier passes wrote, whose keys are patched in once their
30
- // BelongsToOne rows of this pass are written. Deferred keys are written
31
- // after the inserts:
32
- const activeNodes = new Set(plan.activeNodes);
33
- this.deferredDependencies = new Set(
34
- [...this.dependencies].flatMap(([node, dependencies]) =>
35
- activeNodes.has(node) ? [] : dependencies.filter((dependency) => dependency.relation),
36
- ),
37
- );
36
+ // Dependencies that aren't waited for, to break cycles. Deferred keys are
37
+ // written after the inserts:
38
+ this.deferredDependencies = new Set();
38
39
  // Relates whose rows are moved to their new owners before the removals:
39
40
  this.movedNodes = new Set(
40
41
  plan.graphOptions.isInsertOnly()
41
42
  ? []
42
43
  : plan.activeNodes.filter((node) => this._isMovedFirst(node)),
43
44
  );
44
- // Relates that move the rows out of the slots of HasOneRelations that new
45
- // rows go into, and that aren't moved first:
46
- this.slotMovedNodes = this._getSlotMovedNodes();
45
+ // Relates and patches that aren't moved first but written in the inserts:
46
+ this.earlyNodes = this._getEarlyNodes();
47
47
  this.steps = this._planSteps();
48
48
  }
49
49
 
@@ -76,12 +76,19 @@ export class GraphSchedule {
76
76
  } else {
77
77
  const removedNodes = activeCurrentNodes.filter((node) => actions.get(node) !== 'keep');
78
78
  const isReferencedByOwner = (currentNode) => currentNode.relation.keySide === 'owner';
79
+ // A row that its own to-many relation on its own key leaves out, e.g. a
80
+ // pet in its own `siblings`, is unrelated after its own writes, which keep
81
+ // its key. A row in its own slot leaves it before another one goes in:
82
+ const isOwnRow = ({ relation, parent, rowKey }) =>
83
+ rowKey === parent.rowKey &&
84
+ !relation.isOneToOne() &&
85
+ hasSameColumns(relation.relatedProp, relation.ownerProp);
79
86
 
80
87
  return [
81
88
  ...this._planLevels([...this.movedNodes]).map(({ nodes }) => ({ kind: 'move', nodes })),
82
89
  {
83
90
  kind: 'remove',
84
- nodes: removedNodes.filter((node) => !isReferencedByOwner(node)),
91
+ nodes: removedNodes.filter((node) => !isReferencedByOwner(node) && !isOwnRow(node)),
85
92
  areReferenced: true,
86
93
  },
87
94
  ...insertSteps,
@@ -89,15 +96,19 @@ export class GraphSchedule {
89
96
  activeNodes.filter((node) => {
90
97
  const action = actions.get(node);
91
98
  return (
92
- (action === 'patch' ||
93
- (action === 'relate' &&
94
- !this.movedNodes.has(node) &&
95
- !this.slotMovedNodes.has(node))) &&
99
+ ['patch', 'relate'].includes(action) &&
100
+ !this.movedNodes.has(node) &&
101
+ !this.earlyNodes.has(node) &&
96
102
  // The row of a `#ref` model is written once, by its model:
97
103
  !this._isWrittenByModel(node)
98
104
  );
99
105
  }),
100
106
  ).map(({ nodes }) => ({ kind: 'patch', nodes })),
107
+ {
108
+ kind: 'remove',
109
+ nodes: removedNodes.filter(isOwnRow),
110
+ areReferenced: true,
111
+ },
101
112
  {
102
113
  kind: 'remove',
103
114
  // Owners unrelate their BelongsToOne rows by patching their keys:
@@ -123,8 +134,8 @@ export class GraphSchedule {
123
134
  // The keys of models with rows are known, also of related models that were
124
135
  // fetched, except for the props that the graph fills in:
125
136
  const hasRow = (node) => !!(node.referencedNode || node).existing;
126
- // Nodes outside this pass were written by an earlier one, or are never
127
- // written, see `checkReferences()`:
137
+ // Nodes outside of `nodes` are written in other steps, or never, see
138
+ // `checkReferences()`:
128
139
  const getPendingDependencies = (node) =>
129
140
  (this.dependencies.get(node) || []).filter(
130
141
  (dependency) =>
@@ -205,7 +216,25 @@ export class GraphSchedule {
205
216
  }
206
217
  }
207
218
  } else if (!breakCycle()) {
208
- throw new Error('the object graph contains cyclic references');
219
+ // A patch that changes a key that the rows it waits for read can be
220
+ // written neither before nor after them:
221
+ const { node: changingNode } =
222
+ nodes
223
+ .filter((node) => !handledNodes.has(node))
224
+ .flatMap(getPendingDependencies)
225
+ .find(
226
+ ({ node, dependent, isKeyChange }) => isKeyChange && isBlockedBy(node, dependent),
227
+ ) || {};
228
+
229
+ if (changingNode) {
230
+ const { modelClass, dataPathKey } = changingNode;
231
+ throw modelClass.createValidationError({
232
+ type: ValidationErrorType.InvalidGraph,
233
+ 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`,
234
+ });
235
+ } else {
236
+ throw new Error('the object graph contains cyclic references');
237
+ }
209
238
  }
210
239
  }
211
240
 
@@ -238,9 +267,10 @@ export class GraphSchedule {
238
267
 
239
268
  // Return true if the HasMany or HasOne relate of `node` moves its row to an
240
269
  // existing owner before the removals, see the steps above. `#ref` models may
241
- // get their ids from the inserts, rows that wait for the inserts are written
242
- // once they're known, and a relate into the slot of a HasOne row waits for
243
- // the removal of that row, or its move, as its key may be unique.
270
+ // get their ids from the inserts, rows that wait for the inserts or for
271
+ // changed keys are written after them, and a relate into the slot of a HasOne
272
+ // row waits for the removal of that row, or its move, as its key may be
273
+ // unique.
244
274
  _isMovedFirst(node, visitedNodes = new Set()) {
245
275
  const { actions } = this.plan;
246
276
  visitedNodes.add(node);
@@ -250,7 +280,7 @@ export class GraphSchedule {
250
280
  !node.isReference &&
251
281
  node.relation.keySide === 'related' &&
252
282
  node.parent.existing &&
253
- !this._waitsForInserts(node)
283
+ !this._waitsForWrites(node)
254
284
  ) {
255
285
  const slotNode = getSlotNode(node);
256
286
  const moverNode = getSlotMover(this.plan, node);
@@ -264,24 +294,32 @@ export class GraphSchedule {
264
294
  }
265
295
  }
266
296
 
267
- // Find the relates that move the rows out of the slots of HasOneRelations
268
- // that new rows go into, and that aren't moved first. They're related in the
269
- // inserts, along with the relates that move the rows out of their own slots,
270
- // as the foreign key may be unique.
271
- _getSlotMovedNodes() {
297
+ // Find the relates and patches that are written in the inserts, as the rows
298
+ // written there wait for them: The relates that move the rows out of the
299
+ // slots of HasOneRelations that new rows go into, along with the relates that
300
+ // move the rows out of their own slots, as the foreign key may be unique, and
301
+ // the patches and relates that change the keys that these rows or join rows
302
+ // read, as a foreign key may reference them (#428).
303
+ _getEarlyNodes() {
304
+ const { actions } = this.plan;
272
305
  const nodes = new Set();
273
- const addMoverNodes = (node) => {
274
- for (const { node: moverNode, isSlotMove } of this.dependencies.get(node) || []) {
275
- if (isSlotMove && !this.movedNodes.has(moverNode) && !nodes.has(moverNode)) {
276
- nodes.add(moverNode);
277
- addMoverNodes(moverNode);
306
+ const addEarlyNodes = (node) => {
307
+ for (const dependency of this.dependencies.get(node) || []) {
308
+ const { node: earlyNode, isSlotMove, isKeyChange } = dependency;
309
+ if (
310
+ (isSlotMove || isKeyChange) &&
311
+ !this.movedNodes.has(earlyNode) &&
312
+ !nodes.has(earlyNode)
313
+ ) {
314
+ nodes.add(earlyNode);
315
+ addEarlyNodes(earlyNode);
278
316
  }
279
317
  }
280
318
  };
281
319
 
282
320
  for (const node of this.plan.activeNodes) {
283
- if (this.plan.actions.get(node) === 'insert') {
284
- addMoverNodes(node);
321
+ if (actions.get(node) === 'insert' || (node.relation?.keySide === 'join' && !node.current)) {
322
+ addEarlyNodes(node);
285
323
  }
286
324
  }
287
325
 
@@ -310,19 +348,21 @@ export class GraphSchedule {
310
348
  }
311
349
 
312
350
  // Return true if `node` waits for the keys or references of inserted models,
313
- // also through the models that it references (#356), and through the models
314
- // that it waits for to get its keys.
315
- _waitsForInserts(node, visitedNodes = new Set()) {
351
+ // or for keys that patches change (#428), also through the models that it
352
+ // references (#356), and through the models that it waits for to get its
353
+ // keys.
354
+ _waitsForWrites(node, visitedNodes = new Set()) {
316
355
  const { actions } = this.plan;
317
356
  visitedNodes.add(node);
318
357
  return (this.dependencies.get(node) || []).some((dependency) => {
319
- const { node: dependencyNode, relation } = dependency;
358
+ const { node: dependencyNode, relation, isKeyWait, isKeyChange } = dependency;
320
359
  const referencedNode = dependencyNode.referencedNode || dependencyNode;
321
360
  return (
322
361
  actions.get(referencedNode) === 'insert' ||
323
- ((!relation || dependency.isKeyWait) &&
362
+ isKeyChange ||
363
+ ((!relation || isKeyWait) &&
324
364
  !visitedNodes.has(referencedNode) &&
325
- this._waitsForInserts(referencedNode, visitedNodes))
365
+ this._waitsForWrites(referencedNode, visitedNodes))
326
366
  );
327
367
  });
328
368
  }
@@ -347,7 +387,6 @@ export class GraphSchedule {
347
387
  // their cycle: the strongly connected components of these references, found
348
388
  // with Tarjan's algorithm.
349
389
  findReferenceCycles(nodes) {
350
- const getRow = (node) => node.referencedNode || node;
351
390
  const nodesByRow = groupBy(nodes, getRow);
352
391
  const getReferencedNodes = (node) =>
353
392
  (this.dependencies.get(node) || [])
@@ -452,20 +491,23 @@ function createDependencies(plan, keys) {
452
491
 
453
492
  // A place waits for the inserts and `#ref{}` values that bind the slots of
454
493
  // its keys. A dependency on the same row through the same key becomes such
455
- // a wait, and stays deferrable to break cycles. The other waits aren't:
456
- const getRow = (node) => node.referencedNode || node;
457
-
458
- for (const { node: waitedNode, prop } of keys.waitsFor(node)) {
494
+ // a wait, and stays deferrable to break cycles. The other waits aren't. A
495
+ // key change is waited for at the node that writes it, not at a `#ref`
496
+ // model of its row, which writes nothing:
497
+ for (const { node: waitedNode, prop, isKeyChange } of keys.waitsFor(node)) {
459
498
  const dependency = (dependencies.get(node) || []).find(
460
499
  (dependency) =>
461
- getRow(dependency.node) === getRow(waitedNode) &&
500
+ (isKeyChange
501
+ ? dependency.node === waitedNode
502
+ : getRow(dependency.node) === getRow(waitedNode)) &&
462
503
  (!dependency.relation || getKeyProps(dependency, node).includes(prop)),
463
504
  );
464
505
 
465
506
  if (dependency) {
466
507
  dependency.isKeyWait = true;
508
+ dependency.isKeyChange ||= isKeyChange;
467
509
  } else {
468
- addDependency(node, { node: waitedNode, isKeyWait: true });
510
+ addDependency(node, { node: waitedNode, isKeyWait: true, isKeyChange });
469
511
  }
470
512
  }
471
513
 
@@ -507,6 +549,11 @@ function getSlotNode(node) {
507
549
  : null;
508
550
  }
509
551
 
552
+ // The row of `node`: a `#ref` model is the model it refers to.
553
+ function getRow(node) {
554
+ return node.referencedNode || node;
555
+ }
556
+
510
557
  // Return the key props of `node` on its side of the relation of `dependency`.
511
558
  function getKeyProps({ relation, related }, node) {
512
559
  return (related === node ? relation.relatedProp : relation.ownerProp).props;