@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, logGroup } from "@warp-drive-mirror/core/store/-private";
2
+ import { assertPrivateCapabilities, fieldValueIdentity, isRequestKey, isResourceKey, logGroup } from "@warp-drive-mirror/core/store/-private";
3
3
  import Fuse from "fuse.js";
4
4
  import jsonToAst from "json-to-ast";
5
5
 
@@ -912,7 +912,7 @@ function validateResourceDocument(reporter, doc) {
912
912
  }
913
913
 
914
914
  //#endregion
915
- //#region ../../node_modules/.pnpm/@embroider+macros@1.20.6/node_modules/@embroider/macros/src/addon/runtime.js
915
+ //#region ../../node_modules/.pnpm/@embroider+macros@1.21.1/node_modules/@embroider/macros/src/addon/runtime.js
916
916
  function config(packageRoot) {
917
917
  return runtimeConfig.packages[packageRoot];
918
918
  }
@@ -1032,14 +1032,101 @@ const EMPTY_ITERATOR = { iterator() {
1032
1032
  };
1033
1033
  } };
1034
1034
  } };
1035
+ /** One attributes hash on a {@link CachedResource}: a **layer** a projection is read through. */
1036
+ /**
1037
+ * The cache's entry for a single resource: its id, its attribute values split
1038
+ * across four **layers**, and the flags tracking where it sits in the
1039
+ * create/update/delete lifecycle.
1040
+ *
1041
+ * A **projection** answers "what is this field's value" by reading a stack of
1042
+ * layers top-down; the first layer holding the field wins. The two projections,
1043
+ * with their resolution orders:
1044
+ *
1045
+ * - **remote state**, what the _immutable_ record reads:
1046
+ * {@link RESOLUTION_ORDER_REMOTE_STATE}
1047
+ * - **local state**, what an _editable_ copy reads:
1048
+ * {@link RESOLUTION_ORDER_LOCAL_STATE}
1049
+ *
1050
+ * Local state is remote state with the uncommitted mutations laid over it. The
1051
+ * guides call those mutations "the diff"; here they are simply the top layers of
1052
+ * local state, {@link CachedResource.localAttrs | localAttrs} and
1053
+ * {@link CachedResource.inflightAttrs | inflightAttrs}.
1054
+ *
1055
+ * A field is **dirty** while `localAttrs` or `inflightAttrs` holds it. A save
1056
+ * **commits** the mutation: `didCommit` merges the in-flight values into
1057
+ * `remoteAttrs` and removes them from `inflightAttrs`. A push carrying the same
1058
+ * value as a pending edit in `localAttrs` has the same effect on that edit: the
1059
+ * server already holds it, so it is removed from `localAttrs`. "Same value" is
1060
+ * decided by the field's schema: a schema-object with an identity hash compares
1061
+ * by that hash, everything else by reference.
1062
+ *
1063
+ * Thus, a dirty field reads as mutated for local readers only, and goes on doing
1064
+ * so while its save is in flight.
1065
+ *
1066
+ * @internal
1067
+ */
1068
+ /** A layer name on a {@link CachedResource}. */
1069
+ /**
1070
+ * A layer that exists only for the duration of a merge: the attributes arriving from the
1071
+ * server in a save response or a push. {@link partitionChangedKeys} reads it as if it were
1072
+ * already on the resource; {@link mergeIntoRemote} then folds the same attributes in, handed
1073
+ * to it directly. Never part of a reader's resolution order.
1074
+ */
1075
+ /** Anything a projection can be read through: a {@link CachedResource}, or the layers a merge reads, built around one. */
1076
+ /** What the immutable record reads. */
1077
+ const RESOLUTION_ORDER_REMOTE_STATE = ["remoteAttrs", "defaultAttrs"];
1078
+ /**
1079
+ * What a new local edit replaces: the in-flight value if a save is carrying one, else the
1080
+ * persisted value. Put another way, what the record will read once the in-flight save lands,
1081
+ * assuming the server agrees. This is local state beneath `localAttrs`, less `defaultAttrs`: an
1082
+ * edit equal to a schema default is still an edit, since nothing persisted holds that value.
1083
+ *
1084
+ * Saving does not use this baseline. {@link JSONAPICache.changedAttrs | changedAttrs} still lists
1085
+ * in-flight values as unsaved, so a second save re-sends them rather than assuming the first one
1086
+ * will succeed.
1087
+ */
1088
+ const RESOLUTION_ORDER_EDIT_BASELINE = ["inflightAttrs", "remoteAttrs"];
1089
+ /** What an editable copy reads. */
1090
+ const RESOLUTION_ORDER_LOCAL_STATE = [
1091
+ "localAttrs",
1092
+ ...RESOLUTION_ORDER_EDIT_BASELINE,
1093
+ "defaultAttrs"
1094
+ ];
1095
+ const RESOLUTION_ORDER_REMOTE_AFTER_COMMIT = [
1096
+ "incomingAttrs",
1097
+ "inflightAttrs",
1098
+ ...RESOLUTION_ORDER_REMOTE_STATE
1099
+ ];
1100
+ const RESOLUTION_ORDER_LOCAL_AFTER_COMMIT = ["localAttrs", ...RESOLUTION_ORDER_REMOTE_AFTER_COMMIT];
1101
+ const RESOLUTION_ORDER_REMOTE_AFTER_UPSERT = ["incomingAttrs", ...RESOLUTION_ORDER_REMOTE_STATE];
1102
+ const RESOLUTION_ORDER_LOCAL_AFTER_UPSERT = [
1103
+ "localAttrs",
1104
+ "inflightAttrs",
1105
+ ...RESOLUTION_ORDER_REMOTE_AFTER_UPSERT
1106
+ ];
1107
+ /** The layers an after-merge order folds into `remoteAttrs`, lowest precedence first. */
1108
+ function layersFoldedIntoRemote(order) {
1109
+ return order.slice(0, order.indexOf("remoteAttrs")).reverse();
1110
+ }
1111
+ const MERGE_RESOLUTION = {
1112
+ commit: {
1113
+ remoteAfter: RESOLUTION_ORDER_REMOTE_AFTER_COMMIT,
1114
+ localAfter: RESOLUTION_ORDER_LOCAL_AFTER_COMMIT,
1115
+ folded: layersFoldedIntoRemote(RESOLUTION_ORDER_REMOTE_AFTER_COMMIT)
1116
+ },
1117
+ upsert: {
1118
+ remoteAfter: RESOLUTION_ORDER_REMOTE_AFTER_UPSERT,
1119
+ localAfter: RESOLUTION_ORDER_LOCAL_AFTER_UPSERT,
1120
+ folded: layersFoldedIntoRemote(RESOLUTION_ORDER_REMOTE_AFTER_UPSERT)
1121
+ }
1122
+ };
1035
1123
  function makeCache() {
1036
1124
  return {
1037
1125
  id: null,
1038
- remoteAttrs: null,
1039
1126
  localAttrs: null,
1040
- defaultAttrs: null,
1041
1127
  inflightAttrs: null,
1042
- changes: null,
1128
+ remoteAttrs: null,
1129
+ defaultAttrs: null,
1043
1130
  errors: null,
1044
1131
  isNew: false,
1045
1132
  isDeleted: false,
@@ -1090,9 +1177,9 @@ var JSONAPICache = class {
1090
1177
  /**
1091
1178
  * Cache the response to a request
1092
1179
  *
1093
- * Implements `Cache.put`.
1180
+ * Implements {@link Cache.put | Cache.put}.
1094
1181
  *
1095
- * Expects a StructuredDocument whose `content` member is a JsonApiDocument.
1182
+ * Expects a {@link StructuredDocument} whose `content` member is a JsonApiDocument.
1096
1183
  *
1097
1184
  * ```js
1098
1185
  * cache.put({
@@ -1225,6 +1312,16 @@ var JSONAPICache = class {
1225
1312
  * Update the "remote" or "canonical" (persisted) state of the Cache
1226
1313
  * by merging new information into the existing state.
1227
1314
  *
1315
+ * @example
1316
+ * ```ts
1317
+ * cache.patch({
1318
+ * op: 'update',
1319
+ * record: identifier,
1320
+ * field: 'name',
1321
+ * value: 'Chris',
1322
+ * });
1323
+ * ```
1324
+ *
1228
1325
  * @category Cache Management
1229
1326
  * @public
1230
1327
  * @param op the operation or list of operations to perform
@@ -1242,6 +1339,16 @@ var JSONAPICache = class {
1242
1339
  /**
1243
1340
  * Update the "local" or "current" (unpersisted) state of the Cache
1244
1341
  *
1342
+ * @example
1343
+ * ```ts
1344
+ * cache.mutate({
1345
+ * op: 'replaceRelatedRecord',
1346
+ * record: identifier,
1347
+ * field: 'author',
1348
+ * value: authorIdentifier,
1349
+ * });
1350
+ * ```
1351
+ *
1245
1352
  * @category Cache Management
1246
1353
  * @public
1247
1354
  */
@@ -1278,8 +1385,8 @@ var JSONAPICache = class {
1278
1385
  * not require retainining connections to the Store
1279
1386
  * and Cache to present data on a per-field basis.
1280
1387
  *
1281
- * This generally takes the place of `getAttr` as
1282
- * an API and may even take the place of `getRelationship`
1388
+ * This generally takes the place of {@link JSONAPICache.getAttr | getAttr} as
1389
+ * an API and may even take the place of {@link JSONAPICache.getRelationship | getRelationship}
1283
1390
  * depending on implementation specifics, though this
1284
1391
  * latter usage is less recommended due to the advantages
1285
1392
  * of the Graph handling necessary entanglements and
@@ -1294,6 +1401,12 @@ var JSONAPICache = class {
1294
1401
  * the various internal WarpDrive bookkeeping fields.
1295
1402
  * :::
1296
1403
  *
1404
+ * @example
1405
+ * ```ts
1406
+ * const resource = cache.peek(identifier);
1407
+ * const document = cache.peek(requestKey);
1408
+ * ```
1409
+ *
1297
1410
  * @category Cache Management
1298
1411
  * @public
1299
1412
  */
@@ -1333,6 +1446,12 @@ var JSONAPICache = class {
1333
1446
  /**
1334
1447
  * Peek the remote resource data from the Cache.
1335
1448
  *
1449
+ * @example
1450
+ * ```ts
1451
+ * const resource = cache.peekRemoteState(identifier);
1452
+ * const document = cache.peekRemoteState(requestKey);
1453
+ * ```
1454
+ *
1336
1455
  * @category Cache Management
1337
1456
  * @public
1338
1457
  */
@@ -1373,9 +1492,14 @@ var JSONAPICache = class {
1373
1492
  * Peek the Cache for the existing request data associated with
1374
1493
  * a cacheable request.
1375
1494
  *
1376
- * This is effectively the reverse of `put` for a request in
1495
+ * This is effectively the reverse of {@link JSONAPICache.put | put} for a request in
1377
1496
  * that it will return the the request, response, and content
1378
- * whereas `peek` will return just the `content`.
1497
+ * whereas {@link JSONAPICache.peek | peek} will return just the `content`.
1498
+ *
1499
+ * @example
1500
+ * ```ts
1501
+ * const doc = cache.peekRequest(requestKey);
1502
+ * ```
1379
1503
  *
1380
1504
  * @category Cache Management
1381
1505
  * @public
@@ -1386,9 +1510,20 @@ var JSONAPICache = class {
1386
1510
  /**
1387
1511
  * Push resource data from a remote source into the cache for this identifier
1388
1512
  *
1513
+ * @example
1514
+ * ```ts
1515
+ * cache.upsert(identifier, {
1516
+ * type: 'user',
1517
+ * id: '1',
1518
+ * attributes: { name: 'Chris' },
1519
+ * });
1520
+ * ```
1521
+ *
1389
1522
  * @category Cache Management
1390
1523
  * @public
1391
- * @return if `calculateChanges` is true then calculated key changes should be returned
1524
+ * @return when `calculateChanges` is true, the names of the attributes whose persisted value
1525
+ * this push changed (the same keys the `'remote'` channel is notified with), or `undefined`
1526
+ * when none did. Otherwise `void`.
1392
1527
  */
1393
1528
  upsert(identifier, data, calculateChanges) {
1394
1529
  assertPrivateCapabilities(this._capabilities);
@@ -1434,7 +1569,7 @@ var JSONAPICache = class {
1434
1569
  *
1435
1570
  * Each individual resource or document that has
1436
1571
  * been mutated should be described as an individual
1437
- * `Change` entry in the returned array.
1572
+ * {@link Change} entry in the returned array.
1438
1573
  *
1439
1574
  * A `Change` is described by an object containing up to
1440
1575
  * three properties: (1) the `identifier` of the entity that
@@ -1499,6 +1634,11 @@ var JSONAPICache = class {
1499
1634
  * It returns properties from options that should be set on the record during the create
1500
1635
  * process. This return value behavior is deprecated.
1501
1636
  *
1637
+ * @example
1638
+ * ```ts
1639
+ * cache.clientDidCreate(identifier, { name: 'Chris' });
1640
+ * ```
1641
+ *
1502
1642
  * @category Resource Lifecycle
1503
1643
  * @public
1504
1644
  */
@@ -1561,6 +1701,11 @@ var JSONAPICache = class {
1561
1701
  * [LIFECYCLE] Signals to the cache that a resource
1562
1702
  * will be part of a save transaction.
1563
1703
  *
1704
+ * @example
1705
+ * ```ts
1706
+ * cache.willCommit(identifier, context);
1707
+ * ```
1708
+ *
1564
1709
  * @category Resource Lifecycle
1565
1710
  * @public
1566
1711
  */
@@ -1572,6 +1717,11 @@ var JSONAPICache = class {
1572
1717
  * [LIFECYCLE] Signals to the cache that a resource
1573
1718
  * was successfully updated as part of a save transaction.
1574
1719
  *
1720
+ * @example
1721
+ * ```ts
1722
+ * cache.didCommit(identifier, result);
1723
+ * ```
1724
+ *
1575
1725
  * @category Resource Lifecycle
1576
1726
  * @public
1577
1727
  */
@@ -1609,6 +1759,11 @@ var JSONAPICache = class {
1609
1759
  * [LIFECYCLE] Signals to the cache that a resource
1610
1760
  * was update via a save transaction failed.
1611
1761
  *
1762
+ * @example
1763
+ * ```ts
1764
+ * cache.commitWasRejected(identifier, errors);
1765
+ * ```
1766
+ *
1612
1767
  * @category Resource Lifecycle
1613
1768
  * @public
1614
1769
  */
@@ -1625,6 +1780,11 @@ var JSONAPICache = class {
1625
1780
  *
1626
1781
  * This method is a candidate to become a mutation
1627
1782
  *
1783
+ * @example
1784
+ * ```ts
1785
+ * cache.unloadRecord(identifier);
1786
+ * ```
1787
+ *
1628
1788
  * @category Resource Lifecycle
1629
1789
  * @public
1630
1790
  */
@@ -1665,6 +1825,12 @@ var JSONAPICache = class {
1665
1825
  * Retrieve the data for an attribute from the cache
1666
1826
  * with local mutations applied.
1667
1827
  *
1828
+ * @example
1829
+ * ```ts
1830
+ * const name = cache.getAttr(identifier, 'name');
1831
+ * const zip = cache.getAttr(identifier, ['address', 'zip']);
1832
+ * ```
1833
+ *
1668
1834
  * @category Resource Data
1669
1835
  * @public
1670
1836
  */
@@ -1678,37 +1844,28 @@ var JSONAPICache = class {
1678
1844
  if (!test) throw new Error(`Cannot retrieve attributes for identifier ${String(identifier)} as it is not present in the cache`);
1679
1845
  })(cached);
1680
1846
  if (!cached) return;
1681
- if (cached.localAttrs && attribute in cached.localAttrs) return cached.localAttrs[attribute];
1682
- else if (cached.inflightAttrs && attribute in cached.inflightAttrs) return cached.inflightAttrs[attribute];
1683
- else if (cached.remoteAttrs && attribute in cached.remoteAttrs) return cached.remoteAttrs[attribute];
1684
- else if (cached.defaultAttrs && attribute in cached.defaultAttrs) return cached.defaultAttrs[attribute];
1685
- else {
1686
- const attrSchema = getCacheFields(this, identifier).get(attribute);
1687
- assertPrivateCapabilities(this._capabilities);
1688
- const defaultValue = getDefaultValue(attrSchema, identifier, this._capabilities._store);
1689
- if (schemaHasLegacyDefaultValueFn(attrSchema)) {
1690
- cached.defaultAttrs = cached.defaultAttrs || Object.create(null);
1691
- cached.defaultAttrs[attribute] = defaultValue;
1692
- }
1693
- return defaultValue;
1847
+ const layer = layerHolding(attribute, cached, RESOLUTION_ORDER_LOCAL_STATE);
1848
+ if (layer) return layer[attribute];
1849
+ const attrSchema = getCacheFields(this, identifier).get(attribute);
1850
+ assertPrivateCapabilities(this._capabilities);
1851
+ const defaultValue = getDefaultValue(attrSchema, identifier, this._capabilities._store);
1852
+ if (schemaHasLegacyDefaultValueFn(attrSchema)) {
1853
+ cached.defaultAttrs = cached.defaultAttrs || Object.create(null);
1854
+ cached.defaultAttrs[attribute] = defaultValue;
1694
1855
  }
1856
+ return defaultValue;
1695
1857
  }
1696
- const path = attr;
1697
1858
  const cached = this.__peek(identifier, true);
1698
- const basePath = path[0];
1699
- let current = cached.localAttrs && basePath in cached.localAttrs ? cached.localAttrs[basePath] : void 0;
1700
- if (current === void 0) current = cached.inflightAttrs && basePath in cached.inflightAttrs ? cached.inflightAttrs[basePath] : void 0;
1701
- if (current === void 0) current = cached.remoteAttrs && basePath in cached.remoteAttrs ? cached.remoteAttrs[basePath] : void 0;
1702
- if (current === void 0) return;
1703
- for (let i = 1; i < path.length; i++) {
1704
- current = current[path[i]];
1705
- if (current === void 0) return;
1706
- }
1707
- return current;
1859
+ return resolveAttr(attr, cached, RESOLUTION_ORDER_LOCAL_STATE);
1708
1860
  }
1709
1861
  /**
1710
1862
  * Retrieve the remote data for an attribute from the cache
1711
1863
  *
1864
+ * @example
1865
+ * ```ts
1866
+ * const name = cache.getRemoteAttr(identifier, 'name');
1867
+ * ```
1868
+ *
1712
1869
  * @category Resource Data
1713
1870
  * @public
1714
1871
  */
@@ -1722,35 +1879,30 @@ var JSONAPICache = class {
1722
1879
  if (!test) throw new Error(`Cannot retrieve remote attributes for identifier ${String(identifier)} as it is not present in the cache`);
1723
1880
  })(cached);
1724
1881
  if (!cached) return;
1725
- if (cached.remoteAttrs && attribute in cached.remoteAttrs) return cached.remoteAttrs[attribute];
1726
- else if (cached.defaultAttrs && attribute in cached.defaultAttrs) return cached.defaultAttrs[attribute];
1727
- else {
1728
- const attrSchema = getCacheFields(this, identifier).get(attribute);
1729
- assertPrivateCapabilities(this._capabilities);
1730
- const defaultValue = getDefaultValue(attrSchema, identifier, this._capabilities._store);
1731
- if (schemaHasLegacyDefaultValueFn(attrSchema)) {
1732
- cached.defaultAttrs = cached.defaultAttrs || Object.create(null);
1733
- cached.defaultAttrs[attribute] = defaultValue;
1734
- }
1735
- return defaultValue;
1882
+ const layer = layerHolding(attribute, cached, RESOLUTION_ORDER_REMOTE_STATE);
1883
+ if (layer) return layer[attribute];
1884
+ const attrSchema = getCacheFields(this, identifier).get(attribute);
1885
+ assertPrivateCapabilities(this._capabilities);
1886
+ const defaultValue = getDefaultValue(attrSchema, identifier, this._capabilities._store);
1887
+ if (schemaHasLegacyDefaultValueFn(attrSchema)) {
1888
+ cached.defaultAttrs = cached.defaultAttrs || Object.create(null);
1889
+ cached.defaultAttrs[attribute] = defaultValue;
1736
1890
  }
1891
+ return defaultValue;
1737
1892
  }
1738
- const path = attr;
1739
1893
  const cached = this.__peek(identifier, true);
1740
- const basePath = path[0];
1741
- let current = cached.remoteAttrs && basePath in cached.remoteAttrs ? cached.remoteAttrs[basePath] : void 0;
1742
- if (current === void 0) return;
1743
- for (let i = 1; i < path.length; i++) {
1744
- current = current[path[i]];
1745
- if (current === void 0) return;
1746
- }
1747
- return current;
1894
+ return resolveAttr(attr, cached, RESOLUTION_ORDER_REMOTE_STATE);
1748
1895
  }
1749
1896
  /**
1750
1897
  * Mutate the data for an attribute in the cache
1751
1898
  *
1752
1899
  * This method is a candidate to become a mutation
1753
1900
  *
1901
+ * @example
1902
+ * ```ts
1903
+ * cache.setAttr(identifier, 'name', 'Chris');
1904
+ * ```
1905
+ *
1754
1906
  * @category Resource Data
1755
1907
  * @public
1756
1908
  */
@@ -1758,21 +1910,19 @@ var JSONAPICache = class {
1758
1910
  ((test) => {
1759
1911
  if (!test) throw new Error("setAttr must receive at least one attribute path");
1760
1912
  })(attr.length > 0);
1913
+ ((test) => {
1914
+ if (!test) throw new Error(`Cannot set '${Array.isArray(attr) ? attr.join(".") : attr}' on '${identifier.type}' to undefined: undefined is not a JSON value. Use null instead. If you feel like this behavior is incorrect, open an issue and ping @runspired.`);
1915
+ })(value !== void 0);
1916
+ if (value === void 0) value = null;
1761
1917
  const isSimplePath = !Array.isArray(attr) || attr.length === 1;
1762
1918
  if (Array.isArray(attr) && attr.length === 1) attr = attr[0];
1763
1919
  if (isSimplePath) {
1764
1920
  const cached = this.__peek(identifier, false);
1765
1921
  const currentAttr = attr;
1766
- const existing = cached.inflightAttrs && currentAttr in cached.inflightAttrs ? cached.inflightAttrs[currentAttr] : cached.remoteAttrs && currentAttr in cached.remoteAttrs ? cached.remoteAttrs[currentAttr] : void 0;
1767
- if (existing !== value) {
1922
+ if (resolveAttr(currentAttr, cached, RESOLUTION_ORDER_EDIT_BASELINE) !== value) {
1768
1923
  cached.localAttrs = cached.localAttrs || Object.create(null);
1769
1924
  cached.localAttrs[currentAttr] = value;
1770
- cached.changes = cached.changes || Object.create(null);
1771
- cached.changes[currentAttr] = [existing, value];
1772
- } else if (cached.localAttrs) {
1773
- delete cached.localAttrs[currentAttr];
1774
- delete cached.changes[currentAttr];
1775
- }
1925
+ } else if (cached.localAttrs) delete cached.localAttrs[currentAttr];
1776
1926
  if (cached.defaultAttrs && currentAttr in cached.defaultAttrs) delete cached.defaultAttrs[currentAttr];
1777
1927
  this._capabilities.notifyChange(identifier, "attributes", currentAttr, "local");
1778
1928
  return;
@@ -1780,32 +1930,50 @@ var JSONAPICache = class {
1780
1930
  const path = attr;
1781
1931
  const cached = this.__peek(identifier, false);
1782
1932
  const basePath = path[0];
1783
- const existing = cached.inflightAttrs && basePath in cached.inflightAttrs ? cached.inflightAttrs[basePath] : cached.remoteAttrs && basePath in cached.remoteAttrs ? cached.remoteAttrs[basePath] : void 0;
1784
- let existingAttr;
1785
- if (existing) {
1786
- existingAttr = existing[path[1]];
1787
- for (let i = 2; i < path.length; i++) existingAttr = existingAttr[path[i]];
1933
+ const baseline = resolveAttr(basePath, cached, RESOLUTION_ORDER_EDIT_BASELINE);
1934
+ const isRevert = valueAtPath(baseline, path) === value;
1935
+ const hasLocalClone = !!cached.localAttrs && basePath in cached.localAttrs;
1936
+ if (isRevert && !hasLocalClone) return;
1937
+ cached.localAttrs = cached.localAttrs || Object.create(null);
1938
+ if (!hasLocalClone) {
1939
+ const seed = baseline ?? (cached.defaultAttrs ? cached.defaultAttrs[basePath] : void 0);
1940
+ ((test) => {
1941
+ if (!test) throw new Error(`Cannot set '${path.join(".")}' on '${identifier.type}': '${basePath}' holds no object to write into`);
1942
+ })(!!seed && typeof seed === "object");
1943
+ cached.localAttrs[basePath] = structuredClone(seed);
1788
1944
  }
1789
- if (existingAttr !== value) {
1790
- cached.localAttrs = cached.localAttrs || Object.create(null);
1791
- cached.localAttrs[basePath] = cached.localAttrs[basePath] || structuredClone(existing);
1792
- cached.changes = cached.changes || Object.create(null);
1793
- let currentLocal = cached.localAttrs[basePath];
1794
- let nextLink = 1;
1795
- while (nextLink < path.length - 1) currentLocal = currentLocal[path[nextLink++]];
1796
- currentLocal[path[nextLink]] = value;
1797
- cached.changes[basePath] = [existing, cached.localAttrs[basePath]];
1798
- } else if (cached.localAttrs) try {
1799
- if (!existing) return;
1800
- if (JSON.stringify(existing) !== JSON.stringify(cached.localAttrs[basePath])) {
1801
- delete cached.localAttrs[basePath];
1802
- delete cached.changes[basePath];
1803
- }
1804
- } catch {}
1945
+ let currentLocal = cached.localAttrs[basePath];
1946
+ let nextLink = 1;
1947
+ while (nextLink < path.length - 1) currentLocal = currentLocal[path[nextLink++]];
1948
+ if (currentLocal[path[nextLink]] === value) return;
1949
+ currentLocal[path[nextLink]] = value;
1950
+ if (isRevert) {
1951
+ const field = getCacheFields(this, identifier).get(basePath);
1952
+ if (localCloneMatchesBaseline(this._capabilities.schema, field, baseline, cached.localAttrs[basePath])) delete cached.localAttrs[basePath];
1953
+ }
1954
+ if (cached.defaultAttrs && basePath in cached.defaultAttrs) delete cached.defaultAttrs[basePath];
1805
1955
  this._capabilities.notifyChange(identifier, "attributes", basePath, "local");
1806
1956
  }
1807
1957
  /**
1808
- * Query the cache for the changed attributes of a resource.
1958
+ * Query the cache for the changed attributes of a resource: every unsaved
1959
+ * mutation, as a `[before, after]` pair per field.
1960
+ *
1961
+ * `before` is the value the mutation replaces, which is not always the
1962
+ * persisted one. A mutation a save is carrying replaces remote state; an edit
1963
+ * made while that save is in flight replaces the in-flight value. So this is
1964
+ * what saving from here would change, which is what `serializePatch` and the
1965
+ * legacy `Snapshot` consume, rather than a diff against persisted state.
1966
+ *
1967
+ * Derived from the layers on each call, so it is always consistent with
1968
+ * {@link JSONAPICache.getAttr | getAttr} and
1969
+ * {@link JSONAPICache.rollbackAttrs | rollbackAttrs}. Dirtiness does not go
1970
+ * through here; see {@link JSONAPICache.hasChangedAttrs | hasChangedAttrs}.
1971
+ *
1972
+ * @example
1973
+ * ```ts
1974
+ * const changes = cache.changedAttrs(identifier);
1975
+ * // { name: ['Igor', 'Chris'] }
1976
+ * ```
1809
1977
  *
1810
1978
  * @category Resource Data
1811
1979
  * @public
@@ -1816,12 +1984,29 @@ var JSONAPICache = class {
1816
1984
  ((test) => {
1817
1985
  if (!test) throw new Error(`Cannot retrieve changed attributes for identifier ${String(identifier)} as it is not present in the cache`);
1818
1986
  })(cached);
1819
- if (!cached) return Object.create(null);
1820
- return cached.changes || Object.create(null);
1987
+ const changes = Object.create(null);
1988
+ if (!cached) return changes;
1989
+ const { localAttrs, inflightAttrs, remoteAttrs } = cached;
1990
+ if (inflightAttrs) {
1991
+ const keys = Object.keys(inflightAttrs);
1992
+ for (let i = 0; i < keys.length; i++) changes[keys[i]] = [remoteAttrs ? remoteAttrs[keys[i]] : void 0, inflightAttrs[keys[i]]];
1993
+ }
1994
+ if (localAttrs) {
1995
+ const keys = Object.keys(localAttrs);
1996
+ for (let i = 0; i < keys.length; i++) changes[keys[i]] = [resolveAttr(keys[i], cached, RESOLUTION_ORDER_EDIT_BASELINE), localAttrs[keys[i]]];
1997
+ }
1998
+ return changes;
1821
1999
  }
1822
2000
  /**
1823
2001
  * Query the cache for whether any mutated attributes exist
1824
2002
  *
2003
+ * @example
2004
+ * ```ts
2005
+ * if (cache.hasChangedAttrs(identifier)) {
2006
+ * // ...
2007
+ * }
2008
+ * ```
2009
+ *
1825
2010
  * @category Resource Data
1826
2011
  * @public
1827
2012
  */
@@ -1838,6 +2023,11 @@ var JSONAPICache = class {
1838
2023
  *
1839
2024
  * This method is a candidate to become a mutation
1840
2025
  *
2026
+ * @example
2027
+ * ```ts
2028
+ * const restoredKeys = cache.rollbackAttrs(identifier);
2029
+ * ```
2030
+ *
1841
2031
  * @category Resource Data
1842
2032
  * @public
1843
2033
  * @return the names of fields that were restored
@@ -1849,7 +2039,6 @@ var JSONAPICache = class {
1849
2039
  if (cached.localAttrs !== null) {
1850
2040
  dirtyKeys = Object.keys(cached.localAttrs);
1851
2041
  cached.localAttrs = null;
1852
- cached.changes = null;
1853
2042
  }
1854
2043
  if (cached.isNew) {
1855
2044
  cached.isDeletionCommitted = true;
@@ -1869,7 +2058,7 @@ var JSONAPICache = class {
1869
2058
  /**
1870
2059
  * Query the cache for the changes to relationships of a resource.
1871
2060
  *
1872
- * Returns a map of relationship names to RelationshipDiff objects.
2061
+ * Returns a map of relationship names to {@link RelationshipDiff} objects.
1873
2062
  *
1874
2063
  * ```ts
1875
2064
  * type RelationshipDiff =
@@ -1888,6 +2077,12 @@ var JSONAPICache = class {
1888
2077
  };
1889
2078
  ```
1890
2079
  *
2080
+ * @example
2081
+ * ```ts
2082
+ * const diffs = cache.changedRelationships(identifier);
2083
+ * const comments = diffs.get('comments');
2084
+ * ```
2085
+ *
1891
2086
  * @category Resource Data
1892
2087
  * @public
1893
2088
  */
@@ -1897,6 +2092,13 @@ var JSONAPICache = class {
1897
2092
  /**
1898
2093
  * Query the cache for whether any mutated relationships exist
1899
2094
  *
2095
+ * @example
2096
+ * ```ts
2097
+ * if (cache.hasChangedRelationships(identifier)) {
2098
+ * // ...
2099
+ * }
2100
+ * ```
2101
+ *
1900
2102
  * @category Resource Data
1901
2103
  * @public
1902
2104
  */
@@ -1910,6 +2112,11 @@ var JSONAPICache = class {
1910
2112
  *
1911
2113
  * This method is a candidate to become a mutation
1912
2114
  *
2115
+ * @example
2116
+ * ```ts
2117
+ * const restoredFields = cache.rollbackRelationships(identifier);
2118
+ * ```
2119
+ *
1913
2120
  * @category Resource Data
1914
2121
  * @public
1915
2122
  * @return the names of relationships that were restored
@@ -1925,6 +2132,11 @@ var JSONAPICache = class {
1925
2132
  /**
1926
2133
  * Query the cache for the current state of a relationship property
1927
2134
  *
2135
+ * @example
2136
+ * ```ts
2137
+ * const relationship = cache.getRelationship(identifier, 'comments');
2138
+ * ```
2139
+ *
1928
2140
  * @category Resource Data
1929
2141
  * @public
1930
2142
  * @return resource relationship object
@@ -1935,6 +2147,11 @@ var JSONAPICache = class {
1935
2147
  /**
1936
2148
  * Query the cache for the remote state of a relationship property
1937
2149
  *
2150
+ * @example
2151
+ * ```ts
2152
+ * const relationship = cache.getRemoteRelationship(identifier, 'comments');
2153
+ * ```
2154
+ *
1938
2155
  * @category Resource Data
1939
2156
  * @public
1940
2157
  * @return resource relationship object
@@ -1948,6 +2165,11 @@ var JSONAPICache = class {
1948
2165
  *
1949
2166
  * This method is a candidate to become a mutation
1950
2167
  *
2168
+ * @example
2169
+ * ```ts
2170
+ * cache.setIsDeleted(identifier, true);
2171
+ * ```
2172
+ *
1951
2173
  * @category Resource State
1952
2174
  * @public
1953
2175
  */
@@ -1959,6 +2181,11 @@ var JSONAPICache = class {
1959
2181
  /**
1960
2182
  * Query the cache for any validation errors applicable to the given resource.
1961
2183
  *
2184
+ * @example
2185
+ * ```ts
2186
+ * const errors = cache.getErrors(identifier);
2187
+ * ```
2188
+ *
1962
2189
  * @category Resource State
1963
2190
  * @public
1964
2191
  */
@@ -1968,6 +2195,13 @@ var JSONAPICache = class {
1968
2195
  /**
1969
2196
  * Query the cache for whether a given resource has any available data
1970
2197
  *
2198
+ * @example
2199
+ * ```ts
2200
+ * if (cache.isEmpty(identifier)) {
2201
+ * // ...
2202
+ * }
2203
+ * ```
2204
+ *
1971
2205
  * @category Resource State
1972
2206
  * @public
1973
2207
  */
@@ -1979,6 +2213,13 @@ var JSONAPICache = class {
1979
2213
  * Query the cache for whether a given resource was created locally and not
1980
2214
  * yet persisted.
1981
2215
  *
2216
+ * @example
2217
+ * ```ts
2218
+ * if (cache.isNew(identifier)) {
2219
+ * // ...
2220
+ * }
2221
+ * ```
2222
+ *
1982
2223
  * @category Resource State
1983
2224
  * @public
1984
2225
  */
@@ -1989,6 +2230,13 @@ var JSONAPICache = class {
1989
2230
  * Query the cache for whether a given resource is marked as deleted (but not
1990
2231
  * necessarily persisted yet).
1991
2232
  *
2233
+ * @example
2234
+ * ```ts
2235
+ * if (cache.isDeleted(identifier)) {
2236
+ * // ...
2237
+ * }
2238
+ * ```
2239
+ *
1992
2240
  * @category Resource State
1993
2241
  * @public
1994
2242
  */
@@ -1999,6 +2247,13 @@ var JSONAPICache = class {
1999
2247
  * Query the cache for whether a given resource has been deleted and that deletion
2000
2248
  * has also been persisted.
2001
2249
  *
2250
+ * @example
2251
+ * ```ts
2252
+ * if (cache.isDeletionCommitted(identifier)) {
2253
+ * // ...
2254
+ * }
2255
+ * ```
2256
+ *
2002
2257
  * @category Resource State
2003
2258
  * @public
2004
2259
  */
@@ -2216,20 +2471,189 @@ function getDefaultValue(schema, identifier, store) {
2216
2471
  if (transform?.defaultValue) return transform.defaultValue(options || null, identifier);
2217
2472
  }
2218
2473
  }
2219
- function calculateChangedKeys(cached, updates, fields) {
2220
- const changedKeys = /* @__PURE__ */ new Set();
2221
- const keys = Object.keys(updates);
2222
- const length = keys.length;
2223
- const localAttrs = cached.localAttrs;
2224
- const original = Object.assign(Object.create(null), cached.remoteAttrs, cached.inflightAttrs);
2225
- for (let i = 0; i < length; i++) {
2226
- const key = keys[i];
2227
- if (!fields.has(key)) continue;
2228
- const value = updates[key];
2229
- if (localAttrs && localAttrs[key] !== void 0) continue;
2230
- if (original[key] !== value) changedKeys.add(key);
2474
+ /**
2475
+ * The first layer, in the given resolution order, that holds `key`. Presence is `in`, so a value
2476
+ * explicitly set to `undefined` counts as present.
2477
+ */
2478
+ function layerHolding(key, layers, order) {
2479
+ for (let i = 0; i < order.length; i++) {
2480
+ const layer = layers[order[i]];
2481
+ if (layer && key in layer) return layer;
2231
2482
  }
2232
- return changedKeys;
2483
+ return null;
2484
+ }
2485
+ /**
2486
+ * What the projection with the given resolution order reads for `attr`. A path resolves its first
2487
+ * segment through the layers and follows the rest into the value, stopping with `undefined` at the
2488
+ * first missing link.
2489
+ */
2490
+ function resolveAttr(attr, layers, order) {
2491
+ const key = typeof attr === "string" ? attr : attr[0];
2492
+ const layer = layerHolding(key, layers, order);
2493
+ if (!layer) return void 0;
2494
+ return typeof attr === "string" ? layer[key] : valueAtPath(layer[key], attr);
2495
+ }
2496
+ /** Follow `path` (from its second segment) into `base`, stopping with `undefined` at the first missing link or `null`. */
2497
+ function valueAtPath(base, path) {
2498
+ let current = base;
2499
+ for (let i = 1; i < path.length; i++) {
2500
+ if (current === void 0 || current === null) return void 0;
2501
+ current = current[path[i]];
2502
+ }
2503
+ return current;
2504
+ }
2505
+ /**
2506
+ * Whether two values of `field` count as the same value for change detection.
2507
+ *
2508
+ * A `schema-object`, and each element of a `schema-array`, compares by the identity hash its
2509
+ * `ObjectSchema` declares (`identity: { kind: '@hash', ... }`), so the schema decides what "same"
2510
+ * means for it. With no hash declared, and for every other field kind, only the same reference
2511
+ * counts. That knowingly over-notifies for equal-content objects: content equality is the schema's
2512
+ * to define, not the cache's to guess.
2513
+ */
2514
+ function attrValuesEqual(schema, field, a, b) {
2515
+ if (a === b) return true;
2516
+ if (field.kind === "schema-object") return schemaObjectsEqual(schema, field, a, b);
2517
+ if (field.kind === "schema-array") return schemaArraysEqual(schema, field, a, b);
2518
+ return false;
2519
+ }
2520
+ /**
2521
+ * A schema-array has no hash of its own: two arrays are the same when they have the same length
2522
+ * and every element compares equal as a schema-object of the element type.
2523
+ */
2524
+ function schemaArraysEqual(schema, field, a, b) {
2525
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
2526
+ for (let i = 0; i < a.length; i++) if (!schemaObjectsEqual(schema, field, a[i], b[i])) return false;
2527
+ return true;
2528
+ }
2529
+ function schemaObjectsEqual(schema, field, a, b) {
2530
+ if (a === b) return true;
2531
+ if (!a || !b || typeof a !== "object" || typeof b !== "object") return false;
2532
+ const ia = fieldValueIdentity(schema, field, a);
2533
+ const ib = fieldValueIdentity(schema, field, b);
2534
+ return ia !== null && ib !== null && ia.type === ib.type && ia.hash !== null && ia.hash === ib.hash;
2535
+ }
2536
+ /**
2537
+ * Whether a nested edit's local clone is back to the baseline, so the edit can end. The schema
2538
+ * decides first, through {@link attrValuesEqual}: a schema-object with an identity hash matches
2539
+ * when its hash does. A value with no schema-defined equality falls back to comparing serialized
2540
+ * content, as this path always has: the clone began as a copy of the baseline, so key order
2541
+ * agrees while edits only replace existing keys. Anything that cannot serialize counts as still
2542
+ * edited, so no edit the cache cannot understand is thrown away.
2543
+ */
2544
+ function localCloneMatchesBaseline(schema, field, baseline, clone) {
2545
+ if (field && attrValuesEqual(schema, field, baseline, clone)) return true;
2546
+ if (!baseline || !clone) return false;
2547
+ try {
2548
+ return JSON.stringify(baseline) === JSON.stringify(clone);
2549
+ } catch {
2550
+ return false;
2551
+ }
2552
+ }
2553
+ /**
2554
+ * Which projection each changed key moved in, so the caller can notify on the matching channel:
2555
+ * `localOnly` and `remoteOnly` on theirs, `both` unscoped.
2556
+ *
2557
+ * `undefined` rather than empty Sets: this runs on every `upsert`, and a resource where nothing
2558
+ * moved should cost no allocation.
2559
+ */
2560
+ const NO_PROJECTION_CHANGES = Object.freeze({
2561
+ localOnly: void 0,
2562
+ remoteOnly: void 0,
2563
+ both: void 0
2564
+ });
2565
+ /** The layers a merge reads: the resource's own, plus the attributes arriving from the server. */
2566
+ function layersForMerge(cached, incomingAttrs) {
2567
+ return {
2568
+ localAttrs: cached.localAttrs,
2569
+ inflightAttrs: cached.inflightAttrs,
2570
+ remoteAttrs: cached.remoteAttrs,
2571
+ defaultAttrs: cached.defaultAttrs,
2572
+ incomingAttrs
2573
+ };
2574
+ }
2575
+ /**
2576
+ * Every key a merge could move: every key in every layer the after-merge order folds into
2577
+ * `remoteAttrs`. `null` when there is nothing to examine.
2578
+ */
2579
+ function candidateKeys(layers, remoteAfterOrder) {
2580
+ let keys = null;
2581
+ for (let i = 0; i < remoteAfterOrder.length; i++) {
2582
+ const layer = remoteAfterOrder[i];
2583
+ if (layer === "remoteAttrs") break;
2584
+ const hash = layers[layer];
2585
+ if (!hash) continue;
2586
+ const layerKeys = Object.keys(hash);
2587
+ if (!layerKeys.length) continue;
2588
+ if (keys === null) {
2589
+ keys = layerKeys;
2590
+ continue;
2591
+ }
2592
+ for (let j = 0; j < layerKeys.length; j++) if (!keys.includes(layerKeys[j])) keys.push(layerKeys[j]);
2593
+ }
2594
+ return keys;
2595
+ }
2596
+ /**
2597
+ * Fold every layer above `remoteAttrs` in the after-merge order for `kind` into `remoteAttrs`,
2598
+ * lowest precedence first, and clear each resource layer that was folded in. The same order
2599
+ * {@link partitionChangedKeys} used to predict the result, so the two cannot disagree.
2600
+ *
2601
+ * Reads the resource layers off `cached` rather than taking a {@link Layered}, so an upsert that
2602
+ * is not calculating changes allocates nothing beyond the merge itself.
2603
+ */
2604
+ function mergeIntoRemote(cached, incomingAttrs, kind) {
2605
+ const folded = MERGE_RESOLUTION[kind].folded;
2606
+ const target = cached.remoteAttrs || Object.create(null);
2607
+ for (let i = 0; i < folded.length; i++) {
2608
+ const layer = folded[i];
2609
+ const hash = layer === "incomingAttrs" ? incomingAttrs : cached[layer];
2610
+ if (!hash) continue;
2611
+ Object.assign(target, hash);
2612
+ if (layer !== "incomingAttrs") cached[layer] = null;
2613
+ if (cached.defaultAttrs) dropMemoizedDefaults(cached.defaultAttrs, hash);
2614
+ }
2615
+ cached.remoteAttrs = target;
2616
+ }
2617
+ function dropMemoizedDefaults(defaultAttrs, replacedBy) {
2618
+ const keys = Object.keys(replacedBy);
2619
+ for (let i = 0; i < keys.length; i++) if (keys[i] in defaultAttrs) delete defaultAttrs[keys[i]];
2620
+ }
2621
+ /**
2622
+ * Partition the keys a merge touches by which projection each one moves in. Must run *before*
2623
+ * {@link mergeIntoRemote}: it reads the pre-merge layers from `layers` and predicts the merge with
2624
+ * the same `RESOLUTION_ORDER_*_AFTER_*` orders `mergeIntoRemote` applies.
2625
+ */
2626
+ function partitionChangedKeys(schema, layers, fields, kind) {
2627
+ const { remoteAfter: remoteAfterOrder, localAfter: localAfterOrder } = MERGE_RESOLUTION[kind];
2628
+ const keys = candidateKeys(layers, remoteAfterOrder);
2629
+ if (keys === null) return NO_PROJECTION_CHANGES;
2630
+ let localOnly;
2631
+ let remoteOnly;
2632
+ let both;
2633
+ for (let i = 0; i < keys.length; i++) {
2634
+ const key = keys[i];
2635
+ const field = fields.get(key);
2636
+ if (!field || isRelationship(field)) continue;
2637
+ const remoteBefore = resolveAttr(key, layers, RESOLUTION_ORDER_REMOTE_STATE);
2638
+ const remoteAfter = resolveAttr(key, layers, remoteAfterOrder);
2639
+ const localBefore = resolveAttr(key, layers, RESOLUTION_ORDER_LOCAL_STATE);
2640
+ const localAfter = resolveAttr(key, layers, localAfterOrder);
2641
+ const remoteMoved = !attrValuesEqual(schema, field, remoteBefore, remoteAfter);
2642
+ const localMoved = !attrValuesEqual(schema, field, localBefore, localAfter);
2643
+ if (remoteMoved && localMoved) (both ??= /* @__PURE__ */ new Set()).add(key);
2644
+ else if (remoteMoved) (remoteOnly ??= /* @__PURE__ */ new Set()).add(key);
2645
+ else if (localMoved) (localOnly ??= /* @__PURE__ */ new Set()).add(key);
2646
+ }
2647
+ return both || remoteOnly || localOnly ? {
2648
+ localOnly,
2649
+ remoteOnly,
2650
+ both
2651
+ } : NO_PROJECTION_CHANGES;
2652
+ }
2653
+ function notifyProjectionChanges(cache, identifier, { both, remoteOnly, localOnly }) {
2654
+ if (both?.size) cache._capabilities.notifyChange(identifier, "attributes", both);
2655
+ if (remoteOnly?.size) cache._capabilities.notifyChange(identifier, "attributes", remoteOnly, "remote");
2656
+ if (localOnly?.size) cache._capabilities.notifyChange(identifier, "attributes", localOnly, "local");
2233
2657
  }
2234
2658
  function cacheIsEmpty(cached) {
2235
2659
  return !cached || cached.remoteAttrs === null && cached.inflightAttrs === null && cached.localAttrs === null;
@@ -2270,25 +2694,26 @@ function isRelationship(field) {
2270
2694
  const { kind } = field;
2271
2695
  return kind === "hasMany" || kind === "belongsTo" || kind === "resource" || kind === "collection";
2272
2696
  }
2273
- function patchLocalAttributes(cached, changedRemoteKeys) {
2274
- const { localAttrs, remoteAttrs, inflightAttrs, defaultAttrs, changes } = cached;
2275
- if (!localAttrs) {
2276
- cached.changes = null;
2277
- return false;
2278
- }
2279
- let hasAppliedPatch = false;
2280
- const mutatedKeys = Object.keys(localAttrs);
2281
- for (let i = 0, length = mutatedKeys.length; i < length; i++) {
2282
- const attr = mutatedKeys[i];
2283
- if ((inflightAttrs && attr in inflightAttrs ? inflightAttrs[attr] : remoteAttrs && attr in remoteAttrs ? remoteAttrs[attr] : void 0) === localAttrs[attr]) {
2284
- hasAppliedPatch = true;
2285
- changedRemoteKeys?.delete(attr);
2286
- delete localAttrs[attr];
2287
- delete changes[attr];
2697
+ /**
2698
+ * After a merge: drop every local edit the new baseline agrees with, by the same equality
2699
+ * {@link partitionChangedKeys} uses. Returns whether any edit was dropped.
2700
+ */
2701
+ function reconcileLocalEdits(schema, cached, fields) {
2702
+ const { localAttrs, defaultAttrs } = cached;
2703
+ if (!localAttrs) return false;
2704
+ let droppedAnEdit = false;
2705
+ const editedKeys = Object.keys(localAttrs);
2706
+ for (let i = 0; i < editedKeys.length; i++) {
2707
+ const key = editedKeys[i];
2708
+ const field = fields.get(key);
2709
+ const baseline = resolveAttr(key, cached, RESOLUTION_ORDER_EDIT_BASELINE);
2710
+ if (field ? attrValuesEqual(schema, field, baseline, localAttrs[key]) : baseline === localAttrs[key]) {
2711
+ droppedAnEdit = true;
2712
+ delete localAttrs[key];
2288
2713
  }
2289
- if (defaultAttrs && attr in defaultAttrs) delete defaultAttrs[attr];
2714
+ if (defaultAttrs && key in defaultAttrs) delete defaultAttrs[key];
2290
2715
  }
2291
- return hasAppliedPatch;
2716
+ return droppedAnEdit;
2292
2717
  }
2293
2718
  function putOne(cache, identifiers, resource) {
2294
2719
  ((test) => {
@@ -2380,7 +2805,7 @@ function copyLinksAndMeta(target, source) {
2380
2805
  if ("meta" in source) target.meta = source.meta;
2381
2806
  }
2382
2807
  function cacheUpsert(cache, identifier, data, calculateChanges) {
2383
- let changedKeys;
2808
+ let changes = NO_PROJECTION_CHANGES;
2384
2809
  const peeked = cache.__safePeek(identifier, false);
2385
2810
  const existed = !!peeked;
2386
2811
  const cached = peeked || cache._createCache(identifier);
@@ -2401,17 +2826,27 @@ function cacheUpsert(cache, identifier, data, calculateChanges) {
2401
2826
  cache._capabilities.notifyChange(identifier, "state", null);
2402
2827
  }
2403
2828
  const fields = getCacheFields(cache, identifier);
2404
- if (calculateChanges && existed && data.attributes) changedKeys = calculateChangedKeys(cached, data.attributes, fields);
2405
- cached.remoteAttrs = Object.assign(cached.remoteAttrs || Object.create(null), data.attributes);
2829
+ if (calculateChanges && existed && data.attributes) changes = partitionChangedKeys(cache._capabilities.schema, layersForMerge(cached, data.attributes), fields, "upsert");
2830
+ mergeIntoRemote(cached, data.attributes ?? null, "upsert");
2406
2831
  if (cached.localAttrs) {
2407
- if (patchLocalAttributes(cached, changedKeys)) cache._capabilities.notifyChange(identifier, "state", null);
2832
+ if (reconcileLocalEdits(cache._capabilities.schema, cached, fields)) cache._capabilities.notifyChange(identifier, "state", null);
2408
2833
  }
2409
2834
  if (!isUpdate) cache._capabilities.notifyChange(identifier, "added", null);
2410
2835
  if (data.id) cached.id = data.id;
2411
2836
  if (data.relationships) setupRelationships(cache.__graph, fields, identifier, data);
2412
- if (changedKeys?.size) cache._capabilities.notifyChange(identifier, "attributes", changedKeys);
2837
+ ((test) => {
2838
+ if (!test) throw new Error("An upsert merges nothing into remote state, so no key can move in the local projection alone");
2839
+ })(!changes.localOnly?.size);
2840
+ notifyProjectionChanges(cache, identifier, changes);
2413
2841
  if (getGlobalConfig().WarpDriveMirror.debug.LOG_CACHE || globalThis.getWarpDriveRuntimeConfig().debug.LOG_CACHE) console.groupEnd();
2414
- return changedKeys?.size ? Array.from(changedKeys) : void 0;
2842
+ return remoteChangedKeys(changes);
2843
+ }
2844
+ /** Every key whose remote value moved, as the `upsert` return value. */
2845
+ function remoteChangedKeys({ both, remoteOnly }) {
2846
+ if (!both?.size) return remoteOnly?.size ? Array.from(remoteOnly) : void 0;
2847
+ const keys = Array.from(both);
2848
+ if (remoteOnly?.size) keys.push(...remoteOnly);
2849
+ return keys;
2415
2850
  }
2416
2851
  function patchCache(Cache, op) {
2417
2852
  const isRecord = isResourceKey(op.record);
@@ -2510,10 +2945,11 @@ function commitDidError(cache, identifier, errors) {
2510
2945
  const keys = Object.keys(cached.inflightAttrs);
2511
2946
  if (keys.length > 0) {
2512
2947
  const attrs = cached.localAttrs = cached.localAttrs || Object.create(null);
2513
- for (let i = 0; i < keys.length; i++) if (attrs[keys[i]] === void 0) attrs[keys[i]] = cached.inflightAttrs[keys[i]];
2948
+ for (let i = 0; i < keys.length; i++) if (!(keys[i] in attrs)) attrs[keys[i]] = cached.inflightAttrs[keys[i]];
2514
2949
  }
2515
2950
  cached.inflightAttrs = null;
2516
2951
  }
2952
+ if (reconcileLocalEdits(cache._capabilities.schema, cached, getCacheFields(cache, identifier))) cache._capabilities.notifyChange(identifier, "state", null);
2517
2953
  if (errors) cached.errors = errors;
2518
2954
  cache._capabilities.notifyChange(identifier, "errors", null);
2519
2955
  }
@@ -2543,7 +2979,7 @@ function didCommit(cache, committedIdentifier, data, op) {
2543
2979
  }
2544
2980
  const fields = getCacheFields(cache, identifier);
2545
2981
  cached.isNew = false;
2546
- let newCanonicalAttributes;
2982
+ let responseAttrs = null;
2547
2983
  if (data) {
2548
2984
  if (data.id && !cached.id) cached.id = data.id;
2549
2985
  if (identifier === committedIdentifier && identifier.id !== existingId) cache._capabilities.notifyChange(identifier, "identity", null);
@@ -2551,17 +2987,16 @@ function didCommit(cache, committedIdentifier, data, op) {
2551
2987
  if (!test) throw new Error(`Expected the ID received for the primary '${identifier.type}' resource being saved to match the current id '${cached.id}' but received '${identifier.id}'.`);
2552
2988
  })(identifier.id === cached.id);
2553
2989
  if (data.relationships) setupRelationships(cache.__graph, fields, identifier, data);
2554
- newCanonicalAttributes = data.attributes;
2990
+ responseAttrs = data.attributes ?? null;
2555
2991
  }
2556
- const changedKeys = newCanonicalAttributes && calculateChangedKeys(cached, newCanonicalAttributes, fields);
2557
- cached.remoteAttrs = Object.assign(cached.remoteAttrs || Object.create(null), cached.inflightAttrs, newCanonicalAttributes);
2558
- cached.inflightAttrs = null;
2559
- patchLocalAttributes(cached, changedKeys);
2992
+ const changes = partitionChangedKeys(cache._capabilities.schema, layersForMerge(cached, responseAttrs), fields, "commit");
2993
+ mergeIntoRemote(cached, responseAttrs, "commit");
2994
+ reconcileLocalEdits(cache._capabilities.schema, cached, fields);
2560
2995
  if (cached.errors) {
2561
2996
  cached.errors = null;
2562
2997
  cache._capabilities.notifyChange(identifier, "errors", null);
2563
2998
  }
2564
- if (changedKeys?.size) cache._capabilities.notifyChange(identifier, "attributes", changedKeys);
2999
+ notifyProjectionChanges(cache, identifier, changes);
2565
3000
  cache._capabilities.notifyChange(identifier, "state", null);
2566
3001
  }
2567
3002
  function willCommit(cache, identifier) {