@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.
package/dist/index.js CHANGED
@@ -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
  import { getGlobalConfig, macroCondition } from "@embroider/macros";
@@ -938,14 +938,101 @@ const EMPTY_ITERATOR = { iterator() {
938
938
  };
939
939
  } };
940
940
  } };
941
+ /** One attributes hash on a {@link CachedResource}: a **layer** a projection is read through. */
942
+ /**
943
+ * The cache's entry for a single resource: its id, its attribute values split
944
+ * across four **layers**, and the flags tracking where it sits in the
945
+ * create/update/delete lifecycle.
946
+ *
947
+ * A **projection** answers "what is this field's value" by reading a stack of
948
+ * layers top-down; the first layer holding the field wins. The two projections,
949
+ * with their resolution orders:
950
+ *
951
+ * - **remote state**, what the _immutable_ record reads:
952
+ * {@link RESOLUTION_ORDER_REMOTE_STATE}
953
+ * - **local state**, what an _editable_ copy reads:
954
+ * {@link RESOLUTION_ORDER_LOCAL_STATE}
955
+ *
956
+ * Local state is remote state with the uncommitted mutations laid over it. The
957
+ * guides call those mutations "the diff"; here they are simply the top layers of
958
+ * local state, {@link CachedResource.localAttrs | localAttrs} and
959
+ * {@link CachedResource.inflightAttrs | inflightAttrs}.
960
+ *
961
+ * A field is **dirty** while `localAttrs` or `inflightAttrs` holds it. A save
962
+ * **commits** the mutation: `didCommit` merges the in-flight values into
963
+ * `remoteAttrs` and removes them from `inflightAttrs`. A push carrying the same
964
+ * value as a pending edit in `localAttrs` has the same effect on that edit: the
965
+ * server already holds it, so it is removed from `localAttrs`. "Same value" is
966
+ * decided by the field's schema: a schema-object with an identity hash compares
967
+ * by that hash, everything else by reference.
968
+ *
969
+ * Thus, a dirty field reads as mutated for local readers only, and goes on doing
970
+ * so while its save is in flight.
971
+ *
972
+ * @internal
973
+ */
974
+ /** A layer name on a {@link CachedResource}. */
975
+ /**
976
+ * A layer that exists only for the duration of a merge: the attributes arriving from the
977
+ * server in a save response or a push. {@link partitionChangedKeys} reads it as if it were
978
+ * already on the resource; {@link mergeIntoRemote} then folds the same attributes in, handed
979
+ * to it directly. Never part of a reader's resolution order.
980
+ */
981
+ /** Anything a projection can be read through: a {@link CachedResource}, or the layers a merge reads, built around one. */
982
+ /** What the immutable record reads. */
983
+ const RESOLUTION_ORDER_REMOTE_STATE = ["remoteAttrs", "defaultAttrs"];
984
+ /**
985
+ * What a new local edit replaces: the in-flight value if a save is carrying one, else the
986
+ * persisted value. Put another way, what the record will read once the in-flight save lands,
987
+ * assuming the server agrees. This is local state beneath `localAttrs`, less `defaultAttrs`: an
988
+ * edit equal to a schema default is still an edit, since nothing persisted holds that value.
989
+ *
990
+ * Saving does not use this baseline. {@link JSONAPICache.changedAttrs | changedAttrs} still lists
991
+ * in-flight values as unsaved, so a second save re-sends them rather than assuming the first one
992
+ * will succeed.
993
+ */
994
+ const RESOLUTION_ORDER_EDIT_BASELINE = ["inflightAttrs", "remoteAttrs"];
995
+ /** What an editable copy reads. */
996
+ const RESOLUTION_ORDER_LOCAL_STATE = [
997
+ "localAttrs",
998
+ ...RESOLUTION_ORDER_EDIT_BASELINE,
999
+ "defaultAttrs"
1000
+ ];
1001
+ const RESOLUTION_ORDER_REMOTE_AFTER_COMMIT = [
1002
+ "incomingAttrs",
1003
+ "inflightAttrs",
1004
+ ...RESOLUTION_ORDER_REMOTE_STATE
1005
+ ];
1006
+ const RESOLUTION_ORDER_LOCAL_AFTER_COMMIT = ["localAttrs", ...RESOLUTION_ORDER_REMOTE_AFTER_COMMIT];
1007
+ const RESOLUTION_ORDER_REMOTE_AFTER_UPSERT = ["incomingAttrs", ...RESOLUTION_ORDER_REMOTE_STATE];
1008
+ const RESOLUTION_ORDER_LOCAL_AFTER_UPSERT = [
1009
+ "localAttrs",
1010
+ "inflightAttrs",
1011
+ ...RESOLUTION_ORDER_REMOTE_AFTER_UPSERT
1012
+ ];
1013
+ /** The layers an after-merge order folds into `remoteAttrs`, lowest precedence first. */
1014
+ function layersFoldedIntoRemote(order) {
1015
+ return order.slice(0, order.indexOf("remoteAttrs")).reverse();
1016
+ }
1017
+ const MERGE_RESOLUTION = {
1018
+ commit: {
1019
+ remoteAfter: RESOLUTION_ORDER_REMOTE_AFTER_COMMIT,
1020
+ localAfter: RESOLUTION_ORDER_LOCAL_AFTER_COMMIT,
1021
+ folded: layersFoldedIntoRemote(RESOLUTION_ORDER_REMOTE_AFTER_COMMIT)
1022
+ },
1023
+ upsert: {
1024
+ remoteAfter: RESOLUTION_ORDER_REMOTE_AFTER_UPSERT,
1025
+ localAfter: RESOLUTION_ORDER_LOCAL_AFTER_UPSERT,
1026
+ folded: layersFoldedIntoRemote(RESOLUTION_ORDER_REMOTE_AFTER_UPSERT)
1027
+ }
1028
+ };
941
1029
  function makeCache() {
942
1030
  return {
943
1031
  id: null,
944
- remoteAttrs: null,
945
1032
  localAttrs: null,
946
- defaultAttrs: null,
947
1033
  inflightAttrs: null,
948
- changes: null,
1034
+ remoteAttrs: null,
1035
+ defaultAttrs: null,
949
1036
  errors: null,
950
1037
  isNew: false,
951
1038
  isDeleted: false,
@@ -996,9 +1083,9 @@ var JSONAPICache = class {
996
1083
  /**
997
1084
  * Cache the response to a request
998
1085
  *
999
- * Implements `Cache.put`.
1086
+ * Implements {@link Cache.put | Cache.put}.
1000
1087
  *
1001
- * Expects a StructuredDocument whose `content` member is a JsonApiDocument.
1088
+ * Expects a {@link StructuredDocument} whose `content` member is a JsonApiDocument.
1002
1089
  *
1003
1090
  * ```js
1004
1091
  * cache.put({
@@ -1133,6 +1220,16 @@ var JSONAPICache = class {
1133
1220
  * Update the "remote" or "canonical" (persisted) state of the Cache
1134
1221
  * by merging new information into the existing state.
1135
1222
  *
1223
+ * @example
1224
+ * ```ts
1225
+ * cache.patch({
1226
+ * op: 'update',
1227
+ * record: identifier,
1228
+ * field: 'name',
1229
+ * value: 'Chris',
1230
+ * });
1231
+ * ```
1232
+ *
1136
1233
  * @category Cache Management
1137
1234
  * @public
1138
1235
  * @param op the operation or list of operations to perform
@@ -1154,6 +1251,16 @@ var JSONAPICache = class {
1154
1251
  /**
1155
1252
  * Update the "local" or "current" (unpersisted) state of the Cache
1156
1253
  *
1254
+ * @example
1255
+ * ```ts
1256
+ * cache.mutate({
1257
+ * op: 'replaceRelatedRecord',
1258
+ * record: identifier,
1259
+ * field: 'author',
1260
+ * value: authorIdentifier,
1261
+ * });
1262
+ * ```
1263
+ *
1157
1264
  * @category Cache Management
1158
1265
  * @public
1159
1266
  */
@@ -1194,8 +1301,8 @@ var JSONAPICache = class {
1194
1301
  * not require retainining connections to the Store
1195
1302
  * and Cache to present data on a per-field basis.
1196
1303
  *
1197
- * This generally takes the place of `getAttr` as
1198
- * an API and may even take the place of `getRelationship`
1304
+ * This generally takes the place of {@link JSONAPICache.getAttr | getAttr} as
1305
+ * an API and may even take the place of {@link JSONAPICache.getRelationship | getRelationship}
1199
1306
  * depending on implementation specifics, though this
1200
1307
  * latter usage is less recommended due to the advantages
1201
1308
  * of the Graph handling necessary entanglements and
@@ -1210,6 +1317,12 @@ var JSONAPICache = class {
1210
1317
  * the various internal WarpDrive bookkeeping fields.
1211
1318
  * :::
1212
1319
  *
1320
+ * @example
1321
+ * ```ts
1322
+ * const resource = cache.peek(identifier);
1323
+ * const document = cache.peek(requestKey);
1324
+ * ```
1325
+ *
1213
1326
  * @category Cache Management
1214
1327
  * @public
1215
1328
  */
@@ -1249,6 +1362,12 @@ var JSONAPICache = class {
1249
1362
  /**
1250
1363
  * Peek the remote resource data from the Cache.
1251
1364
  *
1365
+ * @example
1366
+ * ```ts
1367
+ * const resource = cache.peekRemoteState(identifier);
1368
+ * const document = cache.peekRemoteState(requestKey);
1369
+ * ```
1370
+ *
1252
1371
  * @category Cache Management
1253
1372
  * @public
1254
1373
  */
@@ -1289,9 +1408,14 @@ var JSONAPICache = class {
1289
1408
  * Peek the Cache for the existing request data associated with
1290
1409
  * a cacheable request.
1291
1410
  *
1292
- * This is effectively the reverse of `put` for a request in
1411
+ * This is effectively the reverse of {@link JSONAPICache.put | put} for a request in
1293
1412
  * that it will return the the request, response, and content
1294
- * whereas `peek` will return just the `content`.
1413
+ * whereas {@link JSONAPICache.peek | peek} will return just the `content`.
1414
+ *
1415
+ * @example
1416
+ * ```ts
1417
+ * const doc = cache.peekRequest(requestKey);
1418
+ * ```
1295
1419
  *
1296
1420
  * @category Cache Management
1297
1421
  * @public
@@ -1302,9 +1426,20 @@ var JSONAPICache = class {
1302
1426
  /**
1303
1427
  * Push resource data from a remote source into the cache for this identifier
1304
1428
  *
1429
+ * @example
1430
+ * ```ts
1431
+ * cache.upsert(identifier, {
1432
+ * type: 'user',
1433
+ * id: '1',
1434
+ * attributes: { name: 'Chris' },
1435
+ * });
1436
+ * ```
1437
+ *
1305
1438
  * @category Cache Management
1306
1439
  * @public
1307
- * @return if `calculateChanges` is true then calculated key changes should be returned
1440
+ * @return when `calculateChanges` is true, the names of the attributes whose persisted value
1441
+ * this push changed (the same keys the `'remote'` channel is notified with), or `undefined`
1442
+ * when none did. Otherwise `void`.
1308
1443
  */
1309
1444
  upsert(identifier, data, calculateChanges) {
1310
1445
  assertPrivateCapabilities(this._capabilities);
@@ -1350,7 +1485,7 @@ var JSONAPICache = class {
1350
1485
  *
1351
1486
  * Each individual resource or document that has
1352
1487
  * been mutated should be described as an individual
1353
- * `Change` entry in the returned array.
1488
+ * {@link Change} entry in the returned array.
1354
1489
  *
1355
1490
  * A `Change` is described by an object containing up to
1356
1491
  * three properties: (1) the `identifier` of the entity that
@@ -1415,6 +1550,11 @@ var JSONAPICache = class {
1415
1550
  * It returns properties from options that should be set on the record during the create
1416
1551
  * process. This return value behavior is deprecated.
1417
1552
  *
1553
+ * @example
1554
+ * ```ts
1555
+ * cache.clientDidCreate(identifier, { name: 'Chris' });
1556
+ * ```
1557
+ *
1418
1558
  * @category Resource Lifecycle
1419
1559
  * @public
1420
1560
  */
@@ -1479,6 +1619,11 @@ var JSONAPICache = class {
1479
1619
  * [LIFECYCLE] Signals to the cache that a resource
1480
1620
  * will be part of a save transaction.
1481
1621
  *
1622
+ * @example
1623
+ * ```ts
1624
+ * cache.willCommit(identifier, context);
1625
+ * ```
1626
+ *
1482
1627
  * @category Resource Lifecycle
1483
1628
  * @public
1484
1629
  */
@@ -1490,6 +1635,11 @@ var JSONAPICache = class {
1490
1635
  * [LIFECYCLE] Signals to the cache that a resource
1491
1636
  * was successfully updated as part of a save transaction.
1492
1637
  *
1638
+ * @example
1639
+ * ```ts
1640
+ * cache.didCommit(identifier, result);
1641
+ * ```
1642
+ *
1493
1643
  * @category Resource Lifecycle
1494
1644
  * @public
1495
1645
  */
@@ -1529,6 +1679,11 @@ var JSONAPICache = class {
1529
1679
  * [LIFECYCLE] Signals to the cache that a resource
1530
1680
  * was update via a save transaction failed.
1531
1681
  *
1682
+ * @example
1683
+ * ```ts
1684
+ * cache.commitWasRejected(identifier, errors);
1685
+ * ```
1686
+ *
1532
1687
  * @category Resource Lifecycle
1533
1688
  * @public
1534
1689
  */
@@ -1545,6 +1700,11 @@ var JSONAPICache = class {
1545
1700
  *
1546
1701
  * This method is a candidate to become a mutation
1547
1702
  *
1703
+ * @example
1704
+ * ```ts
1705
+ * cache.unloadRecord(identifier);
1706
+ * ```
1707
+ *
1548
1708
  * @category Resource Lifecycle
1549
1709
  * @public
1550
1710
  */
@@ -1585,6 +1745,12 @@ var JSONAPICache = class {
1585
1745
  * Retrieve the data for an attribute from the cache
1586
1746
  * with local mutations applied.
1587
1747
  *
1748
+ * @example
1749
+ * ```ts
1750
+ * const name = cache.getAttr(identifier, 'name');
1751
+ * const zip = cache.getAttr(identifier, ['address', 'zip']);
1752
+ * ```
1753
+ *
1588
1754
  * @category Resource Data
1589
1755
  * @public
1590
1756
  */
@@ -1598,37 +1764,28 @@ var JSONAPICache = class {
1598
1764
  if (!test) throw new Error(`Cannot retrieve attributes for identifier ${String(identifier)} as it is not present in the cache`);
1599
1765
  })(cached);
1600
1766
  if (!cached) return;
1601
- if (cached.localAttrs && attribute in cached.localAttrs) return cached.localAttrs[attribute];
1602
- else if (cached.inflightAttrs && attribute in cached.inflightAttrs) return cached.inflightAttrs[attribute];
1603
- else if (cached.remoteAttrs && attribute in cached.remoteAttrs) return cached.remoteAttrs[attribute];
1604
- else if (cached.defaultAttrs && attribute in cached.defaultAttrs) return cached.defaultAttrs[attribute];
1605
- else {
1606
- const attrSchema = getCacheFields(this, identifier).get(attribute);
1607
- assertPrivateCapabilities(this._capabilities);
1608
- const defaultValue = getDefaultValue(attrSchema, identifier, this._capabilities._store);
1609
- if (schemaHasLegacyDefaultValueFn(attrSchema)) {
1610
- cached.defaultAttrs = cached.defaultAttrs || Object.create(null);
1611
- cached.defaultAttrs[attribute] = defaultValue;
1612
- }
1613
- return defaultValue;
1767
+ const layer = layerHolding(attribute, cached, RESOLUTION_ORDER_LOCAL_STATE);
1768
+ if (layer) return layer[attribute];
1769
+ const attrSchema = getCacheFields(this, identifier).get(attribute);
1770
+ assertPrivateCapabilities(this._capabilities);
1771
+ const defaultValue = getDefaultValue(attrSchema, identifier, this._capabilities._store);
1772
+ if (schemaHasLegacyDefaultValueFn(attrSchema)) {
1773
+ cached.defaultAttrs = cached.defaultAttrs || Object.create(null);
1774
+ cached.defaultAttrs[attribute] = defaultValue;
1614
1775
  }
1776
+ return defaultValue;
1615
1777
  }
1616
- const path = attr;
1617
1778
  const cached = this.__peek(identifier, true);
1618
- const basePath = path[0];
1619
- let current = cached.localAttrs && basePath in cached.localAttrs ? cached.localAttrs[basePath] : void 0;
1620
- if (current === void 0) current = cached.inflightAttrs && basePath in cached.inflightAttrs ? cached.inflightAttrs[basePath] : void 0;
1621
- if (current === void 0) current = cached.remoteAttrs && basePath in cached.remoteAttrs ? cached.remoteAttrs[basePath] : void 0;
1622
- if (current === void 0) return;
1623
- for (let i = 1; i < path.length; i++) {
1624
- current = current[path[i]];
1625
- if (current === void 0) return;
1626
- }
1627
- return current;
1779
+ return resolveAttr(attr, cached, RESOLUTION_ORDER_LOCAL_STATE);
1628
1780
  }
1629
1781
  /**
1630
1782
  * Retrieve the remote data for an attribute from the cache
1631
1783
  *
1784
+ * @example
1785
+ * ```ts
1786
+ * const name = cache.getRemoteAttr(identifier, 'name');
1787
+ * ```
1788
+ *
1632
1789
  * @category Resource Data
1633
1790
  * @public
1634
1791
  */
@@ -1642,35 +1799,30 @@ var JSONAPICache = class {
1642
1799
  if (!test) throw new Error(`Cannot retrieve remote attributes for identifier ${String(identifier)} as it is not present in the cache`);
1643
1800
  })(cached);
1644
1801
  if (!cached) return;
1645
- if (cached.remoteAttrs && attribute in cached.remoteAttrs) return cached.remoteAttrs[attribute];
1646
- else if (cached.defaultAttrs && attribute in cached.defaultAttrs) return cached.defaultAttrs[attribute];
1647
- else {
1648
- const attrSchema = getCacheFields(this, identifier).get(attribute);
1649
- assertPrivateCapabilities(this._capabilities);
1650
- const defaultValue = getDefaultValue(attrSchema, identifier, this._capabilities._store);
1651
- if (schemaHasLegacyDefaultValueFn(attrSchema)) {
1652
- cached.defaultAttrs = cached.defaultAttrs || Object.create(null);
1653
- cached.defaultAttrs[attribute] = defaultValue;
1654
- }
1655
- return defaultValue;
1802
+ const layer = layerHolding(attribute, cached, RESOLUTION_ORDER_REMOTE_STATE);
1803
+ if (layer) return layer[attribute];
1804
+ const attrSchema = getCacheFields(this, identifier).get(attribute);
1805
+ assertPrivateCapabilities(this._capabilities);
1806
+ const defaultValue = getDefaultValue(attrSchema, identifier, this._capabilities._store);
1807
+ if (schemaHasLegacyDefaultValueFn(attrSchema)) {
1808
+ cached.defaultAttrs = cached.defaultAttrs || Object.create(null);
1809
+ cached.defaultAttrs[attribute] = defaultValue;
1656
1810
  }
1811
+ return defaultValue;
1657
1812
  }
1658
- const path = attr;
1659
1813
  const cached = this.__peek(identifier, true);
1660
- const basePath = path[0];
1661
- let current = cached.remoteAttrs && basePath in cached.remoteAttrs ? cached.remoteAttrs[basePath] : void 0;
1662
- if (current === void 0) return;
1663
- for (let i = 1; i < path.length; i++) {
1664
- current = current[path[i]];
1665
- if (current === void 0) return;
1666
- }
1667
- return current;
1814
+ return resolveAttr(attr, cached, RESOLUTION_ORDER_REMOTE_STATE);
1668
1815
  }
1669
1816
  /**
1670
1817
  * Mutate the data for an attribute in the cache
1671
1818
  *
1672
1819
  * This method is a candidate to become a mutation
1673
1820
  *
1821
+ * @example
1822
+ * ```ts
1823
+ * cache.setAttr(identifier, 'name', 'Chris');
1824
+ * ```
1825
+ *
1674
1826
  * @category Resource Data
1675
1827
  * @public
1676
1828
  */
@@ -1678,21 +1830,19 @@ var JSONAPICache = class {
1678
1830
  macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) && ((test) => {
1679
1831
  if (!test) throw new Error("setAttr must receive at least one attribute path");
1680
1832
  })(attr.length > 0);
1833
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) && ((test) => {
1834
+ 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.`);
1835
+ })(value !== void 0);
1836
+ if (value === void 0) value = null;
1681
1837
  const isSimplePath = !Array.isArray(attr) || attr.length === 1;
1682
1838
  if (Array.isArray(attr) && attr.length === 1) attr = attr[0];
1683
1839
  if (isSimplePath) {
1684
1840
  const cached = this.__peek(identifier, false);
1685
1841
  const currentAttr = attr;
1686
- const existing = cached.inflightAttrs && currentAttr in cached.inflightAttrs ? cached.inflightAttrs[currentAttr] : cached.remoteAttrs && currentAttr in cached.remoteAttrs ? cached.remoteAttrs[currentAttr] : void 0;
1687
- if (existing !== value) {
1842
+ if (resolveAttr(currentAttr, cached, RESOLUTION_ORDER_EDIT_BASELINE) !== value) {
1688
1843
  cached.localAttrs = cached.localAttrs || Object.create(null);
1689
1844
  cached.localAttrs[currentAttr] = value;
1690
- cached.changes = cached.changes || Object.create(null);
1691
- cached.changes[currentAttr] = [existing, value];
1692
- } else if (cached.localAttrs) {
1693
- delete cached.localAttrs[currentAttr];
1694
- delete cached.changes[currentAttr];
1695
- }
1845
+ } else if (cached.localAttrs) delete cached.localAttrs[currentAttr];
1696
1846
  if (cached.defaultAttrs && currentAttr in cached.defaultAttrs) delete cached.defaultAttrs[currentAttr];
1697
1847
  this._capabilities.notifyChange(identifier, "attributes", currentAttr, "local");
1698
1848
  return;
@@ -1700,32 +1850,50 @@ var JSONAPICache = class {
1700
1850
  const path = attr;
1701
1851
  const cached = this.__peek(identifier, false);
1702
1852
  const basePath = path[0];
1703
- const existing = cached.inflightAttrs && basePath in cached.inflightAttrs ? cached.inflightAttrs[basePath] : cached.remoteAttrs && basePath in cached.remoteAttrs ? cached.remoteAttrs[basePath] : void 0;
1704
- let existingAttr;
1705
- if (existing) {
1706
- existingAttr = existing[path[1]];
1707
- for (let i = 2; i < path.length; i++) existingAttr = existingAttr[path[i]];
1853
+ const baseline = resolveAttr(basePath, cached, RESOLUTION_ORDER_EDIT_BASELINE);
1854
+ const isRevert = valueAtPath(baseline, path) === value;
1855
+ const hasLocalClone = !!cached.localAttrs && basePath in cached.localAttrs;
1856
+ if (isRevert && !hasLocalClone) return;
1857
+ cached.localAttrs = cached.localAttrs || Object.create(null);
1858
+ if (!hasLocalClone) {
1859
+ const seed = baseline ?? (cached.defaultAttrs ? cached.defaultAttrs[basePath] : void 0);
1860
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) && ((test) => {
1861
+ if (!test) throw new Error(`Cannot set '${path.join(".")}' on '${identifier.type}': '${basePath}' holds no object to write into`);
1862
+ })(!!seed && typeof seed === "object");
1863
+ cached.localAttrs[basePath] = structuredClone(seed);
1708
1864
  }
1709
- if (existingAttr !== value) {
1710
- cached.localAttrs = cached.localAttrs || Object.create(null);
1711
- cached.localAttrs[basePath] = cached.localAttrs[basePath] || structuredClone(existing);
1712
- cached.changes = cached.changes || Object.create(null);
1713
- let currentLocal = cached.localAttrs[basePath];
1714
- let nextLink = 1;
1715
- while (nextLink < path.length - 1) currentLocal = currentLocal[path[nextLink++]];
1716
- currentLocal[path[nextLink]] = value;
1717
- cached.changes[basePath] = [existing, cached.localAttrs[basePath]];
1718
- } else if (cached.localAttrs) try {
1719
- if (!existing) return;
1720
- if (JSON.stringify(existing) !== JSON.stringify(cached.localAttrs[basePath])) {
1721
- delete cached.localAttrs[basePath];
1722
- delete cached.changes[basePath];
1723
- }
1724
- } catch {}
1865
+ let currentLocal = cached.localAttrs[basePath];
1866
+ let nextLink = 1;
1867
+ while (nextLink < path.length - 1) currentLocal = currentLocal[path[nextLink++]];
1868
+ if (currentLocal[path[nextLink]] === value) return;
1869
+ currentLocal[path[nextLink]] = value;
1870
+ if (isRevert) {
1871
+ const field = getCacheFields(this, identifier).get(basePath);
1872
+ if (localCloneMatchesBaseline(this._capabilities.schema, field, baseline, cached.localAttrs[basePath])) delete cached.localAttrs[basePath];
1873
+ }
1874
+ if (cached.defaultAttrs && basePath in cached.defaultAttrs) delete cached.defaultAttrs[basePath];
1725
1875
  this._capabilities.notifyChange(identifier, "attributes", basePath, "local");
1726
1876
  }
1727
1877
  /**
1728
- * Query the cache for the changed attributes of a resource.
1878
+ * Query the cache for the changed attributes of a resource: every unsaved
1879
+ * mutation, as a `[before, after]` pair per field.
1880
+ *
1881
+ * `before` is the value the mutation replaces, which is not always the
1882
+ * persisted one. A mutation a save is carrying replaces remote state; an edit
1883
+ * made while that save is in flight replaces the in-flight value. So this is
1884
+ * what saving from here would change, which is what `serializePatch` and the
1885
+ * legacy `Snapshot` consume, rather than a diff against persisted state.
1886
+ *
1887
+ * Derived from the layers on each call, so it is always consistent with
1888
+ * {@link JSONAPICache.getAttr | getAttr} and
1889
+ * {@link JSONAPICache.rollbackAttrs | rollbackAttrs}. Dirtiness does not go
1890
+ * through here; see {@link JSONAPICache.hasChangedAttrs | hasChangedAttrs}.
1891
+ *
1892
+ * @example
1893
+ * ```ts
1894
+ * const changes = cache.changedAttrs(identifier);
1895
+ * // { name: ['Igor', 'Chris'] }
1896
+ * ```
1729
1897
  *
1730
1898
  * @category Resource Data
1731
1899
  * @public
@@ -1736,12 +1904,29 @@ var JSONAPICache = class {
1736
1904
  macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) && ((test) => {
1737
1905
  if (!test) throw new Error(`Cannot retrieve changed attributes for identifier ${String(identifier)} as it is not present in the cache`);
1738
1906
  })(cached);
1739
- if (!cached) return Object.create(null);
1740
- return cached.changes || Object.create(null);
1907
+ const changes = Object.create(null);
1908
+ if (!cached) return changes;
1909
+ const { localAttrs, inflightAttrs, remoteAttrs } = cached;
1910
+ if (inflightAttrs) {
1911
+ const keys = Object.keys(inflightAttrs);
1912
+ for (let i = 0; i < keys.length; i++) changes[keys[i]] = [remoteAttrs ? remoteAttrs[keys[i]] : void 0, inflightAttrs[keys[i]]];
1913
+ }
1914
+ if (localAttrs) {
1915
+ const keys = Object.keys(localAttrs);
1916
+ for (let i = 0; i < keys.length; i++) changes[keys[i]] = [resolveAttr(keys[i], cached, RESOLUTION_ORDER_EDIT_BASELINE), localAttrs[keys[i]]];
1917
+ }
1918
+ return changes;
1741
1919
  }
1742
1920
  /**
1743
1921
  * Query the cache for whether any mutated attributes exist
1744
1922
  *
1923
+ * @example
1924
+ * ```ts
1925
+ * if (cache.hasChangedAttrs(identifier)) {
1926
+ * // ...
1927
+ * }
1928
+ * ```
1929
+ *
1745
1930
  * @category Resource Data
1746
1931
  * @public
1747
1932
  */
@@ -1758,6 +1943,11 @@ var JSONAPICache = class {
1758
1943
  *
1759
1944
  * This method is a candidate to become a mutation
1760
1945
  *
1946
+ * @example
1947
+ * ```ts
1948
+ * const restoredKeys = cache.rollbackAttrs(identifier);
1949
+ * ```
1950
+ *
1761
1951
  * @category Resource Data
1762
1952
  * @public
1763
1953
  * @return the names of fields that were restored
@@ -1769,7 +1959,6 @@ var JSONAPICache = class {
1769
1959
  if (cached.localAttrs !== null) {
1770
1960
  dirtyKeys = Object.keys(cached.localAttrs);
1771
1961
  cached.localAttrs = null;
1772
- cached.changes = null;
1773
1962
  }
1774
1963
  if (cached.isNew) {
1775
1964
  cached.isDeletionCommitted = true;
@@ -1789,7 +1978,7 @@ var JSONAPICache = class {
1789
1978
  /**
1790
1979
  * Query the cache for the changes to relationships of a resource.
1791
1980
  *
1792
- * Returns a map of relationship names to RelationshipDiff objects.
1981
+ * Returns a map of relationship names to {@link RelationshipDiff} objects.
1793
1982
  *
1794
1983
  * ```ts
1795
1984
  * type RelationshipDiff =
@@ -1808,6 +1997,12 @@ var JSONAPICache = class {
1808
1997
  };
1809
1998
  ```
1810
1999
  *
2000
+ * @example
2001
+ * ```ts
2002
+ * const diffs = cache.changedRelationships(identifier);
2003
+ * const comments = diffs.get('comments');
2004
+ * ```
2005
+ *
1811
2006
  * @category Resource Data
1812
2007
  * @public
1813
2008
  */
@@ -1817,6 +2012,13 @@ var JSONAPICache = class {
1817
2012
  /**
1818
2013
  * Query the cache for whether any mutated relationships exist
1819
2014
  *
2015
+ * @example
2016
+ * ```ts
2017
+ * if (cache.hasChangedRelationships(identifier)) {
2018
+ * // ...
2019
+ * }
2020
+ * ```
2021
+ *
1820
2022
  * @category Resource Data
1821
2023
  * @public
1822
2024
  */
@@ -1830,6 +2032,11 @@ var JSONAPICache = class {
1830
2032
  *
1831
2033
  * This method is a candidate to become a mutation
1832
2034
  *
2035
+ * @example
2036
+ * ```ts
2037
+ * const restoredFields = cache.rollbackRelationships(identifier);
2038
+ * ```
2039
+ *
1833
2040
  * @category Resource Data
1834
2041
  * @public
1835
2042
  * @return the names of relationships that were restored
@@ -1845,6 +2052,11 @@ var JSONAPICache = class {
1845
2052
  /**
1846
2053
  * Query the cache for the current state of a relationship property
1847
2054
  *
2055
+ * @example
2056
+ * ```ts
2057
+ * const relationship = cache.getRelationship(identifier, 'comments');
2058
+ * ```
2059
+ *
1848
2060
  * @category Resource Data
1849
2061
  * @public
1850
2062
  * @return resource relationship object
@@ -1855,6 +2067,11 @@ var JSONAPICache = class {
1855
2067
  /**
1856
2068
  * Query the cache for the remote state of a relationship property
1857
2069
  *
2070
+ * @example
2071
+ * ```ts
2072
+ * const relationship = cache.getRemoteRelationship(identifier, 'comments');
2073
+ * ```
2074
+ *
1858
2075
  * @category Resource Data
1859
2076
  * @public
1860
2077
  * @return resource relationship object
@@ -1868,6 +2085,11 @@ var JSONAPICache = class {
1868
2085
  *
1869
2086
  * This method is a candidate to become a mutation
1870
2087
  *
2088
+ * @example
2089
+ * ```ts
2090
+ * cache.setIsDeleted(identifier, true);
2091
+ * ```
2092
+ *
1871
2093
  * @category Resource State
1872
2094
  * @public
1873
2095
  */
@@ -1879,6 +2101,11 @@ var JSONAPICache = class {
1879
2101
  /**
1880
2102
  * Query the cache for any validation errors applicable to the given resource.
1881
2103
  *
2104
+ * @example
2105
+ * ```ts
2106
+ * const errors = cache.getErrors(identifier);
2107
+ * ```
2108
+ *
1882
2109
  * @category Resource State
1883
2110
  * @public
1884
2111
  */
@@ -1888,6 +2115,13 @@ var JSONAPICache = class {
1888
2115
  /**
1889
2116
  * Query the cache for whether a given resource has any available data
1890
2117
  *
2118
+ * @example
2119
+ * ```ts
2120
+ * if (cache.isEmpty(identifier)) {
2121
+ * // ...
2122
+ * }
2123
+ * ```
2124
+ *
1891
2125
  * @category Resource State
1892
2126
  * @public
1893
2127
  */
@@ -1899,6 +2133,13 @@ var JSONAPICache = class {
1899
2133
  * Query the cache for whether a given resource was created locally and not
1900
2134
  * yet persisted.
1901
2135
  *
2136
+ * @example
2137
+ * ```ts
2138
+ * if (cache.isNew(identifier)) {
2139
+ * // ...
2140
+ * }
2141
+ * ```
2142
+ *
1902
2143
  * @category Resource State
1903
2144
  * @public
1904
2145
  */
@@ -1909,6 +2150,13 @@ var JSONAPICache = class {
1909
2150
  * Query the cache for whether a given resource is marked as deleted (but not
1910
2151
  * necessarily persisted yet).
1911
2152
  *
2153
+ * @example
2154
+ * ```ts
2155
+ * if (cache.isDeleted(identifier)) {
2156
+ * // ...
2157
+ * }
2158
+ * ```
2159
+ *
1912
2160
  * @category Resource State
1913
2161
  * @public
1914
2162
  */
@@ -1919,6 +2167,13 @@ var JSONAPICache = class {
1919
2167
  * Query the cache for whether a given resource has been deleted and that deletion
1920
2168
  * has also been persisted.
1921
2169
  *
2170
+ * @example
2171
+ * ```ts
2172
+ * if (cache.isDeletionCommitted(identifier)) {
2173
+ * // ...
2174
+ * }
2175
+ * ```
2176
+ *
1922
2177
  * @category Resource State
1923
2178
  * @public
1924
2179
  */
@@ -2136,20 +2391,189 @@ function getDefaultValue(schema, identifier, store) {
2136
2391
  if (transform?.defaultValue) return transform.defaultValue(options || null, identifier);
2137
2392
  }
2138
2393
  }
2139
- function calculateChangedKeys(cached, updates, fields) {
2140
- const changedKeys = /* @__PURE__ */ new Set();
2141
- const keys = Object.keys(updates);
2142
- const length = keys.length;
2143
- const localAttrs = cached.localAttrs;
2144
- const original = Object.assign(Object.create(null), cached.remoteAttrs, cached.inflightAttrs);
2145
- for (let i = 0; i < length; i++) {
2146
- const key = keys[i];
2147
- if (!fields.has(key)) continue;
2148
- const value = updates[key];
2149
- if (localAttrs && localAttrs[key] !== void 0) continue;
2150
- if (original[key] !== value) changedKeys.add(key);
2394
+ /**
2395
+ * The first layer, in the given resolution order, that holds `key`. Presence is `in`, so a value
2396
+ * explicitly set to `undefined` counts as present.
2397
+ */
2398
+ function layerHolding(key, layers, order) {
2399
+ for (let i = 0; i < order.length; i++) {
2400
+ const layer = layers[order[i]];
2401
+ if (layer && key in layer) return layer;
2151
2402
  }
2152
- return changedKeys;
2403
+ return null;
2404
+ }
2405
+ /**
2406
+ * What the projection with the given resolution order reads for `attr`. A path resolves its first
2407
+ * segment through the layers and follows the rest into the value, stopping with `undefined` at the
2408
+ * first missing link.
2409
+ */
2410
+ function resolveAttr(attr, layers, order) {
2411
+ const key = typeof attr === "string" ? attr : attr[0];
2412
+ const layer = layerHolding(key, layers, order);
2413
+ if (!layer) return void 0;
2414
+ return typeof attr === "string" ? layer[key] : valueAtPath(layer[key], attr);
2415
+ }
2416
+ /** Follow `path` (from its second segment) into `base`, stopping with `undefined` at the first missing link or `null`. */
2417
+ function valueAtPath(base, path) {
2418
+ let current = base;
2419
+ for (let i = 1; i < path.length; i++) {
2420
+ if (current === void 0 || current === null) return void 0;
2421
+ current = current[path[i]];
2422
+ }
2423
+ return current;
2424
+ }
2425
+ /**
2426
+ * Whether two values of `field` count as the same value for change detection.
2427
+ *
2428
+ * A `schema-object`, and each element of a `schema-array`, compares by the identity hash its
2429
+ * `ObjectSchema` declares (`identity: { kind: '@hash', ... }`), so the schema decides what "same"
2430
+ * means for it. With no hash declared, and for every other field kind, only the same reference
2431
+ * counts. That knowingly over-notifies for equal-content objects: content equality is the schema's
2432
+ * to define, not the cache's to guess.
2433
+ */
2434
+ function attrValuesEqual(schema, field, a, b) {
2435
+ if (a === b) return true;
2436
+ if (field.kind === "schema-object") return schemaObjectsEqual(schema, field, a, b);
2437
+ if (field.kind === "schema-array") return schemaArraysEqual(schema, field, a, b);
2438
+ return false;
2439
+ }
2440
+ /**
2441
+ * A schema-array has no hash of its own: two arrays are the same when they have the same length
2442
+ * and every element compares equal as a schema-object of the element type.
2443
+ */
2444
+ function schemaArraysEqual(schema, field, a, b) {
2445
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
2446
+ for (let i = 0; i < a.length; i++) if (!schemaObjectsEqual(schema, field, a[i], b[i])) return false;
2447
+ return true;
2448
+ }
2449
+ function schemaObjectsEqual(schema, field, a, b) {
2450
+ if (a === b) return true;
2451
+ if (!a || !b || typeof a !== "object" || typeof b !== "object") return false;
2452
+ const ia = fieldValueIdentity(schema, field, a);
2453
+ const ib = fieldValueIdentity(schema, field, b);
2454
+ return ia !== null && ib !== null && ia.type === ib.type && ia.hash !== null && ia.hash === ib.hash;
2455
+ }
2456
+ /**
2457
+ * Whether a nested edit's local clone is back to the baseline, so the edit can end. The schema
2458
+ * decides first, through {@link attrValuesEqual}: a schema-object with an identity hash matches
2459
+ * when its hash does. A value with no schema-defined equality falls back to comparing serialized
2460
+ * content, as this path always has: the clone began as a copy of the baseline, so key order
2461
+ * agrees while edits only replace existing keys. Anything that cannot serialize counts as still
2462
+ * edited, so no edit the cache cannot understand is thrown away.
2463
+ */
2464
+ function localCloneMatchesBaseline(schema, field, baseline, clone) {
2465
+ if (field && attrValuesEqual(schema, field, baseline, clone)) return true;
2466
+ if (!baseline || !clone) return false;
2467
+ try {
2468
+ return JSON.stringify(baseline) === JSON.stringify(clone);
2469
+ } catch {
2470
+ return false;
2471
+ }
2472
+ }
2473
+ /**
2474
+ * Which projection each changed key moved in, so the caller can notify on the matching channel:
2475
+ * `localOnly` and `remoteOnly` on theirs, `both` unscoped.
2476
+ *
2477
+ * `undefined` rather than empty Sets: this runs on every `upsert`, and a resource where nothing
2478
+ * moved should cost no allocation.
2479
+ */
2480
+ const NO_PROJECTION_CHANGES = Object.freeze({
2481
+ localOnly: void 0,
2482
+ remoteOnly: void 0,
2483
+ both: void 0
2484
+ });
2485
+ /** The layers a merge reads: the resource's own, plus the attributes arriving from the server. */
2486
+ function layersForMerge(cached, incomingAttrs) {
2487
+ return {
2488
+ localAttrs: cached.localAttrs,
2489
+ inflightAttrs: cached.inflightAttrs,
2490
+ remoteAttrs: cached.remoteAttrs,
2491
+ defaultAttrs: cached.defaultAttrs,
2492
+ incomingAttrs
2493
+ };
2494
+ }
2495
+ /**
2496
+ * Every key a merge could move: every key in every layer the after-merge order folds into
2497
+ * `remoteAttrs`. `null` when there is nothing to examine.
2498
+ */
2499
+ function candidateKeys(layers, remoteAfterOrder) {
2500
+ let keys = null;
2501
+ for (let i = 0; i < remoteAfterOrder.length; i++) {
2502
+ const layer = remoteAfterOrder[i];
2503
+ if (layer === "remoteAttrs") break;
2504
+ const hash = layers[layer];
2505
+ if (!hash) continue;
2506
+ const layerKeys = Object.keys(hash);
2507
+ if (!layerKeys.length) continue;
2508
+ if (keys === null) {
2509
+ keys = layerKeys;
2510
+ continue;
2511
+ }
2512
+ for (let j = 0; j < layerKeys.length; j++) if (!keys.includes(layerKeys[j])) keys.push(layerKeys[j]);
2513
+ }
2514
+ return keys;
2515
+ }
2516
+ /**
2517
+ * Fold every layer above `remoteAttrs` in the after-merge order for `kind` into `remoteAttrs`,
2518
+ * lowest precedence first, and clear each resource layer that was folded in. The same order
2519
+ * {@link partitionChangedKeys} used to predict the result, so the two cannot disagree.
2520
+ *
2521
+ * Reads the resource layers off `cached` rather than taking a {@link Layered}, so an upsert that
2522
+ * is not calculating changes allocates nothing beyond the merge itself.
2523
+ */
2524
+ function mergeIntoRemote(cached, incomingAttrs, kind) {
2525
+ const folded = MERGE_RESOLUTION[kind].folded;
2526
+ const target = cached.remoteAttrs || Object.create(null);
2527
+ for (let i = 0; i < folded.length; i++) {
2528
+ const layer = folded[i];
2529
+ const hash = layer === "incomingAttrs" ? incomingAttrs : cached[layer];
2530
+ if (!hash) continue;
2531
+ Object.assign(target, hash);
2532
+ if (layer !== "incomingAttrs") cached[layer] = null;
2533
+ if (cached.defaultAttrs) dropMemoizedDefaults(cached.defaultAttrs, hash);
2534
+ }
2535
+ cached.remoteAttrs = target;
2536
+ }
2537
+ function dropMemoizedDefaults(defaultAttrs, replacedBy) {
2538
+ const keys = Object.keys(replacedBy);
2539
+ for (let i = 0; i < keys.length; i++) if (keys[i] in defaultAttrs) delete defaultAttrs[keys[i]];
2540
+ }
2541
+ /**
2542
+ * Partition the keys a merge touches by which projection each one moves in. Must run *before*
2543
+ * {@link mergeIntoRemote}: it reads the pre-merge layers from `layers` and predicts the merge with
2544
+ * the same `RESOLUTION_ORDER_*_AFTER_*` orders `mergeIntoRemote` applies.
2545
+ */
2546
+ function partitionChangedKeys(schema, layers, fields, kind) {
2547
+ const { remoteAfter: remoteAfterOrder, localAfter: localAfterOrder } = MERGE_RESOLUTION[kind];
2548
+ const keys = candidateKeys(layers, remoteAfterOrder);
2549
+ if (keys === null) return NO_PROJECTION_CHANGES;
2550
+ let localOnly;
2551
+ let remoteOnly;
2552
+ let both;
2553
+ for (let i = 0; i < keys.length; i++) {
2554
+ const key = keys[i];
2555
+ const field = fields.get(key);
2556
+ if (!field || isRelationship(field)) continue;
2557
+ const remoteBefore = resolveAttr(key, layers, RESOLUTION_ORDER_REMOTE_STATE);
2558
+ const remoteAfter = resolveAttr(key, layers, remoteAfterOrder);
2559
+ const localBefore = resolveAttr(key, layers, RESOLUTION_ORDER_LOCAL_STATE);
2560
+ const localAfter = resolveAttr(key, layers, localAfterOrder);
2561
+ const remoteMoved = !attrValuesEqual(schema, field, remoteBefore, remoteAfter);
2562
+ const localMoved = !attrValuesEqual(schema, field, localBefore, localAfter);
2563
+ if (remoteMoved && localMoved) (both ??= /* @__PURE__ */ new Set()).add(key);
2564
+ else if (remoteMoved) (remoteOnly ??= /* @__PURE__ */ new Set()).add(key);
2565
+ else if (localMoved) (localOnly ??= /* @__PURE__ */ new Set()).add(key);
2566
+ }
2567
+ return both || remoteOnly || localOnly ? {
2568
+ localOnly,
2569
+ remoteOnly,
2570
+ both
2571
+ } : NO_PROJECTION_CHANGES;
2572
+ }
2573
+ function notifyProjectionChanges(cache, identifier, { both, remoteOnly, localOnly }) {
2574
+ if (both?.size) cache._capabilities.notifyChange(identifier, "attributes", both);
2575
+ if (remoteOnly?.size) cache._capabilities.notifyChange(identifier, "attributes", remoteOnly, "remote");
2576
+ if (localOnly?.size) cache._capabilities.notifyChange(identifier, "attributes", localOnly, "local");
2153
2577
  }
2154
2578
  function cacheIsEmpty(cached) {
2155
2579
  return !cached || cached.remoteAttrs === null && cached.inflightAttrs === null && cached.localAttrs === null;
@@ -2190,25 +2614,26 @@ function isRelationship(field) {
2190
2614
  const { kind } = field;
2191
2615
  return kind === "hasMany" || kind === "belongsTo" || kind === "resource" || kind === "collection";
2192
2616
  }
2193
- function patchLocalAttributes(cached, changedRemoteKeys) {
2194
- const { localAttrs, remoteAttrs, inflightAttrs, defaultAttrs, changes } = cached;
2195
- if (!localAttrs) {
2196
- cached.changes = null;
2197
- return false;
2198
- }
2199
- let hasAppliedPatch = false;
2200
- const mutatedKeys = Object.keys(localAttrs);
2201
- for (let i = 0, length = mutatedKeys.length; i < length; i++) {
2202
- const attr = mutatedKeys[i];
2203
- if ((inflightAttrs && attr in inflightAttrs ? inflightAttrs[attr] : remoteAttrs && attr in remoteAttrs ? remoteAttrs[attr] : void 0) === localAttrs[attr]) {
2204
- hasAppliedPatch = true;
2205
- changedRemoteKeys?.delete(attr);
2206
- delete localAttrs[attr];
2207
- delete changes[attr];
2617
+ /**
2618
+ * After a merge: drop every local edit the new baseline agrees with, by the same equality
2619
+ * {@link partitionChangedKeys} uses. Returns whether any edit was dropped.
2620
+ */
2621
+ function reconcileLocalEdits(schema, cached, fields) {
2622
+ const { localAttrs, defaultAttrs } = cached;
2623
+ if (!localAttrs) return false;
2624
+ let droppedAnEdit = false;
2625
+ const editedKeys = Object.keys(localAttrs);
2626
+ for (let i = 0; i < editedKeys.length; i++) {
2627
+ const key = editedKeys[i];
2628
+ const field = fields.get(key);
2629
+ const baseline = resolveAttr(key, cached, RESOLUTION_ORDER_EDIT_BASELINE);
2630
+ if (field ? attrValuesEqual(schema, field, baseline, localAttrs[key]) : baseline === localAttrs[key]) {
2631
+ droppedAnEdit = true;
2632
+ delete localAttrs[key];
2208
2633
  }
2209
- if (defaultAttrs && attr in defaultAttrs) delete defaultAttrs[attr];
2634
+ if (defaultAttrs && key in defaultAttrs) delete defaultAttrs[key];
2210
2635
  }
2211
- return hasAppliedPatch;
2636
+ return droppedAnEdit;
2212
2637
  }
2213
2638
  function putOne(cache, identifiers, resource) {
2214
2639
  macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) && ((test) => {
@@ -2300,7 +2725,7 @@ function copyLinksAndMeta(target, source) {
2300
2725
  if ("meta" in source) target.meta = source.meta;
2301
2726
  }
2302
2727
  function cacheUpsert(cache, identifier, data, calculateChanges) {
2303
- let changedKeys;
2728
+ let changes = NO_PROJECTION_CHANGES;
2304
2729
  const peeked = cache.__safePeek(identifier, false);
2305
2730
  const existed = !!peeked;
2306
2731
  const cached = peeked || cache._createCache(identifier);
@@ -2323,19 +2748,29 @@ function cacheUpsert(cache, identifier, data, calculateChanges) {
2323
2748
  cache._capabilities.notifyChange(identifier, "state", null);
2324
2749
  }
2325
2750
  const fields = getCacheFields(cache, identifier);
2326
- if (calculateChanges && existed && data.attributes) changedKeys = calculateChangedKeys(cached, data.attributes, fields);
2327
- cached.remoteAttrs = Object.assign(cached.remoteAttrs || Object.create(null), data.attributes);
2751
+ if (calculateChanges && existed && data.attributes) changes = partitionChangedKeys(cache._capabilities.schema, layersForMerge(cached, data.attributes), fields, "upsert");
2752
+ mergeIntoRemote(cached, data.attributes ?? null, "upsert");
2328
2753
  if (cached.localAttrs) {
2329
- if (patchLocalAttributes(cached, changedKeys)) cache._capabilities.notifyChange(identifier, "state", null);
2754
+ if (reconcileLocalEdits(cache._capabilities.schema, cached, fields)) cache._capabilities.notifyChange(identifier, "state", null);
2330
2755
  }
2331
2756
  if (!isUpdate) cache._capabilities.notifyChange(identifier, "added", null);
2332
2757
  if (data.id) cached.id = data.id;
2333
2758
  if (data.relationships) setupRelationships(cache.__graph, fields, identifier, data);
2334
- if (changedKeys?.size) cache._capabilities.notifyChange(identifier, "attributes", changedKeys);
2759
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) && ((test) => {
2760
+ if (!test) throw new Error("An upsert merges nothing into remote state, so no key can move in the local projection alone");
2761
+ })(!changes.localOnly?.size);
2762
+ notifyProjectionChanges(cache, identifier, changes);
2335
2763
  if (macroCondition(getGlobalConfig().WarpDriveMirror.activeLogging.LOG_CACHE)) {
2336
2764
  if (getGlobalConfig().WarpDriveMirror.debug.LOG_CACHE || globalThis.getWarpDriveRuntimeConfig().debug.LOG_CACHE) console.groupEnd();
2337
2765
  }
2338
- return changedKeys?.size ? Array.from(changedKeys) : void 0;
2766
+ return remoteChangedKeys(changes);
2767
+ }
2768
+ /** Every key whose remote value moved, as the `upsert` return value. */
2769
+ function remoteChangedKeys({ both, remoteOnly }) {
2770
+ if (!both?.size) return remoteOnly?.size ? Array.from(remoteOnly) : void 0;
2771
+ const keys = Array.from(both);
2772
+ if (remoteOnly?.size) keys.push(...remoteOnly);
2773
+ return keys;
2339
2774
  }
2340
2775
  function patchCache(Cache, op) {
2341
2776
  const isRecord = isResourceKey(op.record);
@@ -2438,10 +2873,11 @@ function commitDidError(cache, identifier, errors) {
2438
2873
  const keys = Object.keys(cached.inflightAttrs);
2439
2874
  if (keys.length > 0) {
2440
2875
  const attrs = cached.localAttrs = cached.localAttrs || Object.create(null);
2441
- for (let i = 0; i < keys.length; i++) if (attrs[keys[i]] === void 0) attrs[keys[i]] = cached.inflightAttrs[keys[i]];
2876
+ for (let i = 0; i < keys.length; i++) if (!(keys[i] in attrs)) attrs[keys[i]] = cached.inflightAttrs[keys[i]];
2442
2877
  }
2443
2878
  cached.inflightAttrs = null;
2444
2879
  }
2880
+ if (reconcileLocalEdits(cache._capabilities.schema, cached, getCacheFields(cache, identifier))) cache._capabilities.notifyChange(identifier, "state", null);
2445
2881
  if (errors) cached.errors = errors;
2446
2882
  cache._capabilities.notifyChange(identifier, "errors", null);
2447
2883
  }
@@ -2473,7 +2909,7 @@ function didCommit(cache, committedIdentifier, data, op) {
2473
2909
  }
2474
2910
  const fields = getCacheFields(cache, identifier);
2475
2911
  cached.isNew = false;
2476
- let newCanonicalAttributes;
2912
+ let responseAttrs = null;
2477
2913
  if (data) {
2478
2914
  if (data.id && !cached.id) cached.id = data.id;
2479
2915
  if (identifier === committedIdentifier && identifier.id !== existingId) cache._capabilities.notifyChange(identifier, "identity", null);
@@ -2501,17 +2937,16 @@ function didCommit(cache, committedIdentifier, data, op) {
2501
2937
  }
2502
2938
  setupRelationships(cache.__graph, fields, identifier, data);
2503
2939
  }
2504
- newCanonicalAttributes = data.attributes;
2940
+ responseAttrs = data.attributes ?? null;
2505
2941
  }
2506
- const changedKeys = newCanonicalAttributes && calculateChangedKeys(cached, newCanonicalAttributes, fields);
2507
- cached.remoteAttrs = Object.assign(cached.remoteAttrs || Object.create(null), cached.inflightAttrs, newCanonicalAttributes);
2508
- cached.inflightAttrs = null;
2509
- patchLocalAttributes(cached, changedKeys);
2942
+ const changes = partitionChangedKeys(cache._capabilities.schema, layersForMerge(cached, responseAttrs), fields, "commit");
2943
+ mergeIntoRemote(cached, responseAttrs, "commit");
2944
+ reconcileLocalEdits(cache._capabilities.schema, cached, fields);
2510
2945
  if (cached.errors) {
2511
2946
  cached.errors = null;
2512
2947
  cache._capabilities.notifyChange(identifier, "errors", null);
2513
2948
  }
2514
- if (changedKeys?.size) cache._capabilities.notifyChange(identifier, "attributes", changedKeys);
2949
+ notifyProjectionChanges(cache, identifier, changes);
2515
2950
  cache._capabilities.notifyChange(identifier, "state", null);
2516
2951
  }
2517
2952
  function willCommit(cache, identifier) {