@warp-drive-mirror/json-api 5.8.0-alpha.5 → 5.8.0-alpha.7

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/README.md CHANGED
@@ -26,7 +26,7 @@
26
26
  <br>
27
27
 
28
28
  <p align="center">
29
- A {JSON:API} Cache Implementation for <em>Warp</em><strong>Drive</strong>.
29
+ A {json:api} Cache Implementation for <em>Warp</em><strong>Drive</strong>.
30
30
  </p>
31
31
 
32
32
  <br>
@@ -41,7 +41,7 @@ pnpm add -E @warp-drive-mirror/json-api
41
41
 
42
42
  ## Documentation
43
43
 
44
- *Get Started* → [Guides](https://docs.warp-drive.io)
44
+ *Get Started* → [Guides](https://warp-drive.io)
45
45
 
46
46
 
47
47
  <br>
@@ -11,82 +11,12 @@ import type { CollectionResourceDataDocument, ResourceDocument, ResourceErrorDoc
11
11
  import type { ApiError } from "@warp-drive-mirror/core/types/spec/error";
12
12
  import type { CollectionResourceDocument, ExistingResourceObject, ResourceObject, SingleResourceDocument } from "@warp-drive-mirror/core/types/spec/json-api-raw";
13
13
  /**
14
- * ### JSONAPICache
15
- *
16
14
  * ```ts
17
15
  * import { JSONAPICache } from '@warp-drive-mirror/json-api';
18
16
  * ```
19
17
  *
20
- A {@link Cache} implementation tuned for [{json:api}](https://jsonapi.org/)
21
-
22
- This format excels at simiplifying common complex problems around cache
23
- consistency and information density. Because most API responses can be quickly
24
- transformed into {json:api} format without losing any information, WarpDrive
25
- recommends that most apps use this Cache implementation.
26
-
27
- If a cache built to understand another format would do better for your app then
28
- it just needs to follow the same interface.
29
-
30
- Do you really need a cache? Are sunsets beautiful? Caching is what powers features like
31
- immutability, mutation management, and allows ***Warp*Drive** to understand your relational
32
- data.
33
-
34
- Some caches are simple request/response maps. ***Warp*Drive**'s is not. The Cache deeply
35
- understands the structure of your data, ensuring your data remains consistent both within
36
- and across requests.
37
-
38
- ### Installation
39
-
40
- ::: code-group
41
-
42
- ```sh [pnpm]
43
- pnpm add -E @warp-drive-mirror/core@latest @warp-drive-mirror/json-api@latest
44
- ```
45
-
46
- ```sh [npm]
47
- npm add -E @warp-drive-mirror/core@latest @warp-drive-mirror/json-api@latest
48
- ```
49
-
50
- ```sh [yarn]
51
- yarn add -E @warp-drive-mirror/core@latest @warp-drive-mirror/json-api@latest
52
- ```
53
-
54
- ```sh [bun]
55
- bun add --exact @warp-drive-mirror/core@latest @warp-drive-mirror/json-api@latest
56
- ```
57
-
58
- :::
59
-
60
- ### Setup
61
-
62
- ```ts [services/store.ts]
63
- import { Fetch, RequestManager, Store } from '@warp-drive-mirror/core';
64
- import {
65
- registerDerivations,
66
- SchemaService,
67
- } from '@warp-drive-mirror/core/reactive';
68
- import { CacheHandler } from '@warp-drive-mirror/core/store';
69
- import type { CacheCapabilitiesManager } from '@warp-drive-mirror/core/types'; // [!code focus:2]
70
- import { JSONAPICache } from '@warp-drive-mirror/json-api';
71
-
72
- export default class AppStore extends Store {
73
-
74
- requestManager = new RequestManager()
75
- .use([Fetch])
76
- .useCache(CacheHandler);
77
-
78
- createSchemaService() {
79
- const schema = new SchemaService();
80
- registerDerivations(schema);
81
- return schema;
82
- }
83
-
84
- createCache(capabilities: CacheCapabilitiesManager) { // [!code focus:3]
85
- return new JSONAPICache(capabilities);
86
- }
87
- }
88
- ```
89
-
18
+ * A {@link Cache} implementation tuned for [{json:api}](https://jsonapi.org/)
19
+ *
90
20
  * @categoryDescription Cache Management
91
21
  * APIs for primary cache management functionality
92
22
  * @categoryDescription Cache Forking
@@ -99,8 +29,8 @@ return new JSONAPICache(capabilities);
99
29
  * APIs that support granular field level management of resource data
100
30
  * @categoryDescription Resource State
101
31
  * APIs that support managing Resource states
102
-
103
- @public
32
+ *
33
+ * @public
104
34
  */
105
35
  export declare class JSONAPICache implements Cache {
106
36
  /**
@@ -325,7 +255,7 @@ export declare class JSONAPICache implements Cache {
325
255
  * @category Resource Lifecycle
326
256
  * @public
327
257
  */
328
- willCommit(identifier: ResourceKey, _context: RequestContext | null): void;
258
+ willCommit(identifier: ResourceKey | ResourceKey[], _context: RequestContext | null): void;
329
259
  /**
330
260
  * [LIFECYCLE] Signals to the cache that a resource
331
261
  * was successfully updated as part of a save transaction.
@@ -333,7 +263,9 @@ export declare class JSONAPICache implements Cache {
333
263
  * @category Resource Lifecycle
334
264
  * @public
335
265
  */
336
- didCommit(committedIdentifier: ResourceKey, result: StructuredDataDocument<SingleResourceDocument> | null): SingleResourceDataDocument;
266
+ didCommit(committedIdentifier: ResourceKey, result: StructuredDataDocument<SingleResourceDataDocument> | null): SingleResourceDataDocument;
267
+ didCommit(committedIdentifier: ResourceKey[], result: StructuredDataDocument<SingleResourceDataDocument> | null): SingleResourceDataDocument;
268
+ didCommit(committedIdentifier: ResourceKey[], result: StructuredDataDocument<CollectionResourceDataDocument> | null): CollectionResourceDataDocument;
337
269
  /**
338
270
  * [LIFECYCLE] Signals to the cache that a resource
339
271
  * was update via a save transaction failed.
@@ -341,7 +273,7 @@ export declare class JSONAPICache implements Cache {
341
273
  * @category Resource Lifecycle
342
274
  * @public
343
275
  */
344
- commitWasRejected(identifier: ResourceKey, errors?: ApiError[]): void;
276
+ commitWasRejected(identifier: ResourceKey | ResourceKey[], errors?: ApiError[]): void;
345
277
  /**
346
278
  * [LIFECYCLE] Signals to the cache that all data for a resource
347
279
  * should be cleared.
@@ -1 +1,5 @@
1
+ /**
2
+ * @module
3
+ * @mergeModuleWith <project>
4
+ */
1
5
  export { JSONAPICache } from "./-private/cache.js";
package/dist/index.js CHANGED
@@ -361,7 +361,7 @@ class Reporter {
361
361
  }
362
362
  }
363
363
  }
364
- const contextStr = `${counts.error} errors and ${counts.warning} warnings found in the {JSON:API} document returned by ${this.contextDocument.request?.method} ${this.contextDocument.request?.url}`;
364
+ const contextStr = `${counts.error} errors and ${counts.warning} warnings found in the {json:api} document returned by ${this.contextDocument.request?.method} ${this.contextDocument.request?.url}`;
365
365
  const errorString = contextStr + `\n\n` + errorLines.join('\n');
366
366
 
367
367
  // eslint-disable-next-line no-console, @typescript-eslint/no-unused-expressions
@@ -449,10 +449,10 @@ function validateTopLevelDocumentMembers(reporter, doc) {
449
449
  const extension = reporter.getExtension(extensionName);
450
450
  extension(reporter, [key]);
451
451
  } else {
452
- reporter.warn([key], `Unrecognized extension ${extensionName}. The data provided by "${key}" will be ignored as it is not a valid {JSON:API} member`);
452
+ reporter.warn([key], `Unrecognized extension ${extensionName}. The data provided by "${key}" will be ignored as it is not a valid {json:api} member`);
453
453
  }
454
454
  } else {
455
- reporter.error([key], `Unrecognized top-level member. The data it provides is ignored as it is not a valid {JSON:API} member`);
455
+ reporter.error([key], `Unrecognized top-level member. The data it provides is ignored as it is not a valid {json:api} member`);
456
456
  }
457
457
  }
458
458
  }
@@ -462,24 +462,24 @@ function validateTopLevelDocumentMembers(reporter, doc) {
462
462
 
463
463
  // 1. MUST have either `data`, `errors`, or `meta`
464
464
  if (!('data' in doc) && !('errors' in doc) && !('meta' in doc)) {
465
- reporter.error([], 'A {JSON:API} Document must contain one-of `data` `errors` or `meta`');
465
+ reporter.error([], 'A {json:api} Document must contain one-of `data` `errors` or `meta`');
466
466
  }
467
467
 
468
468
  // 2. MUST NOT have both `data` and `errors`
469
469
  if ('data' in doc && 'errors' in doc) {
470
- reporter.error(['data'], 'A {JSON:API} Document MUST NOT contain both `data` and `errors` members');
470
+ reporter.error(['data'], 'A {json:api} Document MUST NOT contain both `data` and `errors` members');
471
471
  }
472
472
 
473
473
  // 3. MUST NOT have both `included` and `errors`
474
474
  // while not explicitly stated in the spec, this is a logical extension of the above rule
475
475
  // since `included` is only valid when `data` is present.
476
476
  if ('included' in doc && 'errors' in doc) {
477
- reporter.error(['included'], 'A {JSON:API} Document MUST NOT contain both `included` and `errors` members');
477
+ reporter.error(['included'], 'A {json:api} Document MUST NOT contain both `included` and `errors` members');
478
478
  }
479
479
 
480
480
  // 4. MUST NOT have `included` if `data` is not present
481
481
  if ('included' in doc && !('data' in doc)) {
482
- reporter.error(['included'], 'A {JSON:API} Document MUST NOT contain `included` if `data` is not present');
482
+ reporter.error(['included'], 'A {json:api} Document MUST NOT contain `included` if `data` is not present');
483
483
  }
484
484
 
485
485
  // 5. MUST NOT have `included` if `data` is null
@@ -489,7 +489,7 @@ function validateTopLevelDocumentMembers(reporter, doc) {
489
489
  if ('included' in doc && doc.data === null) {
490
490
  const isMaybeDelete = reporter.contextDocument.request?.method?.toUpperCase() === 'DELETE' || reporter.contextDocument.request?.op === 'deleteRecord';
491
491
  const method = !reporter.strict.linkage && isMaybeDelete ? 'warn' : 'error';
492
- reporter[method](['included'], 'A {JSON:API} Document MUST NOT contain `included` if `data` is null');
492
+ reporter[method](['included'], 'A {json:api} Document MUST NOT contain `included` if `data` is null');
493
493
  }
494
494
 
495
495
  // Simple Validation of Top-Level Members
@@ -576,7 +576,7 @@ function validateLinks(reporter, doc, type, path = ['links']) {
576
576
  const keys = Object.keys(links);
577
577
  for (const key of keys) {
578
578
  if (!VALID_TOP_LEVEL_LINKS.includes(key)) {
579
- reporter.warn([...path, key], `Unrecognized top-level link. The data it provides may be ignored as it is not a valid {JSON:API} link for a ${type}`);
579
+ reporter.warn([...path, key], `Unrecognized top-level link. The data it provides may be ignored as it is not a valid {json:api} link for a ${type}`);
580
580
  }
581
581
  // links may be either a string or an object with an href property or null
582
582
  if (links[key] === null) ; else if (typeof links[key] === 'string') {
@@ -738,7 +738,7 @@ function validateTopLevelResourceShape(reporter, resource, path) {
738
738
  const extension = reporter.getExtension(extensionName);
739
739
  extension(reporter, [...path, key]);
740
740
  } else {
741
- reporter.warn([...path, key], `Unrecognized extension ${extensionName}. The data provided by "${key}" will be ignored as it is not a valid {JSON:API} ResourceObject member`);
741
+ reporter.warn([...path, key], `Unrecognized extension ${extensionName}. The data provided by "${key}" will be ignored as it is not a valid {json:api} ResourceObject member`);
742
742
  }
743
743
  } else {
744
744
  // check if this is an attribute or relationship
@@ -767,7 +767,7 @@ function validateTopLevelResourceShape(reporter, resource, path) {
767
767
  }
768
768
  }
769
769
  }
770
- reporter.error([...path, key], `Unrecognized ResourceObject member. The data it provides is ignored as it is not a valid {JSON:API} ResourceObject member.${didYouMean}`);
770
+ reporter.error([...path, key], `Unrecognized ResourceObject member. The data it provides is ignored as it is not a valid {json:api} ResourceObject member.${didYouMean}`);
771
771
  }
772
772
  }
773
773
  }
@@ -801,7 +801,7 @@ function validateResourceAttributes(reporter, type, resource, path) {
801
801
  const extension = reporter.getExtension(extensionName);
802
802
  extension(reporter, [...path, key]);
803
803
  } else {
804
- reporter.warn([...path, key], `Unrecognized extension ${extensionName}. The data provided by "${key}" will be ignored as it is not a valid {JSON:API} ResourceObject member`);
804
+ reporter.warn([...path, key], `Unrecognized extension ${extensionName}. The data provided by "${key}" will be ignored as it is not a valid {json:api} ResourceObject member`);
805
805
  }
806
806
  } else {
807
807
  const method = reporter.strict.unknownAttribute ? 'error' : 'warn';
@@ -835,7 +835,7 @@ function validateResourceRelationships(reporter, type, resource, path) {
835
835
  const extension = reporter.getExtension(extensionName);
836
836
  extension(reporter, [...path, key]);
837
837
  } else {
838
- reporter.warn([...path, key], `Unrecognized extension ${extensionName}. The data provided by "${key}" will be ignored as it is not a valid {JSON:API} ResourceObject member`);
838
+ reporter.warn([...path, key], `Unrecognized extension ${extensionName}. The data provided by "${key}" will be ignored as it is not a valid {json:api} ResourceObject member`);
839
839
  }
840
840
  } else {
841
841
  const method = reporter.strict.unknownRelationship ? 'error' : 'warn';
@@ -955,82 +955,12 @@ function makeCache() {
955
955
  }
956
956
 
957
957
  /**
958
- * ### JSONAPICache
959
- *
960
958
  * ```ts
961
959
  * import { JSONAPICache } from '@warp-drive-mirror/json-api';
962
960
  * ```
963
961
  *
964
- A {@link Cache} implementation tuned for [{json:api}](https://jsonapi.org/)
965
-
966
- This format excels at simiplifying common complex problems around cache
967
- consistency and information density. Because most API responses can be quickly
968
- transformed into {json:api} format without losing any information, WarpDrive
969
- recommends that most apps use this Cache implementation.
970
-
971
- If a cache built to understand another format would do better for your app then
972
- it just needs to follow the same interface.
973
-
974
- Do you really need a cache? Are sunsets beautiful? Caching is what powers features like
975
- immutability, mutation management, and allows ***Warp*Drive** to understand your relational
976
- data.
977
-
978
- Some caches are simple request/response maps. ***Warp*Drive**'s is not. The Cache deeply
979
- understands the structure of your data, ensuring your data remains consistent both within
980
- and across requests.
981
-
982
- ### Installation
983
-
984
- ::: code-group
985
-
986
- ```sh [pnpm]
987
- pnpm add -E @warp-drive-mirror/core@latest @warp-drive-mirror/json-api@latest
988
- ```
989
-
990
- ```sh [npm]
991
- npm add -E @warp-drive-mirror/core@latest @warp-drive-mirror/json-api@latest
992
- ```
993
-
994
- ```sh [yarn]
995
- yarn add -E @warp-drive-mirror/core@latest @warp-drive-mirror/json-api@latest
996
- ```
997
-
998
- ```sh [bun]
999
- bun add --exact @warp-drive-mirror/core@latest @warp-drive-mirror/json-api@latest
1000
- ```
1001
-
1002
- :::
1003
-
1004
- ### Setup
1005
-
1006
- ```ts [services/store.ts]
1007
- import { Fetch, RequestManager, Store } from '@warp-drive-mirror/core';
1008
- import {
1009
- registerDerivations,
1010
- SchemaService,
1011
- } from '@warp-drive-mirror/core/reactive';
1012
- import { CacheHandler } from '@warp-drive-mirror/core/store';
1013
- import type { CacheCapabilitiesManager } from '@warp-drive-mirror/core/types'; // [!code focus:2]
1014
- import { JSONAPICache } from '@warp-drive-mirror/json-api';
1015
-
1016
- export default class AppStore extends Store {
1017
-
1018
- requestManager = new RequestManager()
1019
- .use([Fetch])
1020
- .useCache(CacheHandler);
1021
-
1022
- createSchemaService() {
1023
- const schema = new SchemaService();
1024
- registerDerivations(schema);
1025
- return schema;
1026
- }
1027
-
1028
- createCache(capabilities: CacheCapabilitiesManager) { // [!code focus:3]
1029
- return new JSONAPICache(capabilities);
1030
- }
1031
- }
1032
- ```
1033
-
962
+ * A {@link Cache} implementation tuned for [{json:api}](https://jsonapi.org/)
963
+ *
1034
964
  * @categoryDescription Cache Management
1035
965
  * APIs for primary cache management functionality
1036
966
  * @categoryDescription Cache Forking
@@ -1043,10 +973,9 @@ function makeCache() {
1043
973
  * APIs that support granular field level management of resource data
1044
974
  * @categoryDescription Resource State
1045
975
  * APIs that support managing Resource states
1046
-
1047
- @public
976
+ *
977
+ * @public
1048
978
  */
1049
-
1050
979
  class JSONAPICache {
1051
980
  /**
1052
981
  * The Cache Version that this implementation implements.
@@ -1689,50 +1618,12 @@ class JSONAPICache {
1689
1618
  * @public
1690
1619
  */
1691
1620
  willCommit(identifier, _context) {
1692
- const cached = this.__peek(identifier, false);
1693
-
1694
- /*
1695
- if we have multiple saves in flight at once then
1696
- we have information loss no matter what. This
1697
- attempts to lose the least information.
1698
- If we were to clear inflightAttrs, previous requests
1699
- would not be able to use it during their didCommit.
1700
- If we upsert inflightattrs, previous requests incorrectly
1701
- see more recent inflight changes as part of their own and
1702
- will incorrectly mark the new state as the correct remote state.
1703
- We choose this latter behavior to avoid accidentally removing
1704
- earlier changes.
1705
- If apps do not want this behavior they can either
1706
- - chain save requests serially vs allowing concurrent saves
1707
- - move to using a request handler that caches the inflight state
1708
- on a per-request basis
1709
- - change their save requests to only send a "PATCH" instead of a "PUT"
1710
- so that only latest changes are involved in each request, and then also
1711
- ensure that the API or their handler reflects only those changes back
1712
- for upsert into the cache.
1713
- */
1714
- if (cached.inflightAttrs) {
1715
- if (cached.localAttrs) {
1716
- Object.assign(cached.inflightAttrs, cached.localAttrs);
1621
+ if (Array.isArray(identifier)) {
1622
+ for (const key of identifier) {
1623
+ willCommit(this, key);
1717
1624
  }
1718
1625
  } else {
1719
- cached.inflightAttrs = cached.localAttrs;
1720
- }
1721
- cached.localAttrs = null;
1722
- if (macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG)) {
1723
- if (macroCondition(!getGlobalConfig().WarpDriveMirror.deprecations.DEPRECATE_RELATIONSHIP_REMOTE_UPDATE_CLEARING_LOCAL_STATE)) {
1724
- // save off info about saved relationships
1725
- const fields = getCacheFields(this, identifier);
1726
- fields.forEach((schema, name) => {
1727
- if (schema.kind === 'belongsTo') {
1728
- if (this.__graph._isDirty(identifier, name)) {
1729
- const relationshipData = this.__graph.getData(identifier, name);
1730
- const inFlight = cached.inflightRelationships = cached.inflightRelationships || Object.create(null);
1731
- inFlight[name] = relationshipData;
1732
- }
1733
- }
1734
- });
1735
- }
1626
+ willCommit(this, identifier);
1736
1627
  }
1737
1628
  }
1738
1629
 
@@ -1743,9 +1634,10 @@ class JSONAPICache {
1743
1634
  * @category Resource Lifecycle
1744
1635
  * @public
1745
1636
  */
1637
+
1746
1638
  didCommit(committedIdentifier, result) {
1747
1639
  const payload = result ? result.content : null;
1748
- const operation = result ? result.request.op : null;
1640
+ const operation = result?.request?.op ?? null;
1749
1641
  const data = payload && payload.data;
1750
1642
  if (macroCondition(getGlobalConfig().WarpDriveMirror.activeLogging.LOG_CACHE)) {
1751
1643
  if (getGlobalConfig().WarpDriveMirror.debug.LOG_CACHE || globalThis.getWarpDriveRuntimeConfig().debug.LOG_CACHE) {
@@ -1759,101 +1651,45 @@ class JSONAPICache {
1759
1651
  }
1760
1652
  }
1761
1653
  }
1762
- if (!data) {
1654
+ const responseIsCollection = Array.isArray(data);
1655
+ const hasMultipleIdentifiers = Array.isArray(committedIdentifier) && committedIdentifier.length > 1;
1656
+ if (Array.isArray(committedIdentifier)) {
1657
+ // if we get back an array of primary data, we treat each
1658
+ // entry as a separate commit for each identifier
1763
1659
  macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
1764
1660
  if (!test) {
1765
- throw new Error(`Your ${committedIdentifier.type} record was saved to the server, but the response does not have an id and no id has been set client side. Records must have ids. Please update the server response to provide an id in the response or generate the id on the client side either before saving the record or while normalizing the response.`);
1661
+ throw new Error(`Expected the array of primary data to match the array of committed identifiers`);
1766
1662
  }
1767
- })(committedIdentifier.id) : {};
1768
- }
1769
- const {
1770
- cacheKeyManager
1771
- } = this._capabilities;
1772
- const existingId = committedIdentifier.id;
1773
- const identifier = operation !== 'deleteRecord' && data ? cacheKeyManager.updateRecordIdentifier(committedIdentifier, data) : committedIdentifier;
1774
- const cached = this.__peek(identifier, false);
1775
- if (cached.isDeleted) {
1776
- this.__graph.push({
1777
- op: 'deleteRecord',
1778
- record: identifier,
1779
- isNew: false
1780
- });
1781
- cached.isDeletionCommitted = true;
1782
- this._capabilities.notifyChange(identifier, 'removed', null);
1783
- // TODO @runspired should we early exit here?
1784
- }
1785
- if (macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG)) {
1786
- if (cached.isNew && !identifier.id && (typeof data?.id !== 'string' || data.id.length > 0)) {
1787
- const error = new Error(`Expected an id ${String(identifier)} in response ${JSON.stringify(data)}`);
1788
- //@ts-expect-error
1789
- error.isAdapterError = true;
1790
- //@ts-expect-error
1791
- error.code = 'InvalidError';
1792
- throw error;
1793
- }
1794
- }
1795
- const fields = getCacheFields(this, identifier);
1796
- cached.isNew = false;
1797
- let newCanonicalAttributes;
1798
- if (data) {
1799
- if (data.id && !cached.id) {
1800
- cached.id = data.id;
1801
- }
1802
- if (identifier === committedIdentifier && identifier.id !== existingId) {
1803
- this._capabilities.notifyChange(identifier, 'identity', null);
1804
- }
1805
- macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
1806
- if (!test) {
1807
- 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}'.`);
1663
+ })(!hasMultipleIdentifiers || !responseIsCollection || data.length === committedIdentifier.length) : {};
1664
+ if (responseIsCollection) {
1665
+ for (let i = 0; i < committedIdentifier.length; i++) {
1666
+ const identifier = committedIdentifier[i];
1667
+ didCommit(this, identifier, data[i] ?? null, operation);
1808
1668
  }
1809
- })(identifier.id === cached.id) : {};
1810
- if (data.relationships) {
1811
- if (macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG)) {
1812
- if (macroCondition(!getGlobalConfig().WarpDriveMirror.deprecations.DEPRECATE_RELATIONSHIP_REMOTE_UPDATE_CLEARING_LOCAL_STATE)) {
1813
- // assert against bad API behavior where a belongsTo relationship
1814
- // is saved but the return payload indicates a different final state.
1815
- fields.forEach((field, name) => {
1816
- if (field.kind === 'belongsTo') {
1817
- const relationshipData = data.relationships[name]?.data;
1818
- if (relationshipData !== undefined) {
1819
- const inFlightData = cached.inflightRelationships?.[name];
1820
- if (!inFlightData || !('data' in inFlightData)) {
1821
- return;
1822
- }
1823
- const actualData = relationshipData ? this._capabilities.cacheKeyManager.getOrCreateRecordIdentifier(relationshipData) : null;
1824
- macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
1825
- if (!test) {
1826
- throw new Error(`Expected the resource relationship '<${identifier.type}>.${name}' on ${identifier.lid} to be saved as ${inFlightData.data ? inFlightData.data.lid : '<null>'} but it was saved as ${actualData ? actualData.lid : '<null>'}`);
1827
- }
1828
- })(inFlightData.data === actualData) : {};
1829
- }
1830
- }
1831
- });
1832
- cached.inflightRelationships = null;
1833
- }
1669
+ // but if we get back no data or a single entry, we apply
1670
+ // the change back to the original identifier
1671
+ } else {
1672
+ for (let i = 0; i < committedIdentifier.length; i++) {
1673
+ const identifier = committedIdentifier[i];
1674
+ didCommit(this, identifier, i === 0 ? data : null, operation);
1834
1675
  }
1835
- setupRelationships(this.__graph, fields, identifier, data);
1836
1676
  }
1837
- newCanonicalAttributes = data.attributes;
1838
- }
1839
- const changedKeys = newCanonicalAttributes && calculateChangedKeys(cached, newCanonicalAttributes, fields);
1840
- cached.remoteAttrs = Object.assign(cached.remoteAttrs || Object.create(null), cached.inflightAttrs, newCanonicalAttributes);
1841
- cached.inflightAttrs = null;
1842
- patchLocalAttributes(cached, changedKeys);
1843
- if (cached.errors) {
1844
- cached.errors = null;
1845
- this._capabilities.notifyChange(identifier, 'errors', null);
1677
+ } else {
1678
+ didCommit(this, committedIdentifier, data, operation);
1846
1679
  }
1847
- if (changedKeys?.size) notifyAttributes(this._capabilities, identifier, changedKeys);
1848
- this._capabilities.notifyChange(identifier, 'state', null);
1849
1680
  const included = payload && payload.included;
1681
+ const {
1682
+ cacheKeyManager
1683
+ } = this._capabilities;
1850
1684
  if (included) {
1851
1685
  for (let i = 0, length = included.length; i < length; i++) {
1852
1686
  putOne(this, cacheKeyManager, included[i]);
1853
1687
  }
1854
1688
  }
1855
- return {
1856
- data: identifier
1689
+ return hasMultipleIdentifiers && responseIsCollection ? {
1690
+ data: committedIdentifier
1691
+ } : {
1692
+ data: Array.isArray(committedIdentifier) ? committedIdentifier[0] : committedIdentifier
1857
1693
  };
1858
1694
  }
1859
1695
 
@@ -1865,23 +1701,13 @@ class JSONAPICache {
1865
1701
  * @public
1866
1702
  */
1867
1703
  commitWasRejected(identifier, errors) {
1868
- const cached = this.__peek(identifier, false);
1869
- if (cached.inflightAttrs) {
1870
- const keys = Object.keys(cached.inflightAttrs);
1871
- if (keys.length > 0) {
1872
- const attrs = cached.localAttrs = cached.localAttrs || Object.create(null);
1873
- for (let i = 0; i < keys.length; i++) {
1874
- if (attrs[keys[i]] === undefined) {
1875
- attrs[keys[i]] = cached.inflightAttrs[keys[i]];
1876
- }
1877
- }
1704
+ if (Array.isArray(identifier)) {
1705
+ for (let i = 0; i < identifier.length; i++) {
1706
+ commitDidError(this, identifier[i], errors && i === 0 ? errors : null);
1878
1707
  }
1879
- cached.inflightAttrs = null;
1880
- }
1881
- if (errors) {
1882
- cached.errors = errors;
1708
+ return;
1883
1709
  }
1884
- this._capabilities.notifyChange(identifier, 'errors', null);
1710
+ return commitDidError(this, identifier, errors || null);
1885
1711
  }
1886
1712
 
1887
1713
  /**
@@ -3233,5 +3059,160 @@ function getCacheFields(cache, identifier) {
3233
3059
  // the model schema service cannot process fields that are not cache fields
3234
3060
  return cache._capabilities.schema.fields(identifier);
3235
3061
  }
3062
+ function commitDidError(cache, identifier, errors) {
3063
+ const cached = cache.__peek(identifier, false);
3064
+ if (cached.inflightAttrs) {
3065
+ const keys = Object.keys(cached.inflightAttrs);
3066
+ if (keys.length > 0) {
3067
+ const attrs = cached.localAttrs = cached.localAttrs || Object.create(null);
3068
+ for (let i = 0; i < keys.length; i++) {
3069
+ if (attrs[keys[i]] === undefined) {
3070
+ attrs[keys[i]] = cached.inflightAttrs[keys[i]];
3071
+ }
3072
+ }
3073
+ }
3074
+ cached.inflightAttrs = null;
3075
+ }
3076
+ if (errors) {
3077
+ cached.errors = errors;
3078
+ }
3079
+ cache._capabilities.notifyChange(identifier, 'errors', null);
3080
+ }
3081
+ function didCommit(cache, committedIdentifier, data, op) {
3082
+ if (!data) {
3083
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
3084
+ if (!test) {
3085
+ throw new Error(`Your ${committedIdentifier.type} record was saved to the server, but the response does not have an id and no id has been set client side. Records must have ids. Please update the server response to provide an id in the response or generate the id on the client side either before saving the record or while normalizing the response.`);
3086
+ }
3087
+ })(committedIdentifier.id) : {};
3088
+ }
3089
+ const {
3090
+ cacheKeyManager
3091
+ } = cache._capabilities;
3092
+ const existingId = committedIdentifier.id;
3093
+ const identifier = op !== 'deleteRecord' && data ? cacheKeyManager.updateRecordIdentifier(committedIdentifier, data) : committedIdentifier;
3094
+ const cached = cache.__peek(identifier, false);
3095
+ if (cached.isDeleted) {
3096
+ cache.__graph.push({
3097
+ op: 'deleteRecord',
3098
+ record: identifier,
3099
+ isNew: false
3100
+ });
3101
+ cached.isDeletionCommitted = true;
3102
+ cache._capabilities.notifyChange(identifier, 'removed', null);
3103
+ // TODO @runspired should we early exit here?
3104
+ }
3105
+ if (macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG)) {
3106
+ if (cached.isNew && !identifier.id && (typeof data?.id !== 'string' || data.id.length > 0)) {
3107
+ const error = new Error(`Expected an id ${String(identifier)} in response ${JSON.stringify(data)}`);
3108
+ //@ts-expect-error
3109
+ error.isAdapterError = true;
3110
+ //@ts-expect-error
3111
+ error.code = 'InvalidError';
3112
+ throw error;
3113
+ }
3114
+ }
3115
+ const fields = getCacheFields(cache, identifier);
3116
+ cached.isNew = false;
3117
+ let newCanonicalAttributes;
3118
+ if (data) {
3119
+ if (data.id && !cached.id) {
3120
+ cached.id = data.id;
3121
+ }
3122
+ if (identifier === committedIdentifier && identifier.id !== existingId) {
3123
+ cache._capabilities.notifyChange(identifier, 'identity', null);
3124
+ }
3125
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
3126
+ if (!test) {
3127
+ 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}'.`);
3128
+ }
3129
+ })(identifier.id === cached.id) : {};
3130
+ if (data.relationships) {
3131
+ if (macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG)) {
3132
+ if (macroCondition(!getGlobalConfig().WarpDriveMirror.deprecations.DEPRECATE_RELATIONSHIP_REMOTE_UPDATE_CLEARING_LOCAL_STATE)) {
3133
+ // assert against bad API behavior where a belongsTo relationship
3134
+ // is saved but the return payload indicates a different final state.
3135
+ fields.forEach((field, name) => {
3136
+ if (field.kind === 'belongsTo') {
3137
+ const relationshipData = data.relationships[name]?.data;
3138
+ if (relationshipData !== undefined) {
3139
+ const inFlightData = cached.inflightRelationships?.[name];
3140
+ if (!inFlightData || !('data' in inFlightData)) {
3141
+ return;
3142
+ }
3143
+ const actualData = relationshipData ? cache._capabilities.cacheKeyManager.getOrCreateRecordIdentifier(relationshipData) : null;
3144
+ macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
3145
+ if (!test) {
3146
+ throw new Error(`Expected the resource relationship '<${identifier.type}>.${name}' on ${identifier.lid} to be saved as ${inFlightData.data ? inFlightData.data.lid : '<null>'} but it was saved as ${actualData ? actualData.lid : '<null>'}`);
3147
+ }
3148
+ })(inFlightData.data === actualData) : {};
3149
+ }
3150
+ }
3151
+ });
3152
+ cached.inflightRelationships = null;
3153
+ }
3154
+ }
3155
+ setupRelationships(cache.__graph, fields, identifier, data);
3156
+ }
3157
+ newCanonicalAttributes = data.attributes;
3158
+ }
3159
+ const changedKeys = newCanonicalAttributes && calculateChangedKeys(cached, newCanonicalAttributes, fields);
3160
+ cached.remoteAttrs = Object.assign(cached.remoteAttrs || Object.create(null), cached.inflightAttrs, newCanonicalAttributes);
3161
+ cached.inflightAttrs = null;
3162
+ patchLocalAttributes(cached, changedKeys);
3163
+ if (cached.errors) {
3164
+ cached.errors = null;
3165
+ cache._capabilities.notifyChange(identifier, 'errors', null);
3166
+ }
3167
+ if (changedKeys?.size) notifyAttributes(cache._capabilities, identifier, changedKeys);
3168
+ cache._capabilities.notifyChange(identifier, 'state', null);
3169
+ }
3170
+ function willCommit(cache, identifier) {
3171
+ const cached = cache.__peek(identifier, false);
3172
+
3173
+ /*
3174
+ if we have multiple saves in flight at once then
3175
+ we have information loss no matter what. This
3176
+ attempts to lose the least information.
3177
+ If we were to clear inflightAttrs, previous requests
3178
+ would not be able to use it during their didCommit.
3179
+ If we upsert inflightattrs, previous requests incorrectly
3180
+ see more recent inflight changes as part of their own and
3181
+ will incorrectly mark the new state as the correct remote state.
3182
+ We choose this latter behavior to avoid accidentally removing
3183
+ earlier changes.
3184
+ If apps do not want this behavior they can either
3185
+ - chain save requests serially vs allowing concurrent saves
3186
+ - move to using a request handler that caches the inflight state
3187
+ on a per-request basis
3188
+ - change their save requests to only send a "PATCH" instead of a "PUT"
3189
+ so that only latest changes are involved in each request, and then also
3190
+ ensure that the API or their handler reflects only those changes back
3191
+ for upsert into the cache.
3192
+ */
3193
+ if (cached.inflightAttrs) {
3194
+ if (cached.localAttrs) {
3195
+ Object.assign(cached.inflightAttrs, cached.localAttrs);
3196
+ }
3197
+ } else {
3198
+ cached.inflightAttrs = cached.localAttrs;
3199
+ }
3200
+ cached.localAttrs = null;
3201
+ if (macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG)) {
3202
+ if (macroCondition(!getGlobalConfig().WarpDriveMirror.deprecations.DEPRECATE_RELATIONSHIP_REMOTE_UPDATE_CLEARING_LOCAL_STATE)) {
3203
+ // save off info about saved relationships
3204
+ const fields = getCacheFields(cache, identifier);
3205
+ fields.forEach((schema, name) => {
3206
+ if (schema.kind === 'belongsTo') {
3207
+ if (cache.__graph._isDirty(identifier, name)) {
3208
+ const relationshipData = cache.__graph.getData(identifier, name);
3209
+ const inFlight = cached.inflightRelationships = cached.inflightRelationships || Object.create(null);
3210
+ inFlight[name] = relationshipData;
3211
+ }
3212
+ }
3213
+ });
3214
+ }
3215
+ }
3216
+ }
3236
3217
 
3237
3218
  export { JSONAPICache };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@warp-drive-mirror/json-api",
3
- "version": "5.8.0-alpha.5",
4
- "description": "A {JSON:API} Cache Implementation for WarpDrive",
3
+ "version": "5.8.0-alpha.7",
4
+ "description": "A {json:api} Cache Implementation for WarpDrive",
5
5
  "keywords": [
6
6
  "ember-addon"
7
7
  ],
@@ -32,7 +32,7 @@
32
32
  }
33
33
  },
34
34
  "peerDependencies": {
35
- "@warp-drive-mirror/core": "5.8.0-alpha.5"
35
+ "@warp-drive-mirror/core": "5.8.0-alpha.7"
36
36
  },
37
37
  "dependencies": {
38
38
  "@embroider/macros": "^1.18.1",
@@ -44,8 +44,8 @@
44
44
  "@babel/plugin-transform-typescript": "^7.28.0",
45
45
  "@babel/preset-typescript": "^7.27.1",
46
46
  "@types/json-to-ast": "^2.1.4",
47
- "@warp-drive/internal-config": "5.8.0-alpha.5",
48
- "@warp-drive-mirror/core": "5.8.0-alpha.5",
47
+ "@warp-drive/internal-config": "5.8.0-alpha.7",
48
+ "@warp-drive-mirror/core": "5.8.0-alpha.7",
49
49
  "decorator-transforms": "^2.3.0",
50
50
  "expect-type": "^1.2.2",
51
51
  "typescript": "^5.9.2",