diff --git a/guides/index.md b/guides/index.md index a7683f0f262..b126c1a4af3 100644 --- a/guides/index.md +++ b/guides/index.md @@ -7,6 +7,7 @@ Read [The Manual](./manual/0-index.md) - [Relationships](./relationships/index.md) - [Requests](./requests/index.md) +- [Reactivity](./reactive-data/index.md) - [Typescript](./typescript/index.md) - [Terminology](./terminology.md) - [Cookbook](./cookbook/index.md) diff --git a/guides/reactive-data/index.md b/guides/reactive-data/index.md new file mode 100644 index 00000000000..28e297b6391 --- /dev/null +++ b/guides/reactive-data/index.md @@ -0,0 +1,25 @@ +# Reactive Data + +In addition to request and cache management, WarpDrive provides a reactive access +layer for data in the cache. + +Data in the cache is conceptualized as belonging to one of three forms + +- **Documents** - the response to a request +- **Resources** - a unique cacheable entity within the response to a request +- **Fields** - the data for an individual property on a resource + +Each form of data can be accessed and managed reactively through one of two modes + +- *(upcoming, default in v6)* [PolarisMode](./polaris/overview.md) +- *(current, default in v5)* [LegacyMode](./legacy/overview.md) + +These modes are interopable. The reactive object (record) for a resource in PolarisMode can relate to +a record in LegacyMode and vice-versa. This interopability is true whether the record in LegacyMode is +a SchemaRecord or a Model. + +These reactive primitives use fine-grained signals-based reactivity. Currently, we use +glimmer's (Ember's) implementation of `Signal` (`@tracked`) and `Computed` (`@cached`); +however, we've architected our use to be pluggable and will soon enable configuration +of any desired implementation, thus making WarpDrive compatible with any signals compatible +library or framework. diff --git a/guides/reactive-data/legacy/overview.md b/guides/reactive-data/legacy/overview.md new file mode 100644 index 00000000000..50dc0132eff --- /dev/null +++ b/guides/reactive-data/legacy/overview.md @@ -0,0 +1,15 @@ +# LegacyMode + +- ⮐ [ReactiveData](../index.md) + +--- + +🚧 This guide is under active construction, it will talk about the below points 🚧 + +In LegacyMode records are: + +- mutable +- local changes immediately reflect app wide +- all the APIs of Model (references, state props, currentState, methods etc) +- limited reactivity for fields (same as Model) +- requires continued use of the `model` and `legacy-compat` packages (though most imports from them can be removed) diff --git a/guides/reactive-data/polaris/overview.md b/guides/reactive-data/polaris/overview.md new file mode 100644 index 00000000000..3848f08edce --- /dev/null +++ b/guides/reactive-data/polaris/overview.md @@ -0,0 +1,17 @@ +# PolarisMode + +- ⮐ [ReactiveData](../index.md) + +--- + +🚧 This guide is under active construction, it will talk about the below points 🚧 + +In PolarisMode records are: + +- immutable (unless creating or checking out for editing) +- local changes only show where you want them to on the editable version +- none of the APIs of Model (references, state props, currentState, methods etc) +- deep reactivity for fields +- advanced derivations, aliasing and transformations +- [currently] limited support for relationships +- no promise proxies for relationships diff --git a/guides/relationships/features/links-mode.md b/guides/relationships/features/links-mode.md new file mode 100644 index 00000000000..3c360e8f37a --- /dev/null +++ b/guides/relationships/features/links-mode.md @@ -0,0 +1,231 @@ +# LinksMode + +- ⮐ [Relationships Guide](../index.md) + +--- + +**LinksMode** is a special feature that can be activated for any `belongsTo` or `hasMany` relationship. + +It allows that relationship to be fetched using the standard `request` experience instead of via the legacy `adapter` interface. + +LinksMode behaves *slightly* differently depending on whether +you are using Model (including via [LegacyMode](../../reactive-data/legacy/overview.md)) or [PolarisMode](../../reactive-data/polaris/overview.md). We'll explain this nuance below. + +> [!TIP] +> The next-generation of reactive data which replaces Model is SchemaRecord. +> SchemaRecord has two modes, Legacy - which emulates all of Model's +> behaviors and APIs, and Polaris - a new experience which we intend +> to make default in Version 6. + +## How Does It Work? + +### Related Link Becomes Required + +Relationships in WarpDrive are stored using the same top-level structure as resource documents, a structure +adopted from the [JSON:API](https://jsonapi.org) specification. + +- **data**: the membership (state of) the relationship +- **links** *(optional)*: an object containing various links for fetching and managing the relationship +- **meta** *(optional)*: an object of arbitrary extra information about the relationship + +This is roughly described by the interface below: + +```ts +interface Relationship { + meta?: Record; + links?: Links; +} + +interface ResourceRelationship extends Relationship { + data: { type: string; id: string | null; lid: string } | null; +} + +interface CollectionRelationship extends Relationship { + data: { type: string; id: string | null; lid: string }[] +} +``` + +When `linksMode` is activated for a relationship, it is required that a related link is present. + +```ts +interface LinksModeRelationship { + meta?: Record; + + // no longer optional + links: { + related: string | { href: string }; + + // other links as desired + } +} +``` + +### Related Links May Be Provided by Handlers + +This means that, in order to use links mode, a relationship payload given to the cache MUST contain this related link. + +If your API does not provide this link, a [request handler](https://api.emberjs.com/ember-data/release/classes/%3Cinterface%3E%20handler/) could be utilized to decorate an API response to add them provided that your handlers (or your API) are able to understand that link. + +Note that this approach can even work if your API requires you to send a POST request to fetch the relationship. [This blog post](https://runspired.com/2025/02/26/exploring-advanced-handlers.html) contains an overview of advanced request handling to achieve a similar aim for pagination. + +### When a Relationship Is Fetched, the Related Link Is Used + +Fetching a relationship via any of `relationship.reload`, `reference.reload`, `reference.load` or `await record.relationship` will issue a request to your handler chain. That request will look like the following: + +```ts +interface FetchRelationshipRequest { + op: 'findHasMany' | 'findBelongsTo'; + store: Store; + url: string; // the related link + method: 'GET'; + records: StableRecordIdentifier[]; // the current membership of the relationship + data: { + field: LegacyBelongsToField | LegacyHasManyField; + links: Links; + meta: Meta; + options: unknown; // any options passed to `reload` or `load` + record: StableRecordIdentifier; // the parent record + }; + + // tells the store to not automatically convert the response into something reactive + // since the reactive relationship class itself will do that + [EnableHydration]: false; +} +``` + +The three most important things in this request are: + +- the `op` code: this is how the cache will know to use the response to update the state of a relationship +- `data.field`: this is how the cache will know which field it should update +- `data.record`: this is how the cache will know which record to associate the response to. + +The normalized API response (what your handler must return either directly from your API or with some normalization on the client) that should be passed to the JSON:API cache should be a standard JSON:API document. + +The contents of `data` will be inserted into the resource cache and the list of records contained therein will be used to update the state of the relationship. The `meta` and `links` of the response will become the `meta` and `links` available for the +relationship as well. + +Sideloads (included records) are valid to include in these responses. + +## Activating LinksMode + +LinksMode is activated by adding `linksMode: true` to the relationship's options. + +Read on below for examples and nuances specific to Model vs SchemaRecord + +### For a Relationship on a Model + +```ts +import Model, { belongsTo, hasMany } from '@ember-data/model'; + +export default class User extends Model { + @belongsTo('address', { + async: false, + inverse: 'residents', + linksMode: true + }) + homeAddress; +} +``` + +This works for both `async` and `non-async` relationships and only changes the fetching behavior of the field it is defined on. For instance, in the example above, `homeAddress` is fetched in links mode while `
.residents` might still be using the legacy adapter experience. + +### For a SchemaRecord in LegacyMode + +```ts +import type { ResourceSchema } from '@warp-drive/core-types/schema/fields'; + +const UserSchema = { + type: 'user', + // this is what puts the record instance into LegacyMode + legacy: true, + fields: [ + { + kind: 'belongsTo', + name: 'homeAddress', + options: { + async: false, + inverse: 'residents', + linksMode: true + } + } + ] +} satisfies ResourceSchema; +``` + +The behavior of a relationship for a SchemaRecord in LegacyMode is always identical to that of a the same +relationship defined on a Model. + +### For a SchemaRecord in PolarisMode + +```ts +import type { ResourceSchema } from '@warp-drive/core-types/schema/fields'; + +const UserSchema = { + type: 'user', + fields: [ + { + kind: 'belongsTo', + name: 'homeAddress', + options: { + async: false, + inverse: 'residents', + linksMode: true + } + } + ] +} satisfies ResourceSchema; +``` + +The only difference here is that we don't mark the resource schemas as `legacy`. This puts us in the standard/default mode (`polaris`); + +When using PolarisMode, `hasMany` and `belongsTo` relationships have additional constraints: + +- 1. They MUST use linksMode. Nothing except linksMode is supported. +- 2. They MUST be `async: false`. Async relationships will never be supported in PolarisMode (read more on this below) +- 3. There is no `autofetch` behavior (because relationships are `async: false`) + +You can load this link to fetch the relationship, though it is less easy to because the utility methods and links are +not as readily exposed via references as they are with Model. + +For `belongsTo` this is a particularly large drawback. `belongsTo` has no mechanism by which to expose its links or a reload method. There are work arounds via the cache API / via derivations if needed, but cumbersome. + +For `hasMany`, this restriction is not too difficult as it can be loaded via its link by calling `reload`, e.g. `user.friends.reload()`. As with hasMany in LegacyMode, its links are also available via `user.friends.links`. + +This makes PolarisMode relationships intentionally limited. This limitation is not permanent – there is a replacement +in the works for `belongsTo` and `hasMany` that aligns relationships with the intended Polaris experience. + +In the meantime, we've enabled synchronous linksMode relationships in order to allow folks to experiment with the polaris experience while still staying generally aligned with the direction relationships will evolve. + +If this limitation is too great we would recommend continuing to use `LegacyMode` until the full story for +relationships in PolarisMode is shipped. + +#### What To Expect from PolarisMode Relationships in the Future + +We intend to replace `belongsTo` and `hasMany` fields with the (as yet not implemented) +`resource` and `collection` fields. + +These fields will have no `autofetch` behavior, and no async proxy. There will still be `sync` and `async` +variations of the field but this flag will take on a better meaning. + +An `async` relationship represents a POTENTIALLY asynchronous boundary in your API, meaning that even if +sometimes the data for that relationship is included as a sideload, it may not always be and may require +its own request. Async collection relationships can be paginated. + +A `sync` relationship represents an ALWAYS synchronous boundary, meaning that the full state of the relationship +is ALWAYS included as a sideload and cannot ever be loaded as its own request. Sync relationships can never be +paginated, and generally require use of a request which fetches their parent record to get updated state. + +In LegacyMode, sync relationships gave direct access to the record or array while async relationships gave access +to a promisified proxy to the record/array. + +In PolarisMode using `resource` and `collection`, sync relationships will also give direct access while async +relationships will instead provide access to a [reactive document](https://api.emberjs.com/ember-data/release/classes/Document). + +So for instance, if `user.homeAddress` were `async: false`, then its value would be an instance of an `Address` record. +But if `user.homeAddress` were `asunc: true`, it would instead be a reactive class with `links`, `meta` and (only-if-loaded) `data`. + +- `user.homeAddress.links` would provide access to its associated links +- `user.homeAddress.meta` would provide access to any associated meta +- `user.homeAddress.data` would provide access to the address record instance IF (and only if) the relationship data had been included as part of the response for a parent record previously OR fetched explicitly via its link. + + diff --git a/guides/relationships/features/polymorphism.md b/guides/relationships/features/polymorphism.md new file mode 100644 index 00000000000..527b27736b2 --- /dev/null +++ b/guides/relationships/features/polymorphism.md @@ -0,0 +1,264 @@ +# Relationship Polymorphism + +- ⮐ [Relationships Guide](../index.md) + +--- + +Polymorphic relationships are relationships where the value can be more than one +type of resource. + +For instance, say a human has pets, where a pet can be any furry friend: + +```ts +interface Human { + pets: FurryFriend[]; +} +``` + +That furry friend may be a cat, a zebra, a monkey or best a dog! Each furry friend comes with +their own unique characteristics and personalities, but they have one thing in common: YOU. + +```ts +interface FurryFriend { + owner: Human; +} + +interface Cat extends FurryFriend { + color: 'calico' | 'tabby' | 'black'; + personality: 'indoor' | 'outdoor'; + name: 'you only wish you knew'; +} + +interface Zebra extends FurryFriend { + speed: number; + weight: number; + name: string; +} + +interface Monkey extends FurryFriend { + throwPoop: boolean; + stealsHats: boolean; + sitsOnShoulder: boolean; + bananaCache: number; +} + +interface Dog extends FurryFriend { + isGood: true; + goesToHeaven: 'always'; +} +``` + +It isn't very useful to think of your pets as just furry friends, because you want to use the unique +characteristics of each. In reality, our relationship is a union: + +```ts +interface Human { + pets: Array; +} +``` + +We can look at each pet and immediately see their distinctly loveable traits. + +Of course, sometimes we're a bit too giving of our love and accept anything that shows up at our door, in +our car, above our kitchen cabinent, or in our attic as our pet. We can model that too: + +```ts +interface Human { + pets: unknown[]; +} +``` + +## How To Implement + +WarpDrive implements polymorphism structurally: as long as records on both sides of the relationship agree +to the same structural contract, it works. In other words, you do not need inheritance, mixins, decorators +or any other compositional primitive to achieve polymorphism (though sometimes these compositional patterns are useful in their own right). + +There are two polymorphic modes in WarpDrive: + +- **open** - any type of record can be a value (this is like our last example above of `pets: unknown[]`) +- **closed** - only types of records that conform to a specific contract can be a value + +### Open Polymorphism + +To make any relationship an open polymorphic relationship, its options should include both `inverse: null` and +`polymorphic: true`. The related type can be any meaningful string, and does not need to be a resource type +ever encountered. + +So for instance, to implement our pets relationship using open polymorphism using `Model`: + +```ts +import Model, { hasMany } from '@ember-data/model'; + +export default class Human extends Model { + @hasMany('abstract-pet', { async: false, inverse: null, polymorphic: true }) + declare pets: unknown[]; +} +``` + +That same relationship using a schema: + +```ts +store.schema.registerResource({ + type: 'human', + identity: { kind: '@id', name: 'id' }, + fields: [ + { + kind: 'hasMany', + name: 'pets', + type: 'abstract-pet', + options: { + async: false, + inverse: null, + polymorphic: true + } + } + ] +}) +``` + +### Closed/Structural Polymorphism + +To make any relationship a closed polymorphic relationship based on structural contract, its options should +include both an explicit non-null inverse and `polymorphic: true`. + +The related type can be any meaningful string, and does not need to be a resource type ever encountered. + +The inverse relationship on any record looking to adhere to the structural contract MUST be implemented +exactly the same each time. + +So for instance, to implement our pets relationship using closed polymorphism using `Model`: + + +```ts +import Model, { hasMany } from '@ember-data/model'; + +export default class Human extends Model { + @hasMany('abstract-pet', { async: false, inverse: 'owner', polymorphic: true }) + declare pets: Array +} +``` + +And on *every* model that can be a pet, this same relationship as shown below for cat: + +```ts +import Model, { belongsTo } from '@ember-data/model'; + +export default class Cat extends Model { + @belongsTo('human', { async: false, inverse: 'pets', as: 'abstract-pet' }) + declare owner: Human; +} +``` + +By "same" we mean the entirety of the below with zero changes: + +```ts + @belongsTo('human', { async: false, inverse: 'pets', as: 'abstract-pet' }) + declare owner: Human; +``` + +E.g. if the relationship is `async: false` it must always be `async: false`, if it is named `owner` it must +always be named `owner`, if it is a `belongsTo` is must always be a `belongsTo` and so-on. + +Enforcing this consistency is why often teams will choose to use a class decorator, inheritance or similar +as a compositional pattern to provide the relationship definition. But it is not the mechanism of composition +but the shape of the field that actually drives the behavior. + +For completeness: the above relationships using schemas: + +```ts +store.schema.registerResources([ + { + type: 'human', + identity: { kind: '@id', name: 'id' }, + fields: [ + { + kind: 'hasMany', + name: 'pets', + type: 'abstract-pet', + options: { + async: false, + inverse: 'owner', + polymorphic: true + } + } + ] + }, + { + type: 'cat', + identity: { kind: '@id', name: 'id' }, + fields: [ + { + kind: 'belongsTo', + name: 'owner', + type: 'human', + options: { + async: false, + inverse: 'pets', + as: 'abstract-pet' + } + } + ] + }, +]); +``` + +In the schema approach, the entirety of the below field definition is what must be the same on each resource +schema: + +```js + { + kind: 'belongsTo', + name: 'owner', + type: 'human', + options: { + async: false, + inverse: 'pets', + as: 'abstract-pet' + } +} +``` + +## Fetching Polymorphic Data + +When working with a polymorphic relationship, the resource data for each related resource +should use its concrete type, not the abstract type. + +For instance, `cat` in our example is a concrete type, while `abstract-pet` is the abstract type. + +The happy path for polymorphism is to always use the concrete type when possible in relationship and resource data. + +But if your app does not take the happy path, all is not lost! + +It is fine to request data via the abstract type provided the API response returns the concrete types. Most of the time WarpDrive will just do the right thing and understand what you did. + +Figuring out "the right thing" even extends to automatically detecting and upgrading the identity of a record +from the abstract type to the concrete type. + +For instance: say you said you had one pet in your pets relationship, specified as `{ type: 'abstract-pet', id: '1' }`. +For whatever reason, at the point you got this data the concrete type was unknown. Later, you make a request to get this data: + +```ts +await store.request(findRecord('abstract-pet', '1')); + +/* response json +=> { + data: { + type: 'dog', + id: '1', + attributes: { ... } + } +} +*/ +``` + +The response returns a resource with the type `'dog'` (still with id `'1'`). This is what is often referred to as +single-table polymorphism (single shared id index, multiple potential types). By default, WarpDrive will *usually* +recognize that `'abstract-pet'` was the abstract type and upgrade the type to `'dog'`, ensuring any relationships +that relate to `{ type: 'abstract-pet', id: '1' }` point at the dog resource. + +When WarpDrive doesn't get it right, or when your API uses multi-table polymorphism and exposes relationships via the abstract and not the concrete type, there are several escape valves to be aware of. + +- 1) Your most powerful ally is requests and request handlers. You can post-process responses and convert the concrete types back to abstract types in relationships and assign the `lid` of the abstract type to the resource so that the cache understands to associated the abstract identity to the concrete identity. +- 2) Alternatively (or in conjunction with option 1) you can implement the identity generation hook to teach the cache how to understand which identities are actually the same identity. This generally works best in scenarios where `id` is +globally unique (such as a uuid). diff --git a/guides/relationships/index.md b/guides/relationships/index.md index f7f75d3676f..602915b928b 100644 --- a/guides/relationships/index.md +++ b/guides/relationships/index.md @@ -2,6 +2,9 @@ ## Feature Overview - [Inverses](./features/inverses.md) +- [LinksMode](./features/links-mode.md) +- [Polymorphism](./features/polymorphism.md) +