@warp-drive-mirror/json-api 5.10.0-alpha.0 → 5.10.0-alpha.10

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,5 +1,5 @@
1
1
  import { graphFor, isBelongsTo, peekGraph } from "@warp-drive-mirror/core/graph/-private";
2
- import { assertPrivateCapabilities, isRequestKey, isResourceKey } from "@warp-drive-mirror/core/store/-private";
2
+ import { assertPrivateCapabilities, fieldValueIdentity, isRequestKey, isResourceKey } from "@warp-drive-mirror/core/store/-private";
3
3
  import "fuse.js";
4
4
  import "json-to-ast";
5
5
 
@@ -24,14 +24,101 @@ const EMPTY_ITERATOR = { iterator() {
24
24
  };
25
25
  } };
26
26
  } };
27
+ /** One attributes hash on a {@link CachedResource}: a **layer** a projection is read through. */
28
+ /**
29
+ * The cache's entry for a single resource: its id, its attribute values split
30
+ * across four **layers**, and the flags tracking where it sits in the
31
+ * create/update/delete lifecycle.
32
+ *
33
+ * A **projection** answers "what is this field's value" by reading a stack of
34
+ * layers top-down; the first layer holding the field wins. The two projections,
35
+ * with their resolution orders:
36
+ *
37
+ * - **remote state**, what the _immutable_ record reads:
38
+ * {@link RESOLUTION_ORDER_REMOTE_STATE}
39
+ * - **local state**, what an _editable_ copy reads:
40
+ * {@link RESOLUTION_ORDER_LOCAL_STATE}
41
+ *
42
+ * Local state is remote state with the uncommitted mutations laid over it. The
43
+ * guides call those mutations "the diff"; here they are simply the top layers of
44
+ * local state, {@link CachedResource.localAttrs | localAttrs} and
45
+ * {@link CachedResource.inflightAttrs | inflightAttrs}.
46
+ *
47
+ * A field is **dirty** while `localAttrs` or `inflightAttrs` holds it. A save
48
+ * **commits** the mutation: `didCommit` merges the in-flight values into
49
+ * `remoteAttrs` and removes them from `inflightAttrs`. A push carrying the same
50
+ * value as a pending edit in `localAttrs` has the same effect on that edit: the
51
+ * server already holds it, so it is removed from `localAttrs`. "Same value" is
52
+ * decided by the field's schema: a schema-object with an identity hash compares
53
+ * by that hash, everything else by reference.
54
+ *
55
+ * Thus, a dirty field reads as mutated for local readers only, and goes on doing
56
+ * so while its save is in flight.
57
+ *
58
+ * @internal
59
+ */
60
+ /** A layer name on a {@link CachedResource}. */
61
+ /**
62
+ * A layer that exists only for the duration of a merge: the attributes arriving from the
63
+ * server in a save response or a push. {@link partitionChangedKeys} reads it as if it were
64
+ * already on the resource; {@link mergeIntoRemote} then folds the same attributes in, handed
65
+ * to it directly. Never part of a reader's resolution order.
66
+ */
67
+ /** Anything a projection can be read through: a {@link CachedResource}, or the layers a merge reads, built around one. */
68
+ /** What the immutable record reads. */
69
+ const RESOLUTION_ORDER_REMOTE_STATE = ["remoteAttrs", "defaultAttrs"];
70
+ /**
71
+ * What a new local edit replaces: the in-flight value if a save is carrying one, else the
72
+ * persisted value. Put another way, what the record will read once the in-flight save lands,
73
+ * assuming the server agrees. This is local state beneath `localAttrs`, less `defaultAttrs`: an
74
+ * edit equal to a schema default is still an edit, since nothing persisted holds that value.
75
+ *
76
+ * Saving does not use this baseline. {@link JSONAPICache.changedAttrs | changedAttrs} still lists
77
+ * in-flight values as unsaved, so a second save re-sends them rather than assuming the first one
78
+ * will succeed.
79
+ */
80
+ const RESOLUTION_ORDER_EDIT_BASELINE = ["inflightAttrs", "remoteAttrs"];
81
+ /** What an editable copy reads. */
82
+ const RESOLUTION_ORDER_LOCAL_STATE = [
83
+ "localAttrs",
84
+ ...RESOLUTION_ORDER_EDIT_BASELINE,
85
+ "defaultAttrs"
86
+ ];
87
+ const RESOLUTION_ORDER_REMOTE_AFTER_COMMIT = [
88
+ "incomingAttrs",
89
+ "inflightAttrs",
90
+ ...RESOLUTION_ORDER_REMOTE_STATE
91
+ ];
92
+ const RESOLUTION_ORDER_LOCAL_AFTER_COMMIT = ["localAttrs", ...RESOLUTION_ORDER_REMOTE_AFTER_COMMIT];
93
+ const RESOLUTION_ORDER_REMOTE_AFTER_UPSERT = ["incomingAttrs", ...RESOLUTION_ORDER_REMOTE_STATE];
94
+ const RESOLUTION_ORDER_LOCAL_AFTER_UPSERT = [
95
+ "localAttrs",
96
+ "inflightAttrs",
97
+ ...RESOLUTION_ORDER_REMOTE_AFTER_UPSERT
98
+ ];
99
+ /** The layers an after-merge order folds into `remoteAttrs`, lowest precedence first. */
100
+ function layersFoldedIntoRemote(order) {
101
+ return order.slice(0, order.indexOf("remoteAttrs")).reverse();
102
+ }
103
+ const MERGE_RESOLUTION = {
104
+ commit: {
105
+ remoteAfter: RESOLUTION_ORDER_REMOTE_AFTER_COMMIT,
106
+ localAfter: RESOLUTION_ORDER_LOCAL_AFTER_COMMIT,
107
+ folded: layersFoldedIntoRemote(RESOLUTION_ORDER_REMOTE_AFTER_COMMIT)
108
+ },
109
+ upsert: {
110
+ remoteAfter: RESOLUTION_ORDER_REMOTE_AFTER_UPSERT,
111
+ localAfter: RESOLUTION_ORDER_LOCAL_AFTER_UPSERT,
112
+ folded: layersFoldedIntoRemote(RESOLUTION_ORDER_REMOTE_AFTER_UPSERT)
113
+ }
114
+ };
27
115
  function makeCache() {
28
116
  return {
29
117
  id: null,
30
- remoteAttrs: null,
31
118
  localAttrs: null,
32
- defaultAttrs: null,
33
119
  inflightAttrs: null,
34
- changes: null,
120
+ remoteAttrs: null,
121
+ defaultAttrs: null,
35
122
  errors: null,
36
123
  isNew: false,
37
124
  isDeleted: false,
@@ -82,9 +169,9 @@ var JSONAPICache = class {
82
169
  /**
83
170
  * Cache the response to a request
84
171
  *
85
- * Implements `Cache.put`.
172
+ * Implements {@link Cache.put | Cache.put}.
86
173
  *
87
- * Expects a StructuredDocument whose `content` member is a JsonApiDocument.
174
+ * Expects a {@link StructuredDocument} whose `content` member is a JsonApiDocument.
88
175
  *
89
176
  * ```js
90
177
  * cache.put({
@@ -168,6 +255,16 @@ var JSONAPICache = class {
168
255
  * Update the "remote" or "canonical" (persisted) state of the Cache
169
256
  * by merging new information into the existing state.
170
257
  *
258
+ * @example
259
+ * ```ts
260
+ * cache.patch({
261
+ * op: 'update',
262
+ * record: identifier,
263
+ * field: 'name',
264
+ * value: 'Chris',
265
+ * });
266
+ * ```
267
+ *
171
268
  * @category Cache Management
172
269
  * @public
173
270
  * @param op the operation or list of operations to perform
@@ -183,6 +280,16 @@ var JSONAPICache = class {
183
280
  /**
184
281
  * Update the "local" or "current" (unpersisted) state of the Cache
185
282
  *
283
+ * @example
284
+ * ```ts
285
+ * cache.mutate({
286
+ * op: 'replaceRelatedRecord',
287
+ * record: identifier,
288
+ * field: 'author',
289
+ * value: authorIdentifier,
290
+ * });
291
+ * ```
292
+ *
186
293
  * @category Cache Management
187
294
  * @public
188
295
  */
@@ -209,8 +316,8 @@ var JSONAPICache = class {
209
316
  * not require retainining connections to the Store
210
317
  * and Cache to present data on a per-field basis.
211
318
  *
212
- * This generally takes the place of `getAttr` as
213
- * an API and may even take the place of `getRelationship`
319
+ * This generally takes the place of {@link JSONAPICache.getAttr | getAttr} as
320
+ * an API and may even take the place of {@link JSONAPICache.getRelationship | getRelationship}
214
321
  * depending on implementation specifics, though this
215
322
  * latter usage is less recommended due to the advantages
216
323
  * of the Graph handling necessary entanglements and
@@ -225,6 +332,12 @@ var JSONAPICache = class {
225
332
  * the various internal WarpDrive bookkeeping fields.
226
333
  * :::
227
334
  *
335
+ * @example
336
+ * ```ts
337
+ * const resource = cache.peek(identifier);
338
+ * const document = cache.peek(requestKey);
339
+ * ```
340
+ *
228
341
  * @category Cache Management
229
342
  * @public
230
343
  */
@@ -264,6 +377,12 @@ var JSONAPICache = class {
264
377
  /**
265
378
  * Peek the remote resource data from the Cache.
266
379
  *
380
+ * @example
381
+ * ```ts
382
+ * const resource = cache.peekRemoteState(identifier);
383
+ * const document = cache.peekRemoteState(requestKey);
384
+ * ```
385
+ *
267
386
  * @category Cache Management
268
387
  * @public
269
388
  */
@@ -304,9 +423,14 @@ var JSONAPICache = class {
304
423
  * Peek the Cache for the existing request data associated with
305
424
  * a cacheable request.
306
425
  *
307
- * This is effectively the reverse of `put` for a request in
426
+ * This is effectively the reverse of {@link JSONAPICache.put | put} for a request in
308
427
  * that it will return the the request, response, and content
309
- * whereas `peek` will return just the `content`.
428
+ * whereas {@link JSONAPICache.peek | peek} will return just the `content`.
429
+ *
430
+ * @example
431
+ * ```ts
432
+ * const doc = cache.peekRequest(requestKey);
433
+ * ```
310
434
  *
311
435
  * @category Cache Management
312
436
  * @public
@@ -317,9 +441,20 @@ var JSONAPICache = class {
317
441
  /**
318
442
  * Push resource data from a remote source into the cache for this identifier
319
443
  *
444
+ * @example
445
+ * ```ts
446
+ * cache.upsert(identifier, {
447
+ * type: 'user',
448
+ * id: '1',
449
+ * attributes: { name: 'Chris' },
450
+ * });
451
+ * ```
452
+ *
320
453
  * @category Cache Management
321
454
  * @public
322
- * @return if `calculateChanges` is true then calculated key changes should be returned
455
+ * @return when `calculateChanges` is true, the names of the attributes whose persisted value
456
+ * this push changed (the same keys the `'remote'` channel is notified with), or `undefined`
457
+ * when none did. Otherwise `void`.
323
458
  */
324
459
  upsert(identifier, data, calculateChanges) {
325
460
  assertPrivateCapabilities(this._capabilities);
@@ -365,7 +500,7 @@ var JSONAPICache = class {
365
500
  *
366
501
  * Each individual resource or document that has
367
502
  * been mutated should be described as an individual
368
- * `Change` entry in the returned array.
503
+ * {@link Change} entry in the returned array.
369
504
  *
370
505
  * A `Change` is described by an object containing up to
371
506
  * three properties: (1) the `identifier` of the entity that
@@ -430,6 +565,11 @@ var JSONAPICache = class {
430
565
  * It returns properties from options that should be set on the record during the create
431
566
  * process. This return value behavior is deprecated.
432
567
  *
568
+ * @example
569
+ * ```ts
570
+ * cache.clientDidCreate(identifier, { name: 'Chris' });
571
+ * ```
572
+ *
433
573
  * @category Resource Lifecycle
434
574
  * @public
435
575
  */
@@ -486,6 +626,11 @@ var JSONAPICache = class {
486
626
  * [LIFECYCLE] Signals to the cache that a resource
487
627
  * will be part of a save transaction.
488
628
  *
629
+ * @example
630
+ * ```ts
631
+ * cache.willCommit(identifier, context);
632
+ * ```
633
+ *
489
634
  * @category Resource Lifecycle
490
635
  * @public
491
636
  */
@@ -497,6 +642,11 @@ var JSONAPICache = class {
497
642
  * [LIFECYCLE] Signals to the cache that a resource
498
643
  * was successfully updated as part of a save transaction.
499
644
  *
645
+ * @example
646
+ * ```ts
647
+ * cache.didCommit(identifier, result);
648
+ * ```
649
+ *
500
650
  * @category Resource Lifecycle
501
651
  * @public
502
652
  */
@@ -525,6 +675,11 @@ var JSONAPICache = class {
525
675
  * [LIFECYCLE] Signals to the cache that a resource
526
676
  * was update via a save transaction failed.
527
677
  *
678
+ * @example
679
+ * ```ts
680
+ * cache.commitWasRejected(identifier, errors);
681
+ * ```
682
+ *
528
683
  * @category Resource Lifecycle
529
684
  * @public
530
685
  */
@@ -541,6 +696,11 @@ var JSONAPICache = class {
541
696
  *
542
697
  * This method is a candidate to become a mutation
543
698
  *
699
+ * @example
700
+ * ```ts
701
+ * cache.unloadRecord(identifier);
702
+ * ```
703
+ *
544
704
  * @category Resource Lifecycle
545
705
  * @public
546
706
  */
@@ -581,6 +741,12 @@ var JSONAPICache = class {
581
741
  * Retrieve the data for an attribute from the cache
582
742
  * with local mutations applied.
583
743
  *
744
+ * @example
745
+ * ```ts
746
+ * const name = cache.getAttr(identifier, 'name');
747
+ * const zip = cache.getAttr(identifier, ['address', 'zip']);
748
+ * ```
749
+ *
584
750
  * @category Resource Data
585
751
  * @public
586
752
  */
@@ -591,37 +757,28 @@ var JSONAPICache = class {
591
757
  const attribute = attr;
592
758
  const cached = this.__peek(identifier, true);
593
759
  if (!cached) return;
594
- if (cached.localAttrs && attribute in cached.localAttrs) return cached.localAttrs[attribute];
595
- else if (cached.inflightAttrs && attribute in cached.inflightAttrs) return cached.inflightAttrs[attribute];
596
- else if (cached.remoteAttrs && attribute in cached.remoteAttrs) return cached.remoteAttrs[attribute];
597
- else if (cached.defaultAttrs && attribute in cached.defaultAttrs) return cached.defaultAttrs[attribute];
598
- else {
599
- const attrSchema = getCacheFields(this, identifier).get(attribute);
600
- assertPrivateCapabilities(this._capabilities);
601
- const defaultValue = getDefaultValue(attrSchema, identifier, this._capabilities._store);
602
- if (schemaHasLegacyDefaultValueFn(attrSchema)) {
603
- cached.defaultAttrs = cached.defaultAttrs || Object.create(null);
604
- cached.defaultAttrs[attribute] = defaultValue;
605
- }
606
- return defaultValue;
760
+ const layer = layerHolding(attribute, cached, RESOLUTION_ORDER_LOCAL_STATE);
761
+ if (layer) return layer[attribute];
762
+ const attrSchema = getCacheFields(this, identifier).get(attribute);
763
+ assertPrivateCapabilities(this._capabilities);
764
+ const defaultValue = getDefaultValue(attrSchema, identifier, this._capabilities._store);
765
+ if (schemaHasLegacyDefaultValueFn(attrSchema)) {
766
+ cached.defaultAttrs = cached.defaultAttrs || Object.create(null);
767
+ cached.defaultAttrs[attribute] = defaultValue;
607
768
  }
769
+ return defaultValue;
608
770
  }
609
- const path = attr;
610
771
  const cached = this.__peek(identifier, true);
611
- const basePath = path[0];
612
- let current = cached.localAttrs && basePath in cached.localAttrs ? cached.localAttrs[basePath] : void 0;
613
- if (current === void 0) current = cached.inflightAttrs && basePath in cached.inflightAttrs ? cached.inflightAttrs[basePath] : void 0;
614
- if (current === void 0) current = cached.remoteAttrs && basePath in cached.remoteAttrs ? cached.remoteAttrs[basePath] : void 0;
615
- if (current === void 0) return;
616
- for (let i = 1; i < path.length; i++) {
617
- current = current[path[i]];
618
- if (current === void 0) return;
619
- }
620
- return current;
772
+ return resolveAttr(attr, cached, RESOLUTION_ORDER_LOCAL_STATE);
621
773
  }
622
774
  /**
623
775
  * Retrieve the remote data for an attribute from the cache
624
776
  *
777
+ * @example
778
+ * ```ts
779
+ * const name = cache.getRemoteAttr(identifier, 'name');
780
+ * ```
781
+ *
625
782
  * @category Resource Data
626
783
  * @public
627
784
  */
@@ -632,54 +789,44 @@ var JSONAPICache = class {
632
789
  const attribute = attr;
633
790
  const cached = this.__peek(identifier, true);
634
791
  if (!cached) return;
635
- if (cached.remoteAttrs && attribute in cached.remoteAttrs) return cached.remoteAttrs[attribute];
636
- else if (cached.defaultAttrs && attribute in cached.defaultAttrs) return cached.defaultAttrs[attribute];
637
- else {
638
- const attrSchema = getCacheFields(this, identifier).get(attribute);
639
- assertPrivateCapabilities(this._capabilities);
640
- const defaultValue = getDefaultValue(attrSchema, identifier, this._capabilities._store);
641
- if (schemaHasLegacyDefaultValueFn(attrSchema)) {
642
- cached.defaultAttrs = cached.defaultAttrs || Object.create(null);
643
- cached.defaultAttrs[attribute] = defaultValue;
644
- }
645
- return defaultValue;
792
+ const layer = layerHolding(attribute, cached, RESOLUTION_ORDER_REMOTE_STATE);
793
+ if (layer) return layer[attribute];
794
+ const attrSchema = getCacheFields(this, identifier).get(attribute);
795
+ assertPrivateCapabilities(this._capabilities);
796
+ const defaultValue = getDefaultValue(attrSchema, identifier, this._capabilities._store);
797
+ if (schemaHasLegacyDefaultValueFn(attrSchema)) {
798
+ cached.defaultAttrs = cached.defaultAttrs || Object.create(null);
799
+ cached.defaultAttrs[attribute] = defaultValue;
646
800
  }
801
+ return defaultValue;
647
802
  }
648
- const path = attr;
649
803
  const cached = this.__peek(identifier, true);
650
- const basePath = path[0];
651
- let current = cached.remoteAttrs && basePath in cached.remoteAttrs ? cached.remoteAttrs[basePath] : void 0;
652
- if (current === void 0) return;
653
- for (let i = 1; i < path.length; i++) {
654
- current = current[path[i]];
655
- if (current === void 0) return;
656
- }
657
- return current;
804
+ return resolveAttr(attr, cached, RESOLUTION_ORDER_REMOTE_STATE);
658
805
  }
659
806
  /**
660
807
  * Mutate the data for an attribute in the cache
661
808
  *
662
809
  * This method is a candidate to become a mutation
663
810
  *
811
+ * @example
812
+ * ```ts
813
+ * cache.setAttr(identifier, 'name', 'Chris');
814
+ * ```
815
+ *
664
816
  * @category Resource Data
665
817
  * @public
666
818
  */
667
819
  setAttr(identifier, attr, value) {
820
+ if (value === void 0) value = null;
668
821
  const isSimplePath = !Array.isArray(attr) || attr.length === 1;
669
822
  if (Array.isArray(attr) && attr.length === 1) attr = attr[0];
670
823
  if (isSimplePath) {
671
824
  const cached = this.__peek(identifier, false);
672
825
  const currentAttr = attr;
673
- const existing = cached.inflightAttrs && currentAttr in cached.inflightAttrs ? cached.inflightAttrs[currentAttr] : cached.remoteAttrs && currentAttr in cached.remoteAttrs ? cached.remoteAttrs[currentAttr] : void 0;
674
- if (existing !== value) {
826
+ if (resolveAttr(currentAttr, cached, RESOLUTION_ORDER_EDIT_BASELINE) !== value) {
675
827
  cached.localAttrs = cached.localAttrs || Object.create(null);
676
828
  cached.localAttrs[currentAttr] = value;
677
- cached.changes = cached.changes || Object.create(null);
678
- cached.changes[currentAttr] = [existing, value];
679
- } else if (cached.localAttrs) {
680
- delete cached.localAttrs[currentAttr];
681
- delete cached.changes[currentAttr];
682
- }
829
+ } else if (cached.localAttrs) delete cached.localAttrs[currentAttr];
683
830
  if (cached.defaultAttrs && currentAttr in cached.defaultAttrs) delete cached.defaultAttrs[currentAttr];
684
831
  this._capabilities.notifyChange(identifier, "attributes", currentAttr, "local");
685
832
  return;
@@ -687,32 +834,47 @@ var JSONAPICache = class {
687
834
  const path = attr;
688
835
  const cached = this.__peek(identifier, false);
689
836
  const basePath = path[0];
690
- const existing = cached.inflightAttrs && basePath in cached.inflightAttrs ? cached.inflightAttrs[basePath] : cached.remoteAttrs && basePath in cached.remoteAttrs ? cached.remoteAttrs[basePath] : void 0;
691
- let existingAttr;
692
- if (existing) {
693
- existingAttr = existing[path[1]];
694
- for (let i = 2; i < path.length; i++) existingAttr = existingAttr[path[i]];
837
+ const baseline = resolveAttr(basePath, cached, RESOLUTION_ORDER_EDIT_BASELINE);
838
+ const isRevert = valueAtPath(baseline, path) === value;
839
+ const hasLocalClone = !!cached.localAttrs && basePath in cached.localAttrs;
840
+ if (isRevert && !hasLocalClone) return;
841
+ cached.localAttrs = cached.localAttrs || Object.create(null);
842
+ if (!hasLocalClone) {
843
+ const seed = baseline ?? (cached.defaultAttrs ? cached.defaultAttrs[basePath] : void 0);
844
+ cached.localAttrs[basePath] = structuredClone(seed);
695
845
  }
696
- if (existingAttr !== value) {
697
- cached.localAttrs = cached.localAttrs || Object.create(null);
698
- cached.localAttrs[basePath] = cached.localAttrs[basePath] || structuredClone(existing);
699
- cached.changes = cached.changes || Object.create(null);
700
- let currentLocal = cached.localAttrs[basePath];
701
- let nextLink = 1;
702
- while (nextLink < path.length - 1) currentLocal = currentLocal[path[nextLink++]];
703
- currentLocal[path[nextLink]] = value;
704
- cached.changes[basePath] = [existing, cached.localAttrs[basePath]];
705
- } else if (cached.localAttrs) try {
706
- if (!existing) return;
707
- if (JSON.stringify(existing) !== JSON.stringify(cached.localAttrs[basePath])) {
708
- delete cached.localAttrs[basePath];
709
- delete cached.changes[basePath];
710
- }
711
- } catch {}
846
+ let currentLocal = cached.localAttrs[basePath];
847
+ let nextLink = 1;
848
+ while (nextLink < path.length - 1) currentLocal = currentLocal[path[nextLink++]];
849
+ if (currentLocal[path[nextLink]] === value) return;
850
+ currentLocal[path[nextLink]] = value;
851
+ if (isRevert) {
852
+ const field = getCacheFields(this, identifier).get(basePath);
853
+ if (localCloneMatchesBaseline(this._capabilities.schema, field, baseline, cached.localAttrs[basePath])) delete cached.localAttrs[basePath];
854
+ }
855
+ if (cached.defaultAttrs && basePath in cached.defaultAttrs) delete cached.defaultAttrs[basePath];
712
856
  this._capabilities.notifyChange(identifier, "attributes", basePath, "local");
713
857
  }
714
858
  /**
715
- * Query the cache for the changed attributes of a resource.
859
+ * Query the cache for the changed attributes of a resource: every unsaved
860
+ * mutation, as a `[before, after]` pair per field.
861
+ *
862
+ * `before` is the value the mutation replaces, which is not always the
863
+ * persisted one. A mutation a save is carrying replaces remote state; an edit
864
+ * made while that save is in flight replaces the in-flight value. So this is
865
+ * what saving from here would change, which is what `serializePatch` and the
866
+ * legacy `Snapshot` consume, rather than a diff against persisted state.
867
+ *
868
+ * Derived from the layers on each call, so it is always consistent with
869
+ * {@link JSONAPICache.getAttr | getAttr} and
870
+ * {@link JSONAPICache.rollbackAttrs | rollbackAttrs}. Dirtiness does not go
871
+ * through here; see {@link JSONAPICache.hasChangedAttrs | hasChangedAttrs}.
872
+ *
873
+ * @example
874
+ * ```ts
875
+ * const changes = cache.changedAttrs(identifier);
876
+ * // { name: ['Igor', 'Chris'] }
877
+ * ```
716
878
  *
717
879
  * @category Resource Data
718
880
  * @public
@@ -720,12 +882,29 @@ var JSONAPICache = class {
720
882
  */
721
883
  changedAttrs(identifier) {
722
884
  const cached = this.__peek(identifier, false);
723
- if (!cached) return Object.create(null);
724
- return cached.changes || Object.create(null);
885
+ const changes = Object.create(null);
886
+ if (!cached) return changes;
887
+ const { localAttrs, inflightAttrs, remoteAttrs } = cached;
888
+ if (inflightAttrs) {
889
+ const keys = Object.keys(inflightAttrs);
890
+ for (let i = 0; i < keys.length; i++) changes[keys[i]] = [remoteAttrs ? remoteAttrs[keys[i]] : void 0, inflightAttrs[keys[i]]];
891
+ }
892
+ if (localAttrs) {
893
+ const keys = Object.keys(localAttrs);
894
+ for (let i = 0; i < keys.length; i++) changes[keys[i]] = [resolveAttr(keys[i], cached, RESOLUTION_ORDER_EDIT_BASELINE), localAttrs[keys[i]]];
895
+ }
896
+ return changes;
725
897
  }
726
898
  /**
727
899
  * Query the cache for whether any mutated attributes exist
728
900
  *
901
+ * @example
902
+ * ```ts
903
+ * if (cache.hasChangedAttrs(identifier)) {
904
+ * // ...
905
+ * }
906
+ * ```
907
+ *
729
908
  * @category Resource Data
730
909
  * @public
731
910
  */
@@ -739,6 +918,11 @@ var JSONAPICache = class {
739
918
  *
740
919
  * This method is a candidate to become a mutation
741
920
  *
921
+ * @example
922
+ * ```ts
923
+ * const restoredKeys = cache.rollbackAttrs(identifier);
924
+ * ```
925
+ *
742
926
  * @category Resource Data
743
927
  * @public
744
928
  * @return the names of fields that were restored
@@ -750,7 +934,6 @@ var JSONAPICache = class {
750
934
  if (cached.localAttrs !== null) {
751
935
  dirtyKeys = Object.keys(cached.localAttrs);
752
936
  cached.localAttrs = null;
753
- cached.changes = null;
754
937
  }
755
938
  if (cached.isNew) {
756
939
  cached.isDeletionCommitted = true;
@@ -770,7 +953,7 @@ var JSONAPICache = class {
770
953
  /**
771
954
  * Query the cache for the changes to relationships of a resource.
772
955
  *
773
- * Returns a map of relationship names to RelationshipDiff objects.
956
+ * Returns a map of relationship names to {@link RelationshipDiff} objects.
774
957
  *
775
958
  * ```ts
776
959
  * type RelationshipDiff =
@@ -789,6 +972,12 @@ var JSONAPICache = class {
789
972
  };
790
973
  ```
791
974
  *
975
+ * @example
976
+ * ```ts
977
+ * const diffs = cache.changedRelationships(identifier);
978
+ * const comments = diffs.get('comments');
979
+ * ```
980
+ *
792
981
  * @category Resource Data
793
982
  * @public
794
983
  */
@@ -798,6 +987,13 @@ var JSONAPICache = class {
798
987
  /**
799
988
  * Query the cache for whether any mutated relationships exist
800
989
  *
990
+ * @example
991
+ * ```ts
992
+ * if (cache.hasChangedRelationships(identifier)) {
993
+ * // ...
994
+ * }
995
+ * ```
996
+ *
801
997
  * @category Resource Data
802
998
  * @public
803
999
  */
@@ -811,6 +1007,11 @@ var JSONAPICache = class {
811
1007
  *
812
1008
  * This method is a candidate to become a mutation
813
1009
  *
1010
+ * @example
1011
+ * ```ts
1012
+ * const restoredFields = cache.rollbackRelationships(identifier);
1013
+ * ```
1014
+ *
814
1015
  * @category Resource Data
815
1016
  * @public
816
1017
  * @return the names of relationships that were restored
@@ -826,6 +1027,11 @@ var JSONAPICache = class {
826
1027
  /**
827
1028
  * Query the cache for the current state of a relationship property
828
1029
  *
1030
+ * @example
1031
+ * ```ts
1032
+ * const relationship = cache.getRelationship(identifier, 'comments');
1033
+ * ```
1034
+ *
829
1035
  * @category Resource Data
830
1036
  * @public
831
1037
  * @return resource relationship object
@@ -836,6 +1042,11 @@ var JSONAPICache = class {
836
1042
  /**
837
1043
  * Query the cache for the remote state of a relationship property
838
1044
  *
1045
+ * @example
1046
+ * ```ts
1047
+ * const relationship = cache.getRemoteRelationship(identifier, 'comments');
1048
+ * ```
1049
+ *
839
1050
  * @category Resource Data
840
1051
  * @public
841
1052
  * @return resource relationship object
@@ -849,6 +1060,11 @@ var JSONAPICache = class {
849
1060
  *
850
1061
  * This method is a candidate to become a mutation
851
1062
  *
1063
+ * @example
1064
+ * ```ts
1065
+ * cache.setIsDeleted(identifier, true);
1066
+ * ```
1067
+ *
852
1068
  * @category Resource State
853
1069
  * @public
854
1070
  */
@@ -860,6 +1076,11 @@ var JSONAPICache = class {
860
1076
  /**
861
1077
  * Query the cache for any validation errors applicable to the given resource.
862
1078
  *
1079
+ * @example
1080
+ * ```ts
1081
+ * const errors = cache.getErrors(identifier);
1082
+ * ```
1083
+ *
863
1084
  * @category Resource State
864
1085
  * @public
865
1086
  */
@@ -869,6 +1090,13 @@ var JSONAPICache = class {
869
1090
  /**
870
1091
  * Query the cache for whether a given resource has any available data
871
1092
  *
1093
+ * @example
1094
+ * ```ts
1095
+ * if (cache.isEmpty(identifier)) {
1096
+ * // ...
1097
+ * }
1098
+ * ```
1099
+ *
872
1100
  * @category Resource State
873
1101
  * @public
874
1102
  */
@@ -880,6 +1108,13 @@ var JSONAPICache = class {
880
1108
  * Query the cache for whether a given resource was created locally and not
881
1109
  * yet persisted.
882
1110
  *
1111
+ * @example
1112
+ * ```ts
1113
+ * if (cache.isNew(identifier)) {
1114
+ * // ...
1115
+ * }
1116
+ * ```
1117
+ *
883
1118
  * @category Resource State
884
1119
  * @public
885
1120
  */
@@ -890,6 +1125,13 @@ var JSONAPICache = class {
890
1125
  * Query the cache for whether a given resource is marked as deleted (but not
891
1126
  * necessarily persisted yet).
892
1127
  *
1128
+ * @example
1129
+ * ```ts
1130
+ * if (cache.isDeleted(identifier)) {
1131
+ * // ...
1132
+ * }
1133
+ * ```
1134
+ *
893
1135
  * @category Resource State
894
1136
  * @public
895
1137
  */
@@ -900,6 +1142,13 @@ var JSONAPICache = class {
900
1142
  * Query the cache for whether a given resource has been deleted and that deletion
901
1143
  * has also been persisted.
902
1144
  *
1145
+ * @example
1146
+ * ```ts
1147
+ * if (cache.isDeletionCommitted(identifier)) {
1148
+ * // ...
1149
+ * }
1150
+ * ```
1151
+ *
903
1152
  * @category Resource State
904
1153
  * @public
905
1154
  */
@@ -1035,20 +1284,189 @@ function getDefaultValue(schema, identifier, store) {
1035
1284
  if (transform?.defaultValue) return transform.defaultValue(options || null, identifier);
1036
1285
  }
1037
1286
  }
1038
- function calculateChangedKeys(cached, updates, fields) {
1039
- const changedKeys = /* @__PURE__ */ new Set();
1040
- const keys = Object.keys(updates);
1041
- const length = keys.length;
1042
- const localAttrs = cached.localAttrs;
1043
- const original = Object.assign(Object.create(null), cached.remoteAttrs, cached.inflightAttrs);
1044
- for (let i = 0; i < length; i++) {
1287
+ /**
1288
+ * The first layer, in the given resolution order, that holds `key`. Presence is `in`, so a value
1289
+ * explicitly set to `undefined` counts as present.
1290
+ */
1291
+ function layerHolding(key, layers, order) {
1292
+ for (let i = 0; i < order.length; i++) {
1293
+ const layer = layers[order[i]];
1294
+ if (layer && key in layer) return layer;
1295
+ }
1296
+ return null;
1297
+ }
1298
+ /**
1299
+ * What the projection with the given resolution order reads for `attr`. A path resolves its first
1300
+ * segment through the layers and follows the rest into the value, stopping with `undefined` at the
1301
+ * first missing link.
1302
+ */
1303
+ function resolveAttr(attr, layers, order) {
1304
+ const key = typeof attr === "string" ? attr : attr[0];
1305
+ const layer = layerHolding(key, layers, order);
1306
+ if (!layer) return void 0;
1307
+ return typeof attr === "string" ? layer[key] : valueAtPath(layer[key], attr);
1308
+ }
1309
+ /** Follow `path` (from its second segment) into `base`, stopping with `undefined` at the first missing link or `null`. */
1310
+ function valueAtPath(base, path) {
1311
+ let current = base;
1312
+ for (let i = 1; i < path.length; i++) {
1313
+ if (current === void 0 || current === null) return void 0;
1314
+ current = current[path[i]];
1315
+ }
1316
+ return current;
1317
+ }
1318
+ /**
1319
+ * Whether two values of `field` count as the same value for change detection.
1320
+ *
1321
+ * A `schema-object`, and each element of a `schema-array`, compares by the identity hash its
1322
+ * `ObjectSchema` declares (`identity: { kind: '@hash', ... }`), so the schema decides what "same"
1323
+ * means for it. With no hash declared, and for every other field kind, only the same reference
1324
+ * counts. That knowingly over-notifies for equal-content objects: content equality is the schema's
1325
+ * to define, not the cache's to guess.
1326
+ */
1327
+ function attrValuesEqual(schema, field, a, b) {
1328
+ if (a === b) return true;
1329
+ if (field.kind === "schema-object") return schemaObjectsEqual(schema, field, a, b);
1330
+ if (field.kind === "schema-array") return schemaArraysEqual(schema, field, a, b);
1331
+ return false;
1332
+ }
1333
+ /**
1334
+ * A schema-array has no hash of its own: two arrays are the same when they have the same length
1335
+ * and every element compares equal as a schema-object of the element type.
1336
+ */
1337
+ function schemaArraysEqual(schema, field, a, b) {
1338
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
1339
+ for (let i = 0; i < a.length; i++) if (!schemaObjectsEqual(schema, field, a[i], b[i])) return false;
1340
+ return true;
1341
+ }
1342
+ function schemaObjectsEqual(schema, field, a, b) {
1343
+ if (a === b) return true;
1344
+ if (!a || !b || typeof a !== "object" || typeof b !== "object") return false;
1345
+ const ia = fieldValueIdentity(schema, field, a);
1346
+ const ib = fieldValueIdentity(schema, field, b);
1347
+ return ia !== null && ib !== null && ia.type === ib.type && ia.hash !== null && ia.hash === ib.hash;
1348
+ }
1349
+ /**
1350
+ * Whether a nested edit's local clone is back to the baseline, so the edit can end. The schema
1351
+ * decides first, through {@link attrValuesEqual}: a schema-object with an identity hash matches
1352
+ * when its hash does. A value with no schema-defined equality falls back to comparing serialized
1353
+ * content, as this path always has: the clone began as a copy of the baseline, so key order
1354
+ * agrees while edits only replace existing keys. Anything that cannot serialize counts as still
1355
+ * edited, so no edit the cache cannot understand is thrown away.
1356
+ */
1357
+ function localCloneMatchesBaseline(schema, field, baseline, clone) {
1358
+ if (field && attrValuesEqual(schema, field, baseline, clone)) return true;
1359
+ if (!baseline || !clone) return false;
1360
+ try {
1361
+ return JSON.stringify(baseline) === JSON.stringify(clone);
1362
+ } catch {
1363
+ return false;
1364
+ }
1365
+ }
1366
+ /**
1367
+ * Which projection each changed key moved in, so the caller can notify on the matching channel:
1368
+ * `localOnly` and `remoteOnly` on theirs, `both` unscoped.
1369
+ *
1370
+ * `undefined` rather than empty Sets: this runs on every `upsert`, and a resource where nothing
1371
+ * moved should cost no allocation.
1372
+ */
1373
+ const NO_PROJECTION_CHANGES = Object.freeze({
1374
+ localOnly: void 0,
1375
+ remoteOnly: void 0,
1376
+ both: void 0
1377
+ });
1378
+ /** The layers a merge reads: the resource's own, plus the attributes arriving from the server. */
1379
+ function layersForMerge(cached, incomingAttrs) {
1380
+ return {
1381
+ localAttrs: cached.localAttrs,
1382
+ inflightAttrs: cached.inflightAttrs,
1383
+ remoteAttrs: cached.remoteAttrs,
1384
+ defaultAttrs: cached.defaultAttrs,
1385
+ incomingAttrs
1386
+ };
1387
+ }
1388
+ /**
1389
+ * Every key a merge could move: every key in every layer the after-merge order folds into
1390
+ * `remoteAttrs`. `null` when there is nothing to examine.
1391
+ */
1392
+ function candidateKeys(layers, remoteAfterOrder) {
1393
+ let keys = null;
1394
+ for (let i = 0; i < remoteAfterOrder.length; i++) {
1395
+ const layer = remoteAfterOrder[i];
1396
+ if (layer === "remoteAttrs") break;
1397
+ const hash = layers[layer];
1398
+ if (!hash) continue;
1399
+ const layerKeys = Object.keys(hash);
1400
+ if (!layerKeys.length) continue;
1401
+ if (keys === null) {
1402
+ keys = layerKeys;
1403
+ continue;
1404
+ }
1405
+ for (let j = 0; j < layerKeys.length; j++) if (!keys.includes(layerKeys[j])) keys.push(layerKeys[j]);
1406
+ }
1407
+ return keys;
1408
+ }
1409
+ /**
1410
+ * Fold every layer above `remoteAttrs` in the after-merge order for `kind` into `remoteAttrs`,
1411
+ * lowest precedence first, and clear each resource layer that was folded in. The same order
1412
+ * {@link partitionChangedKeys} used to predict the result, so the two cannot disagree.
1413
+ *
1414
+ * Reads the resource layers off `cached` rather than taking a {@link Layered}, so an upsert that
1415
+ * is not calculating changes allocates nothing beyond the merge itself.
1416
+ */
1417
+ function mergeIntoRemote(cached, incomingAttrs, kind) {
1418
+ const folded = MERGE_RESOLUTION[kind].folded;
1419
+ const target = cached.remoteAttrs || Object.create(null);
1420
+ for (let i = 0; i < folded.length; i++) {
1421
+ const layer = folded[i];
1422
+ const hash = layer === "incomingAttrs" ? incomingAttrs : cached[layer];
1423
+ if (!hash) continue;
1424
+ Object.assign(target, hash);
1425
+ if (layer !== "incomingAttrs") cached[layer] = null;
1426
+ if (cached.defaultAttrs) dropMemoizedDefaults(cached.defaultAttrs, hash);
1427
+ }
1428
+ cached.remoteAttrs = target;
1429
+ }
1430
+ function dropMemoizedDefaults(defaultAttrs, replacedBy) {
1431
+ const keys = Object.keys(replacedBy);
1432
+ for (let i = 0; i < keys.length; i++) if (keys[i] in defaultAttrs) delete defaultAttrs[keys[i]];
1433
+ }
1434
+ /**
1435
+ * Partition the keys a merge touches by which projection each one moves in. Must run *before*
1436
+ * {@link mergeIntoRemote}: it reads the pre-merge layers from `layers` and predicts the merge with
1437
+ * the same `RESOLUTION_ORDER_*_AFTER_*` orders `mergeIntoRemote` applies.
1438
+ */
1439
+ function partitionChangedKeys(schema, layers, fields, kind) {
1440
+ const { remoteAfter: remoteAfterOrder, localAfter: localAfterOrder } = MERGE_RESOLUTION[kind];
1441
+ const keys = candidateKeys(layers, remoteAfterOrder);
1442
+ if (keys === null) return NO_PROJECTION_CHANGES;
1443
+ let localOnly;
1444
+ let remoteOnly;
1445
+ let both;
1446
+ for (let i = 0; i < keys.length; i++) {
1045
1447
  const key = keys[i];
1046
- if (!fields.has(key)) continue;
1047
- const value = updates[key];
1048
- if (localAttrs && localAttrs[key] !== void 0) continue;
1049
- if (original[key] !== value) changedKeys.add(key);
1448
+ const field = fields.get(key);
1449
+ if (!field || isRelationship(field)) continue;
1450
+ const remoteBefore = resolveAttr(key, layers, RESOLUTION_ORDER_REMOTE_STATE);
1451
+ const remoteAfter = resolveAttr(key, layers, remoteAfterOrder);
1452
+ const localBefore = resolveAttr(key, layers, RESOLUTION_ORDER_LOCAL_STATE);
1453
+ const localAfter = resolveAttr(key, layers, localAfterOrder);
1454
+ const remoteMoved = !attrValuesEqual(schema, field, remoteBefore, remoteAfter);
1455
+ const localMoved = !attrValuesEqual(schema, field, localBefore, localAfter);
1456
+ if (remoteMoved && localMoved) (both ??= /* @__PURE__ */ new Set()).add(key);
1457
+ else if (remoteMoved) (remoteOnly ??= /* @__PURE__ */ new Set()).add(key);
1458
+ else if (localMoved) (localOnly ??= /* @__PURE__ */ new Set()).add(key);
1050
1459
  }
1051
- return changedKeys;
1460
+ return both || remoteOnly || localOnly ? {
1461
+ localOnly,
1462
+ remoteOnly,
1463
+ both
1464
+ } : NO_PROJECTION_CHANGES;
1465
+ }
1466
+ function notifyProjectionChanges(cache, identifier, { both, remoteOnly, localOnly }) {
1467
+ if (both?.size) cache._capabilities.notifyChange(identifier, "attributes", both);
1468
+ if (remoteOnly?.size) cache._capabilities.notifyChange(identifier, "attributes", remoteOnly, "remote");
1469
+ if (localOnly?.size) cache._capabilities.notifyChange(identifier, "attributes", localOnly, "local");
1052
1470
  }
1053
1471
  function cacheIsEmpty(cached) {
1054
1472
  return !cached || cached.remoteAttrs === null && cached.inflightAttrs === null && cached.localAttrs === null;
@@ -1089,25 +1507,26 @@ function isRelationship(field) {
1089
1507
  const { kind } = field;
1090
1508
  return kind === "hasMany" || kind === "belongsTo" || kind === "resource" || kind === "collection";
1091
1509
  }
1092
- function patchLocalAttributes(cached, changedRemoteKeys) {
1093
- const { localAttrs, remoteAttrs, inflightAttrs, defaultAttrs, changes } = cached;
1094
- if (!localAttrs) {
1095
- cached.changes = null;
1096
- return false;
1097
- }
1098
- let hasAppliedPatch = false;
1099
- const mutatedKeys = Object.keys(localAttrs);
1100
- for (let i = 0, length = mutatedKeys.length; i < length; i++) {
1101
- const attr = mutatedKeys[i];
1102
- if ((inflightAttrs && attr in inflightAttrs ? inflightAttrs[attr] : remoteAttrs && attr in remoteAttrs ? remoteAttrs[attr] : void 0) === localAttrs[attr]) {
1103
- hasAppliedPatch = true;
1104
- changedRemoteKeys?.delete(attr);
1105
- delete localAttrs[attr];
1106
- delete changes[attr];
1510
+ /**
1511
+ * After a merge: drop every local edit the new baseline agrees with, by the same equality
1512
+ * {@link partitionChangedKeys} uses. Returns whether any edit was dropped.
1513
+ */
1514
+ function reconcileLocalEdits(schema, cached, fields) {
1515
+ const { localAttrs, defaultAttrs } = cached;
1516
+ if (!localAttrs) return false;
1517
+ let droppedAnEdit = false;
1518
+ const editedKeys = Object.keys(localAttrs);
1519
+ for (let i = 0; i < editedKeys.length; i++) {
1520
+ const key = editedKeys[i];
1521
+ const field = fields.get(key);
1522
+ const baseline = resolveAttr(key, cached, RESOLUTION_ORDER_EDIT_BASELINE);
1523
+ if (field ? attrValuesEqual(schema, field, baseline, localAttrs[key]) : baseline === localAttrs[key]) {
1524
+ droppedAnEdit = true;
1525
+ delete localAttrs[key];
1107
1526
  }
1108
- if (defaultAttrs && attr in defaultAttrs) delete defaultAttrs[attr];
1527
+ if (defaultAttrs && key in defaultAttrs) delete defaultAttrs[key];
1109
1528
  }
1110
- return hasAppliedPatch;
1529
+ return droppedAnEdit;
1111
1530
  }
1112
1531
  function putOne(cache, identifiers, resource) {
1113
1532
  let identifier = identifiers.peekResourceKey(resource);
@@ -1193,7 +1612,7 @@ function copyLinksAndMeta(target, source) {
1193
1612
  if ("meta" in source) target.meta = source.meta;
1194
1613
  }
1195
1614
  function cacheUpsert(cache, identifier, data, calculateChanges) {
1196
- let changedKeys;
1615
+ let changes = NO_PROJECTION_CHANGES;
1197
1616
  const peeked = cache.__safePeek(identifier, false);
1198
1617
  const existed = !!peeked;
1199
1618
  const cached = peeked || cache._createCache(identifier);
@@ -1205,16 +1624,23 @@ function cacheUpsert(cache, identifier, data, calculateChanges) {
1205
1624
  cache._capabilities.notifyChange(identifier, "state", null);
1206
1625
  }
1207
1626
  const fields = getCacheFields(cache, identifier);
1208
- if (calculateChanges && existed && data.attributes) changedKeys = calculateChangedKeys(cached, data.attributes, fields);
1209
- cached.remoteAttrs = Object.assign(cached.remoteAttrs || Object.create(null), data.attributes);
1627
+ if (calculateChanges && existed && data.attributes) changes = partitionChangedKeys(cache._capabilities.schema, layersForMerge(cached, data.attributes), fields, "upsert");
1628
+ mergeIntoRemote(cached, data.attributes ?? null, "upsert");
1210
1629
  if (cached.localAttrs) {
1211
- if (patchLocalAttributes(cached, changedKeys)) cache._capabilities.notifyChange(identifier, "state", null);
1630
+ if (reconcileLocalEdits(cache._capabilities.schema, cached, fields)) cache._capabilities.notifyChange(identifier, "state", null);
1212
1631
  }
1213
1632
  if (!isUpdate) cache._capabilities.notifyChange(identifier, "added", null);
1214
1633
  if (data.id) cached.id = data.id;
1215
1634
  if (data.relationships) setupRelationships(cache.__graph, fields, identifier, data);
1216
- if (changedKeys?.size) cache._capabilities.notifyChange(identifier, "attributes", changedKeys);
1217
- return changedKeys?.size ? Array.from(changedKeys) : void 0;
1635
+ notifyProjectionChanges(cache, identifier, changes);
1636
+ return remoteChangedKeys(changes);
1637
+ }
1638
+ /** Every key whose remote value moved, as the `upsert` return value. */
1639
+ function remoteChangedKeys({ both, remoteOnly }) {
1640
+ if (!both?.size) return remoteOnly?.size ? Array.from(remoteOnly) : void 0;
1641
+ const keys = Array.from(both);
1642
+ if (remoteOnly?.size) keys.push(...remoteOnly);
1643
+ return keys;
1218
1644
  }
1219
1645
  function patchCache(Cache, op) {
1220
1646
  const isRecord = isResourceKey(op.record);
@@ -1277,10 +1703,11 @@ function commitDidError(cache, identifier, errors) {
1277
1703
  const keys = Object.keys(cached.inflightAttrs);
1278
1704
  if (keys.length > 0) {
1279
1705
  const attrs = cached.localAttrs = cached.localAttrs || Object.create(null);
1280
- for (let i = 0; i < keys.length; i++) if (attrs[keys[i]] === void 0) attrs[keys[i]] = cached.inflightAttrs[keys[i]];
1706
+ for (let i = 0; i < keys.length; i++) if (!(keys[i] in attrs)) attrs[keys[i]] = cached.inflightAttrs[keys[i]];
1281
1707
  }
1282
1708
  cached.inflightAttrs = null;
1283
1709
  }
1710
+ if (reconcileLocalEdits(cache._capabilities.schema, cached, getCacheFields(cache, identifier))) cache._capabilities.notifyChange(identifier, "state", null);
1284
1711
  if (errors) cached.errors = errors;
1285
1712
  cache._capabilities.notifyChange(identifier, "errors", null);
1286
1713
  }
@@ -1302,22 +1729,21 @@ function didCommit(cache, committedIdentifier, data, op) {
1302
1729
  }
1303
1730
  const fields = getCacheFields(cache, identifier);
1304
1731
  cached.isNew = false;
1305
- let newCanonicalAttributes;
1732
+ let responseAttrs = null;
1306
1733
  if (data) {
1307
1734
  if (data.id && !cached.id) cached.id = data.id;
1308
1735
  if (identifier === committedIdentifier && identifier.id !== existingId) cache._capabilities.notifyChange(identifier, "identity", null);
1309
1736
  if (data.relationships) setupRelationships(cache.__graph, fields, identifier, data);
1310
- newCanonicalAttributes = data.attributes;
1737
+ responseAttrs = data.attributes ?? null;
1311
1738
  }
1312
- const changedKeys = newCanonicalAttributes && calculateChangedKeys(cached, newCanonicalAttributes, fields);
1313
- cached.remoteAttrs = Object.assign(cached.remoteAttrs || Object.create(null), cached.inflightAttrs, newCanonicalAttributes);
1314
- cached.inflightAttrs = null;
1315
- patchLocalAttributes(cached, changedKeys);
1739
+ const changes = partitionChangedKeys(cache._capabilities.schema, layersForMerge(cached, responseAttrs), fields, "commit");
1740
+ mergeIntoRemote(cached, responseAttrs, "commit");
1741
+ reconcileLocalEdits(cache._capabilities.schema, cached, fields);
1316
1742
  if (cached.errors) {
1317
1743
  cached.errors = null;
1318
1744
  cache._capabilities.notifyChange(identifier, "errors", null);
1319
1745
  }
1320
- if (changedKeys?.size) cache._capabilities.notifyChange(identifier, "attributes", changedKeys);
1746
+ notifyProjectionChanges(cache, identifier, changes);
1321
1747
  cache._capabilities.notifyChange(identifier, "state", null);
1322
1748
  }
1323
1749
  function willCommit(cache, identifier) {