From 5b4c0050d3ab3f104331f418111fb8b8e8b3d058 Mon Sep 17 00:00:00 2001 From: Chris Thoburn Date: Tue, 8 Nov 2022 23:07:27 -0800 Subject: [PATCH 1/3] stash work --- README.md | 61 ++++++++-- ember-data-logo-dark.svg | 12 ++ ember-data-logo-light.svg | 12 ++ packages/store/README.md | 115 +++++++++++++++--- .../store/addon/-private/store-service.ts | 110 ++++++----------- packages/store/ember-data-store-logo-dark.svg | 108 ++++++++++++++++ .../store/ember-data-store-logo-light.svg | 108 ++++++++++++++++ 7 files changed, 427 insertions(+), 99 deletions(-) create mode 100644 ember-data-logo-dark.svg create mode 100644 ember-data-logo-light.svg create mode 100644 packages/store/ember-data-store-logo-dark.svg create mode 100644 packages/store/ember-data-store-logo-light.svg diff --git a/README.md b/README.md index 6fe966e199a..069205d1937 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,67 @@ -EmberData -============================================================================== +

+ + +

+ + +

The lightweight reactive data library for JavaScript applications

[![Build Status](https://github.com/emberjs/data/workflows/CI/badge.svg)](https://github.com/emberjs/data/actions?workflow=CI) [![Code Climate](https://codeclimate.com/github/emberjs/data/badges/gpa.svg)](https://codeclimate.com/github/emberjs/data) [![Discord Community Server](https://img.shields.io/discord/480462759797063690.svg?logo=discord)](https://discord.gg/zT3asNS) +--- -# Overview +Wrangle your application's data management with scalable patterns for developer productivity. + +- ⚡️ Committed to Best-In-Class Performance +- 🌲 Focused on being as svelte as possible +- 🚀 SSR Ready +- 🔜 Typescript Support +- 🐹 Built with ♥️ by [Ember](https://emberjs.com) +- ⚛️ Supports any API: `GraphQL` `JSON:API` `REST` `tRPC` ...bespoke or a mix -`EmberData` is a lightweight reactive data library for JavaScript applications that provides composable primitives for ordering query/mutation/peek flows, managing network and cache, and reducing data for presentation. You can plug-and-play as desired for any api structure and format. -It was designed for robustly managing data in applications built with [Ember](https://github.com/emberjs/ember.js/) and is agnostic to the underlying persistence mechanism, so it works just as well with [JSON:API](https://jsonapi.org/) or [GraphQL](https://graphql.org/) over `HTTPS` as it does with streaming `WebSockets` or local `IndexedDB` storage. +# Overview -It provides many of the features you'd find in server-side `ORM`s like `ActiveRecord`, but is designed specifically for the unique environment of `JavaScript` in the browser. +*Ember***Data** is a lightweight reactive data library for JavaScript applications that provides composable primitives for ordering query/mutation/peek flows, managing network and cache, and reducing data for presentation. -- [Usage Guide](https://guides.emberjs.com/release/models/) - [API Documentation](https://api.emberjs.com/ember-data/release) +- [Community & Help](https://emberjs.com/community) - [Contributing Guide](./CONTRIBUTING.md) +- [Usage Guide](https://guides.emberjs.com/release/models/) - [RFCs](https://github.com/emberjs/rfcs/labels/T-ember-data) -- [Community](https://emberjs.com/community) - [Team](https://emberjs.com/team) - [Blog](https://emberjs.com/blog) + +## 🪜 Architecture + +*Ember***Data** is both *resource* centric and *document* centric in it's approach to caching, requesting and presenting data. Your application's configuration and usage drives which is important and when. + +The `Store` is a **coordinator**. When using a `Store` you configure what cache to use, how cache data should be presented to the UI, and where it should look for requested data when it is not available in the cache. + +This coordination is handled opaquely to the nature of the requests issued and the format of the data being handled. This approach gives applications broad flexibility to configure *Ember***Data** to best suite their needs. This makes *Ember***Data** a powerful solution for applications regardless of their size and complexity. + +*Ember***Data** is designed to scale, with a religious focus on performance and asset-size to keep its footprint small but speedy while still being able to handle large complex APIs in huge data-driven applications with no additional code and no added application complexity. It's goal is to prevent applications from writing code to manage data that is difficult to maintain or reason about. + +*Ember***Data**'s power comes not from specific features, data formats, or adherence to specific API specs such as `JSON:API` `trpc` or `GraphQL`, but from solid conventions around requesting and mutating data developed over decades of experience scaling developer productivity. + + + ## Basic Installation Install using your javascript package manager of choice. For instance with [pnpm](https://pnpm.io/) @@ -38,7 +77,7 @@ not wish to use `ember-data`, remove `ember-data` from your project's `package.j ## Advanced Installation -EmberData is organized into primitives that compose together via public APIs. +*Ember***Data** is organized into primitives that compose together via public APIs. - [@ember-data/store](./packages/store) is the core and handles coordination - [@ember-data/record-data](./packages/record-data) is a resource cache for JSON:API structured data. It integrates with the store via the hook `createRecordDataFor` @@ -56,7 +95,7 @@ public APIs, other libraries or applications may provide their own implementatio ### Deprecation Stripping -EmberData allows users to opt-in and remove code that exists to support deprecated behaviors. +*Ember***Data** allows users to opt-in and remove code that exists to support deprecated behaviors. If your app has resolved all deprecations present in a given version, you may specify that version as your "compatibility" version to remove the code that supported the deprecated behavior from your app. @@ -72,7 +111,7 @@ let app = new EmberApp(defaults, { ### randomUUID polyfill -EmberData uses `UUID V4` by default to generate identifiers for new data created on the client. Identifier generation is configurable, but we also for convenience will polyfill +*Ember***Data** uses `UUID V4` by default to generate identifiers for new data created on the client. Identifier generation is configurable, but we also for convenience will polyfill the necessary feature if your browser support or deployment environment demands it. To activate this polyfill: diff --git a/ember-data-logo-dark.svg b/ember-data-logo-dark.svg new file mode 100644 index 00000000000..737a4aa4321 --- /dev/null +++ b/ember-data-logo-dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ember-data-logo-light.svg b/ember-data-logo-light.svg new file mode 100644 index 00000000000..58ac3d4e544 --- /dev/null +++ b/ember-data-logo-light.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/store/README.md b/packages/store/README.md index 4d137ca8507..5adcaad464d 100644 --- a/packages/store/README.md +++ b/packages/store/README.md @@ -1,31 +1,114 @@ -@ember-data/store -============================================================================== +

+ + +

-[Short description of the addon.] +

⚡️ The lightweight reactive data library for JavaScript applications

+This package provides [*Ember***Data**](https://github.com/emberjs/data/)'s `Store` class. -Compatibility ------------------------------------------------------------------------------- +The `Store` coordinates interaction between your application, the `Cache`, and sources of data (such as your `API` or a local persistence layer). -* Ember.js v3.4 or above -* Ember CLI v2.13 or above +```mermaid +stateDiagram-v2 + [*] --> +``` +## Installation -Installation ------------------------------------------------------------------------------- +Install using your javascript package manager of choice. For instance with [pnpm](https://pnpm.io/) ``` -ember install @ember-data/store +pnpm add @ember-data/store ``` +After installing you will want to configure your first `Store`. Read more below for how to create and configure stores for your application. + + +## 🛠 Creating A Store + +To use a `Store` we will need to do few things: add a `Cache` to store data **in-memory**, add an `Adapter` to fetch data from a source, and implement `instantiateRecord` to tell the store how to display the data for individual resources. + +> **Note** If you are using the package `ember-data` then a `JSON:API` cache and `instantiateRecord` are configured for you by default. + +### Configuring A Cache + +To start, let's install a `JSON:API` cache. If your app uses `GraphQL` or `REST` other caches may better fit your data. You can author your own cache by creating one that conforms to the [spec](). + +The package `@ember-data/record-data` provides a `JSON:API` cache we can use. After installing it, we can configure the store to use this cache. + +```js +import Store from '@ember-data/store'; +import Cache from '@ember-data/record-data'; + +class extends Store { + #cache = null; + + createRecordDataFor(identifier, storeWrapper) { + this.#cache = this.#cache || new Cache(storeWrapper); + this.#cache.createCache(identifier); + return this.#cache; + } +} +``` -Usage ------------------------------------------------------------------------------- +Now that we have a `cache` let's setup something to handle fetching and saving data via our API. -[Longer description of how to use the addon in apps.] +### Adding An Adapter +To start, let's install a `JSON:API` adapter. If your app uses `GraphQL` or `REST` other adapters may better fit your data. You can author your own adapter by creating one that conforms to the [spec](). -License ------------------------------------------------------------------------------- +The package `@ember-data/adapter` provides a `JSON:API` adapter we can use. After installing it, we can configure the store to use this adapter. + +```js +import Store from '@ember-data/store'; +import Adapter from '@ember-data/adapter/json-api'; + +class extends Store { + #adapter = new Adapter(); + + adapterFor() { + return this.#adapter; + } +} +``` + +#### Using with Ember + +Note: If you are using Ember and would like to make use of `service` injections in your adapter, you will want to additionally `setOwner` for the Adapter. + +```js +import Store from '@ember-data/store'; +import Adapter from '@ember-data/adapter/json-api'; +import { getOwner, setOwner } from '@ember/application'; + +class extends Store { + #adapter = null; + + adapterFor() { + let adapter = thsi.#adapter; + if (!adapter) { + const owner = getOwner(this); + adapter = new Adapter(); + setOwner(adapter, owner); + this.#adapter = adapter; + } + + return adapter; + } +} +``` -This project is licensed under the [MIT License](LICENSE.md). +By default when using with Ember you only need to implement this hook if you want your adapter usage to be statically analyzeable. *Ember***Data** will attempt to resolve adapters using Ember's resolver. diff --git a/packages/store/addon/-private/store-service.ts b/packages/store/addon/-private/store-service.ts index 9e6af5b7cca..94a8c09251f 100644 --- a/packages/store/addon/-private/store-service.ts +++ b/packages/store/addon/-private/store-service.ts @@ -160,7 +160,6 @@ export interface CreateRecordProperties { will automatically be synced to include the new or updated record values. - @main @ember-data/store @class Store @public @extends Ember.Service @@ -740,12 +739,10 @@ class Store extends Service { **Example 1** - ```app/routes/post.js - import Route from '@ember/routing/route'; - - export default class PostRoute extends Route { - model({ post_id }) { - return this.store.findRecord('post', post_id); + ```js + class { + getPostData(store, id) { + return store.findRecord('post', id); } } ``` @@ -756,12 +753,10 @@ class Store extends Service { of `type` (modelName) and `id` as separate arguments. You may recognize this combo as the typical pairing from [JSON:API](https://jsonapi.org/format/#document-resource-object-identification) - ```app/routes/post.js - import Route from '@ember/routing/route'; - - export default class PostRoute extends Route { - model({ post_id: id }) { - return this.store.findRecord({ type: 'post', id }); + ```js + class { + getPostData(store, id) { + return store.findRecord({ type: 'post', id }); } } ``` @@ -771,30 +766,30 @@ class Store extends Service { If you have previously received an lid via an Identifier for this record, and the record has already been assigned an id, you can find the record again using just the lid. - ```app/routes/post.js + ```js store.findRecord({ lid }); ``` - If the record is not yet available, the store will ask the adapter's `findRecord` - method to retrieve and supply the necessary data. If the record is already present - in the store, it depends on the reload behavior _when_ the returned promise - resolves. + If the record is not yet available – or options for `reload` or `backgroundReload` are provided – + the store will issue a `findRecord` query against the configured [fetch-manager]() to retrieve + and supply the necessary data. ### Preloading You can optionally `preload` specific attributes and relationships that you know of - by passing them via the passed `options`. + by passing them via the passed `options`. When preloading relationships, you may pass + either the id/ids of the related records or an existing record instance. Preload + information is ignored if the record already exists in the store. For example, if your Ember route looks like `/posts/1/comments/2` and your API route - for the comment also looks like `/posts/1/comments/2` if you want to fetch the comment - without also fetching the post you can pass in the post to the `findRecord` call: + for the comment also looks like `/posts/1/comments/2` if you want to post to be available + on the snapshot provided to the query to fetch the comment you could pass in the post + to the `findRecord` call: - ```app/routes/post-comments.js - import Route from '@ember/routing/route'; - - export default class PostRoute extends Route { - model({ post_id, comment_id: id }) { - return this.store.findRecord({ type: 'comment', id, { preload: { post: post_id }} }); + ```js + class { + getComments(store, post, id) { + return store.findRecord({ type: 'comment', id }, { preload: { post } }); } } ``` @@ -802,60 +797,31 @@ class Store extends Service { In your adapter you can then access this id without triggering a network request via the snapshot: - ```app/adapters/application.js - import EmberObject from '@ember/object'; - - export default class Adapter extends EmberObject { - - findRecord(store, schema, id, snapshot) { - let type = schema.modelName; - - if (type === 'comment') - let postId = snapshot.belongsTo('post', { id: true }); - - return fetch(`./posts/${postId}/comments/${id}`) - .then(response => response.json()) - } - } - } + ```js + const postId = snapshot.belongsTo('post', { id: true }); + const data = await fetch(`./posts/${postId}/comments/${id}`).then(r => r.json()); ``` - This could also be achieved by supplying the post id to the adapter via the adapterOptions - property on the options hash. + Generally speaking, preloading is rarely a good solution as it can have unintended + consequences on the state of your application should the network take a while or error + during the fetch. - ```app/routes/post-comments.js - import Route from '@ember/routing/route'; + If the use-case is to provide additional information to the request this can be done via + options without using the `preload` feature. - export default class PostRoute extends Route { - model({ post_id, comment_id: id }) { - return this.store.findRecord({ type: 'comment', id, { adapterOptions: { post: post_id }} }); - } - } - ``` - - ```app/adapters/application.js - import EmberObject from '@ember/object'; - - export default class Adapter extends EmberObject { - - findRecord(store, schema, id, snapshot) { - let type = schema.modelName; - - if (type === 'comment') - let postId = snapshot.adapterOptions.post; - - return fetch(`./posts/${postId}/comments/${id}`) - .then(response => response.json()) - } + ```js + class { + getComments(store, post, id) { + return store.findRecord({ type: 'comment', id }, { adapterOptions: { post } }); } } ``` - If you have access to the post model you can also pass the model itself to preload: + Similarly to access this from the snapshot - ```javascript - let post = await store.findRecord('post', 1); - let comment = await store.findRecord('comment', 2, { post: myPostModel }); + ```js + const postId = snapshot.adapterOptions.post.id; + const data = await fetch(`./posts/${postId}/comments/${id}`).then(r => r.json()); ``` ### Reloading diff --git a/packages/store/ember-data-store-logo-dark.svg b/packages/store/ember-data-store-logo-dark.svg new file mode 100644 index 00000000000..b70a6b6a34e --- /dev/null +++ b/packages/store/ember-data-store-logo-dark.svg @@ -0,0 +1,108 @@ + + + + +{S +t +o +r +e} + + + + + + + + + + + diff --git a/packages/store/ember-data-store-logo-light.svg b/packages/store/ember-data-store-logo-light.svg new file mode 100644 index 00000000000..d70a2267e21 --- /dev/null +++ b/packages/store/ember-data-store-logo-light.svg @@ -0,0 +1,108 @@ + + + + +{S +t +o +r +e} + + + + + + + + + + + From a097959df0918c2d2d99049707f21b3022bea391 Mon Sep 17 00:00:00 2001 From: Chris Thoburn Date: Tue, 8 Nov 2022 23:27:14 -0800 Subject: [PATCH 2/3] update flow chart --- packages/store/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/store/README.md b/packages/store/README.md index 5adcaad464d..cd0394b7af2 100644 --- a/packages/store/README.md +++ b/packages/store/README.md @@ -22,8 +22,10 @@ This package provides [*Ember***Data**](https://github.com/emberjs/data/)'s `Sto The `Store` coordinates interaction between your application, the `Cache`, and sources of data (such as your `API` or a local persistence layer). ```mermaid -stateDiagram-v2 - [*] --> +graph LR + A[App] <--> B{Store} + B <--> C(Source) + B <--> D(Cache) ``` ## Installation From 8ab47216d608961e74d9527b9bf93fea05a14d95 Mon Sep 17 00:00:00 2001 From: Chris Thoburn Date: Wed, 9 Nov 2022 11:18:30 -0800 Subject: [PATCH 3/3] more updates to align with v2.1 cache --- ember-data-types/q/record-data.ts | 1 + packages/model/addon/-private/model.js | 6 +- packages/model/addon/-private/record-state.ts | 4 +- .../addon/-private/references/belongs-to.ts | 10 +-- .../addon/-private/references/has-many.ts | 10 +-- .../record-data/addon/-private/record-data.ts | 18 ++++++ packages/store/README.md | 62 ++++++++++++++++--- .../addon/-private/caches/instance-cache.ts | 8 ++- .../legacy-model-support/record-reference.ts | 2 +- .../-private/managers/record-data-manager.ts | 26 ++++++++ .../managers/record-data-store-wrapper.ts | 14 ++--- .../store/addon/-private/store-service.ts | 21 +++++-- tests/docs/fixtures/expected.js | 2 +- .../custom-class-model-test.ts | 18 +++--- 14 files changed, 153 insertions(+), 49 deletions(-) diff --git a/ember-data-types/q/record-data.ts b/ember-data-types/q/record-data.ts index d9882d52ee4..aff7e66c641 100644 --- a/ember-data-types/q/record-data.ts +++ b/ember-data-types/q/record-data.ts @@ -85,6 +85,7 @@ export interface RecordData { // Attrs // ===== + peek(identifier: StableRecordIdentifier): Record; getAttr(identifier: StableRecordIdentifier, propertyName: string): unknown; setAttr(identifier: StableRecordIdentifier, propertyName: string, value: unknown): void; changedAttrs(identifier: StableRecordIdentifier): ChangedAttributesHash; diff --git a/packages/model/addon/-private/model.js b/packages/model/addon/-private/model.js index 885d5add07d..3db8f81313b 100644 --- a/packages/model/addon/-private/model.js +++ b/packages/model/addon/-private/model.js @@ -146,7 +146,7 @@ class Model extends EmberObject { this.setProperties(createProps); - let notifications = store._notificationManager; + let notifications = store.notifications; this.___private_notifications = notifications.subscribe(identity, (identifier, type, key) => { notifyChanges(identifier, type, key, this, store); }); @@ -156,7 +156,7 @@ class Model extends EmberObject { const identifier = recordIdentifierFor(this); this.___recordState?.destroy(); const store = storeFor(this); - store._notificationManager.unsubscribe(this.___private_notifications); + store.notifications.unsubscribe(this.___private_notifications); // Legacy behavior is to notify the relationships on destroy // such that they "clear". It's uncertain this behavior would // be good for a new model paradigm, likely cheaper and safer @@ -501,7 +501,7 @@ class Model extends EmberObject { if (normalizedId !== null && didChange) { this.store._instanceCache.setRecordId(identifier, normalizedId); - this.store._notificationManager.notify(identifier, 'identity'); + this.store.notifications.notify(identifier, 'identity'); } } diff --git a/packages/model/addon/-private/record-state.ts b/packages/model/addon/-private/record-state.ts index aea5389add3..03e6ab18a56 100644 --- a/packages/model/addon/-private/record-state.ts +++ b/packages/model/addon/-private/record-state.ts @@ -170,7 +170,7 @@ export default class RecordState { this._lastError = null; let requests = store.getRequestStateService(); - let notifications = store._notificationManager; + let notifications = store.notifications; const handleRequest = (req) => { if (req.type === 'mutation') { @@ -256,7 +256,7 @@ export default class RecordState { } destroy() { - storeFor(this.record)!._notificationManager.unsubscribe(this.handler); + storeFor(this.record)!.notifications.unsubscribe(this.handler); } notify(key) { diff --git a/packages/model/addon/-private/references/belongs-to.ts b/packages/model/addon/-private/references/belongs-to.ts index edfea8e50ca..6b0943f6ec3 100644 --- a/packages/model/addon/-private/references/belongs-to.ts +++ b/packages/model/addon/-private/references/belongs-to.ts @@ -79,7 +79,7 @@ export default class BelongsToReference { this.store = store; this.___identifier = parentIdentifier; - this.___token = store._notificationManager.subscribe( + this.___token = store.notifications.subscribe( parentIdentifier, (_: StableRecordIdentifier, bucket: NotificationType, notifiedKey?: string) => { if (bucket === 'relationships' && notifiedKey === key) { @@ -94,10 +94,10 @@ export default class BelongsToReference { destroy() { // TODO @feature we need the notification manager often enough // we should potentially just expose it fully public - this.store._notificationManager.unsubscribe(this.___token); + this.store.notifications.unsubscribe(this.___token); this.___token = null as unknown as object; if (this.___relatedToken) { - this.store._notificationManager.unsubscribe(this.___relatedToken); + this.store.notifications.unsubscribe(this.___relatedToken); this.___relatedToken = null; } } @@ -114,14 +114,14 @@ export default class BelongsToReference { get identifier(): StableRecordIdentifier | null { this._ref; // consume the tracked prop if (this.___relatedToken) { - this.store._notificationManager.unsubscribe(this.___relatedToken); + this.store.notifications.unsubscribe(this.___relatedToken); this.___relatedToken = null; } let resource = this._resource(); if (resource && resource.data) { const identifier = this.store.identifierCache.getOrCreateRecordIdentifier(resource.data); - this.___relatedToken = this.store._notificationManager.subscribe( + this.___relatedToken = this.store.notifications.subscribe( identifier, (_: StableRecordIdentifier, bucket: NotificationType, notifiedKey?: string) => { if (bucket === 'identity' || (bucket === 'attributes' && notifiedKey === 'id')) { diff --git a/packages/model/addon/-private/references/has-many.ts b/packages/model/addon/-private/references/has-many.ts index d3d8b8836a4..71b3423e795 100644 --- a/packages/model/addon/-private/references/has-many.ts +++ b/packages/model/addon/-private/references/has-many.ts @@ -82,7 +82,7 @@ export default class HasManyReference { this.store = store; this.___identifier = parentIdentifier; - this.___token = store._notificationManager.subscribe( + this.___token = store.notifications.subscribe( parentIdentifier, (_: StableRecordIdentifier, bucket: NotificationType, notifiedKey?: string) => { if (bucket === 'relationships' && notifiedKey === key) { @@ -95,9 +95,9 @@ export default class HasManyReference { } destroy() { - this.store._notificationManager.unsubscribe(this.___token); + this.store.notifications.unsubscribe(this.___token); this.___relatedTokenMap.forEach((token) => { - this.store._notificationManager.unsubscribe(token); + this.store.notifications.unsubscribe(token); }); this.___relatedTokenMap.clear(); } @@ -126,7 +126,7 @@ export default class HasManyReference { if (token) { map.delete(identifier); } else { - token = this.store._notificationManager.subscribe( + token = this.store.notifications.subscribe( identifier, (_: StableRecordIdentifier, bucket: NotificationType, notifiedKey?: string) => { if (bucket === 'identity' || (bucket === 'attributes' && notifiedKey === 'id')) { @@ -142,7 +142,7 @@ export default class HasManyReference { } map.forEach((token) => { - this.store._notificationManager.unsubscribe(token); + this.store.notifications.unsubscribe(token); }); map.clear(); diff --git a/packages/record-data/addon/-private/record-data.ts b/packages/record-data/addon/-private/record-data.ts index bb41420dac4..d4870778f3c 100644 --- a/packages/record-data/addon/-private/record-data.ts +++ b/packages/record-data/addon/-private/record-data.ts @@ -363,6 +363,24 @@ export default class SingletonRecordData implements RecordData { } } + peek(identifier: StableRecordIdentifier): Record { + const cached = this.__peek(identifier, true); + const data = Object.assign({}, cached.remoteAttrs, cached.inflightAttrs, cached.localAttrs); + + const attrDefs = this.__storeWrapper.getSchemaDefinitionService().attributesDefinitionFor(identifier); + + if (attrDefs) { + Object.keys(attrDefs).forEach((key) => { + if (!(key in data)) { + const attrSchema = attrDefs[key]; + data[key] = getDefaultValue(attrSchema?.options); + } + }); + } + + return data; + } + getAttr(identifier: StableRecordIdentifier, attr: string): unknown { const cached = this.__peek(identifier, true); if (cached.localAttrs && attr in cached.localAttrs) { diff --git a/packages/store/README.md b/packages/store/README.md index cd0394b7af2..32df08bba4a 100644 --- a/packages/store/README.md +++ b/packages/store/README.md @@ -56,20 +56,22 @@ import Store from '@ember-data/store'; import Cache from '@ember-data/record-data'; class extends Store { - #cache = null; - - createRecordDataFor(identifier, storeWrapper) { - this.#cache = this.#cache || new Cache(storeWrapper); - this.#cache.createCache(identifier); - return this.#cache; + createCache(storeWrapper) { + return new Cache(storeWrapper); } } ``` Now that we have a `cache` let's setup something to handle fetching and saving data via our API. +> Note: [1] `@ember-data/record-data` is a special cache: if the package is present the `createRecordDataFor` hook will automatically do the above wiring if the hook is not implemented. We still recommend implementing the hook. +> +> Note: [2] The `ember-data` package automatically includes the `@ember-data/record-data` cache for you. + ### Adding An Adapter +When *Ember***Data** needs to fetch or save data it will pass that request to your application's `Adapter` for fulfillment. How this fulfillment occurs (in-memory, device storage, via single or multiple API requests, etc.) is up to that Adapter. + To start, let's install a `JSON:API` adapter. If your app uses `GraphQL` or `REST` other adapters may better fit your data. You can author your own adapter by creating one that conforms to the [spec](). The package `@ember-data/adapter` provides a `JSON:API` adapter we can use. After installing it, we can configure the store to use this adapter. @@ -87,6 +89,8 @@ class extends Store { } ``` +If you want to know more about using Adapters with Ember read the next section, else lets skip to [Presenting Data from the Cache](#presenting-data-from-the-cache) to configure how our application will interact with our data. + #### Using with Ember Note: If you are using Ember and would like to make use of `service` injections in your adapter, you will want to additionally `setOwner` for the Adapter. @@ -113,4 +117,48 @@ class extends Store { } ``` -By default when using with Ember you only need to implement this hook if you want your adapter usage to be statically analyzeable. *Ember***Data** will attempt to resolve adapters using Ember's resolver. +By default when using with Ember you only need to implement this hook if you want your adapter usage to be statically analyzeable. *Ember***Data** will attempt to resolve adapters using Ember's resolver. To provide a single Adapter for your application like the above you would provide it as the default export of the file `app/adapters/application.{js/ts}` + +### Presenting Data from the Cache + +Now that we have a source and a cach for our data, we need to configure how the Store delivers that data back to our application. We do this via the hook `instantiateRecord`, which allows us to transform the data for a resource before handing it to the application. + +A naive way to present the data would be to return it as JSON. Typically instead this hook will be used to add reactivity and make each uniue resource a singleton, ensuring that if the cache updates our presented data will reflect the new state. + +Below is an example of using the hooks `instantiateRecord` and a `teardownRecord` to provide minimal read-only reactive state for simple resources. + +```ts +import Store, { recordIdentifierFor } from '@ember-data/store'; +import { TrackedObject } from 'tracked-built-ins'; + +class extends Store { + instantiateRecord(identifier) { + const { cache, notifications } = this; + + // create a TrackedObject with our attributes, id and type + const record = new TrackedObject(Object.assign({}, cache.peek(identifier))); + record.type = identifier.type; + record.id = identifier.id; + + notifications.subscribe(identifier, (_, change) => { + if (change === 'attributes') { + Object.assign(record, cache.peek(identifier)); + } + }); + + return record; + } +} +``` + +Because `instantiateRecord` is opaque to the nature of the record, an implementation can be anything from a fairly simple object to a robust proxy that intelligently links together associated records through relationships. + +This also enables creating a record that separates `edit` flows from `create` flows entirely. A record class might choose to implement a `checkout`method that gives access to an editable instance while the primary record continues to be read-only and reflect only persisted (non-mutated) state. + +Typically you will choose an existing record implementation such as `@ember-data/model` for your application. + +Because of the boundaries around instantiation and the cache, record implementations should be capable of interop both with each other and with any `Cache`. Due to this, if needed an application can utilize multiple record implementations and multiple cache implementations either to support enhanced features for only a subset of records or to be able to incrementally migrate from one record/cache to another record or cache. + +> Note: [1] `@ember-data/model` is a special record implementation: if the package is present the `instantiateRecord` hook will automatically do the above wiring if the hook is not implemented. Due to the complexity of this legacy package's use of Ember's resolver, we do not recommend wiring this package manually. +> +> Note: [2] The `ember-data` package automatically includes the `@ember-data/model` implementation for you. diff --git a/packages/store/addon/-private/caches/instance-cache.ts b/packages/store/addon/-private/caches/instance-cache.ts index aecfebcd99f..5a7480f4750 100644 --- a/packages/store/addon/-private/caches/instance-cache.ts +++ b/packages/store/addon/-private/caches/instance-cache.ts @@ -243,7 +243,7 @@ export class InstanceCache { identifier, properties || {}, this.__recordDataFor, - this.store._notificationManager + this.store.notifications ); setRecordIdentifier(record, identifier); setRecordDataFor(record, recordData); @@ -411,6 +411,8 @@ export class InstanceCache { const recordData = this.__instances.recordData.get(identifier); if (record) { + // TODO consider automatically unsubscribing records + // to ease ergonomics of using the notifications this.store.teardownRecord(record); this.__instances.record.delete(identifier); StoreMap.delete(record); @@ -538,7 +540,7 @@ export class InstanceCache { // TODO update recordData if needed ? // TODO handle consequences of identifier merge for notifications - this.store._notificationManager.notify(identifier, 'identity'); + this.store.notifications.notify(identifier, 'identity'); } // TODO this should move into something coordinating operations @@ -575,7 +577,7 @@ export class InstanceCache { const recordData = this.getRecordData(identifier); if (recordData.isNew(identifier)) { - this.store._notificationManager.notify(identifier, 'identity'); + this.store.notifications.notify(identifier, 'identity'); } const hasRecord = this.__instances.record.has(identifier); diff --git a/packages/store/addon/-private/legacy-model-support/record-reference.ts b/packages/store/addon/-private/legacy-model-support/record-reference.ts index d8edfcdb5f9..c6cd5ac8308 100644 --- a/packages/store/addon/-private/legacy-model-support/record-reference.ts +++ b/packages/store/addon/-private/legacy-model-support/record-reference.ts @@ -37,7 +37,7 @@ export default class RecordReference { constructor(store: Store, identifier: StableRecordIdentifier) { this.store = store; this.___identifier = identifier; - this.___token = store._notificationManager.subscribe( + this.___token = store.notifications.subscribe( identifier, (_: StableRecordIdentifier, bucket: NotificationType, notifiedKey?: string) => { if (bucket === 'identity' || (bucket === 'attributes' && notifiedKey === 'id')) { diff --git a/packages/store/addon/-private/managers/record-data-manager.ts b/packages/store/addon/-private/managers/record-data-manager.ts index 27f7f2b8f9d..7f90694d42f 100644 --- a/packages/store/addon/-private/managers/record-data-manager.ts +++ b/packages/store/addon/-private/managers/record-data-manager.ts @@ -312,6 +312,28 @@ export class NonSingletonRecordDataManager implements RecordData { // Attrs // ===== + /** + * Retrieve all attributes for a resource from the cache + * + * @method peek + * @public + * @param identifier + * @returns {object} + */ + peek(identifier: StableRecordIdentifier): Record { + const recordData = this.#recordData; + + if (this.#isDeprecated(recordData)) { + const attrs = this.#store.getSchemaDefinitionService().attributesDefinitionFor(identifier); + const ret = {}; + Object.keys(attrs).forEach((key) => { + ret[key] = recordData.getAttr(key); + }); + return ret; + } + return recordData.peek(identifier); + } + /** * Retrieve the data for an attribute from the cache * @@ -786,6 +808,10 @@ export class SingletonRecordDataManager implements RecordData { // Attrs // ===== + peek(identifier: StableRecordIdentifier): Record { + return this.#recordData(identifier).peek(identifier); + } + getAttr(identifier: StableRecordIdentifier, propertyName: string): unknown { return this.#recordData(identifier).getAttr(identifier, propertyName); } diff --git a/packages/store/addon/-private/managers/record-data-store-wrapper.ts b/packages/store/addon/-private/managers/record-data-store-wrapper.ts index 39a5926f6a9..2b431e3a8bd 100644 --- a/packages/store/addon/-private/managers/record-data-store-wrapper.ts +++ b/packages/store/addon/-private/managers/record-data-store-wrapper.ts @@ -70,7 +70,7 @@ class LegacyWrapper implements LegacyRecordDataStoreWrapper { pending.forEach((set, identifier) => { set.forEach((key) => { - this._store._notificationManager.notify(identifier, 'relationships', key); + this._store.notifications.notify(identifier, 'relationships', key); }); }); } @@ -84,7 +84,7 @@ class LegacyWrapper implements LegacyRecordDataStoreWrapper { return; } - this._store._notificationManager.notify(identifier, namespace, key); + this._store.notifications.notify(identifier, namespace, key); if (namespace === 'state') { this._store.recordArrayManager.identifierChanged(identifier); @@ -105,7 +105,7 @@ class LegacyWrapper implements LegacyRecordDataStoreWrapper { const resource = constructResource(type, id, lid); const identifier = this.identifierCache.getOrCreateRecordIdentifier(resource); - this._store._notificationManager.notify(identifier, 'errors'); + this._store.notifications.notify(identifier, 'errors'); } attributesDefinitionFor(type: string): AttributesSchema { @@ -158,7 +158,7 @@ class LegacyWrapper implements LegacyRecordDataStoreWrapper { const resource = constructResource(type, id, lid); const identifier = this.identifierCache.getOrCreateRecordIdentifier(resource); - this._store._notificationManager.notify(identifier, 'attributes', key); + this._store.notifications.notify(identifier, 'attributes', key); } notifyHasManyChange(type: string, id: string | null, lid: string, key: string): void; @@ -208,7 +208,7 @@ class LegacyWrapper implements LegacyRecordDataStoreWrapper { const resource = constructResource(type, id, lid); const identifier = this.identifierCache.getOrCreateRecordIdentifier(resource); - this._store._notificationManager.notify(identifier, 'state'); + this._store.notifications.notify(identifier, 'state'); this._store.recordArrayManager.identifierChanged(identifier); } @@ -374,7 +374,7 @@ class V2RecordDataStoreWrapper implements StoreWrapper { pending.forEach((set, identifier) => { set.forEach((key) => { - this._store._notificationManager.notify(identifier, 'relationships', key); + this._store.notifications.notify(identifier, 'relationships', key); }); }); } @@ -388,7 +388,7 @@ class V2RecordDataStoreWrapper implements StoreWrapper { return; } - this._store._notificationManager.notify(identifier, namespace, key); + this._store.notifications.notify(identifier, namespace, key); if (namespace === 'state') { this._store.recordArrayManager.identifierChanged(identifier); diff --git a/packages/store/addon/-private/store-service.ts b/packages/store/addon/-private/store-service.ts index 94a8c09251f..d2fd0f6319f 100644 --- a/packages/store/addon/-private/store-service.ts +++ b/packages/store/addon/-private/store-service.ts @@ -170,7 +170,7 @@ class Store extends Service { declare recordArrayManager: RecordArrayManager; - declare _notificationManager: NotificationManager; + declare notifications: NotificationManager; declare identifierCache: IdentifierCache; declare _adapterCache: Dict; declare _serializerCache: Dict; @@ -209,8 +209,17 @@ class Store extends Service { // private but maybe useful to be here, somewhat intimate this.recordArrayManager = new RecordArrayManager({ store: this }); - // private, TODO consider taking public as the instance is public to instantiateRecord anyway - this._notificationManager = new NotificationManager(this); + /** + * Provides access to the NotificationManager instance + * for this store. + * + * The NotificationManager can be used to subscribe to changes + * for any identifier. + * + * @property {NotificationManager} notifications + * @public + */ + this.notifications = new NotificationManager(this); // private this._fetchManager = new FetchManager(this); @@ -325,7 +334,7 @@ class Store extends Service { * @param identifier * @param createRecordArgs * @param recordDataFor - * @param notificationManager + * @param notifications * @returns A record instance * @public */ @@ -333,7 +342,7 @@ class Store extends Service { identifier: StableRecordIdentifier, createRecordArgs: { [key: string]: unknown }, recordDataFor: (identifier: StableRecordIdentifier) => RecordData, - notificationManager: NotificationManager + notifications: NotificationManager ): DSModel | RecordInstance { if (HAS_MODEL_PACKAGE) { let modelName = identifier.type; @@ -1891,7 +1900,7 @@ class Store extends Service { graph.identifiers.clear(); } } - this._notificationManager.destroy(); + this.notifications.destroy(); this.recordArrayManager.clear(); this._instanceCache.clear(); diff --git a/tests/docs/fixtures/expected.js b/tests/docs/fixtures/expected.js index 3f12f9d86f6..09fd4f4f545 100644 --- a/tests/docs/fixtures/expected.js +++ b/tests/docs/fixtures/expected.js @@ -423,7 +423,6 @@ module.exports = { '(public) @ember-data/store SchemaDefinitionService#relationshipsDefinitionFor', '(public) @ember-data/adapter/error @ember-data/adapter/error#errorsArrayToHash', '(public) @ember-data/adapter/error @ember-data/adapter/error#errorsHashToArray', - '(public) @ember-data/store Store#identifierCache', '(private) @ember-data/model PromiseManyArray#forEach', '(public) @ember-data/model PromiseManyArray#isFulfilled', '(public) @ember-data/model PromiseManyArray#isPending', @@ -438,5 +437,6 @@ module.exports = { '(public) @ember-data/model PromiseManyArray#then', '(public) @ember-data/store ManyArray#links', '(public) @ember-data/store Store#identifierCache', + '(public) @ember-data/store Store#notifications', ], }; diff --git a/tests/main/tests/unit/custom-class-support/custom-class-model-test.ts b/tests/main/tests/unit/custom-class-support/custom-class-model-test.ts index 29bb44591cf..3b20efdab65 100644 --- a/tests/main/tests/unit/custom-class-support/custom-class-model-test.ts +++ b/tests/main/tests/unit/custom-class-support/custom-class-model-test.ts @@ -50,7 +50,7 @@ module('unit/model - Custom Class Model', function (hooks) { }, }); } - instantiateRecord(identifier, createOptions, recordDataFor, notificationManager) { + instantiateRecord(identifier, createOptions, recordDataFor, notifications) { return new Person(this); } teardownRecord(record) {} @@ -86,10 +86,10 @@ module('unit/model - Custom Class Model', function (hooks) { id: StableRecordIdentifier, createRecordArgs, recordDataFor, - notificationManager: NotificationManager + notifications: NotificationManager ): Object { identifier = id; - notificationManager.subscribe(identifier, (passedId, key) => { + notifications.subscribe(identifier, (passedId, key) => { notificationCount++; assert.strictEqual(passedId, identifier, 'passed the identifier to the callback'); if (notificationCount === 1) { @@ -121,7 +121,7 @@ module('unit/model - Custom Class Model', function (hooks) { assert.expect(5); let returnValue; class CreationStore extends CustomStore { - instantiateRecord(identifier, createRecordArgs, recordDataFor, notificationManager) { + instantiateRecord(identifier, createRecordArgs, recordDataFor, notifications) { assert.strictEqual(identifier.type, 'person', 'Identifier type passed in correctly'); assert.deepEqual(createRecordArgs, { otherProp: 'unk' }, 'createRecordArg passed in'); returnValue = {}; @@ -142,7 +142,7 @@ module('unit/model - Custom Class Model', function (hooks) { assert.expect(1); let rd; class CreationStore extends Store { - instantiateRecord(identifier, createRecordArgs, recordDataFor, notificationManager) { + instantiateRecord(identifier, createRecordArgs, recordDataFor, notifications) { rd = recordDataFor(identifier); assert.strictEqual(rd.getAttr(identifier, 'name'), 'chris', 'Can look up record data from recordDataFor'); return {}; @@ -232,7 +232,7 @@ module('unit/model - Custom Class Model', function (hooks) { }) ); class CustomStore extends Store { - instantiateRecord(identifier, createOptions, recordDataFor, notificationManager) { + instantiateRecord(identifier, createOptions, recordDataFor, notifications) { return new Person(this); } teardownRecord(record) {} @@ -330,11 +330,11 @@ module('unit/model - Custom Class Model', function (hooks) { }) ); let CreationStore = CustomStore.extend({ - instantiateRecord(identifier, createRecordArgs, recordDataFor, notificationManager) { + instantiateRecord(identifier, createRecordArgs, recordDataFor, notifications) { ident = identifier; rd = recordDataFor(identifier); assert.false(rd.isDeleted(identifier), 'we are not deleted when we start'); - notificationManager.subscribe(identifier, (passedId, key) => { + notifications.subscribe(identifier, (passedId, key) => { assert.strictEqual(key, 'state', 'state change to deleted has been notified'); assert.true(recordDataFor(identifier).isDeleted(identifier), 'we have been marked as deleted'); }); @@ -365,7 +365,7 @@ module('unit/model - Custom Class Model', function (hooks) { }) ); class CustomStore extends Store { - instantiateRecord(identifier, createOptions, recordDataFor, notificationManager) { + instantiateRecord(identifier, createOptions, recordDataFor, notifications) { return new Person(this); } teardownRecord(record) {}