Singleton Record Data
Summary
Ensures that RecordData
can be implemented as a singleton, eliminates several redundant APIs
that creeped into the original implementation, and simplifies the method signatures of
RecordData APIs by using Identifiers
.
Motivation
RecordData
is the data-cache primitive for information given to ember-data
, and while
a cache-per-entity setup is sometimes desired, a singleton cache offers opportunities for
performance optimization and improved feature sets. Current default RecordData
, and
InternalModel
which it replaced are examples of cache-per-entity strategies.
Our original intent when discussing RecordData
was for it to be possible to be implemented as a singleton; however, this intent was not
captured well in that RFC and while the APIs presented there would have enabled it, the
actual implementation differed in ways that prevent singleton
implementations.
The introduction of Identifiers
presents us with a good opportunity to refactor the API
surface of our cache-primitive, simplifying and streamlining how it works while solidifying
and codifying its ability to be a singleton
.
Reduce Overloading
Previously, to deal with the lack of a unified identifier concept, we overloaded many
StoreWrapper
and RecordData
method signatures with modelName, id, clientId
as
arguments
. The original intent was to overload all of their method signatures
in this way to ensure singleton
implementations could be built, but we failed to
correctly implement the original RecordData RFC
in this regard.
The introduction of Identifiers
provides us a cleaner interface for communicating identity
in these APIs. For uniformity, methods will always take an identifier
as their first
argument.
Detailed design
Because RecordData
is a public userland interface we
must rely on capabilities reporting to handle the
deprecations for it introduced by this RFC.
We will use this opportunity to improve the encapsulation
of RecordData
via introduction of a sandbox.
This sandbox will be responsible for handling interop,
deprecations and other book-keeping tasks we require as needed
while ensuring that RecordData
implementations are properly
encapsulated (e.g. implementations can only talk to other
implementations via public API).
RecordData implementations must now specify a version
.
The implicit version when unspecified is "1"
(the version
which this RFC supplants and deprecates). Implementations
without a version or with a version equal to "1"
will
receive a deprecation notice the first time an instance of
a previously unseen RecordData
class is encountered.
In keeping with the support policy of Ember.js
, future
releases of ember-data
will support the versions of RecordData
current at the time of the last two ember-data
LTS
releases.
The APIs proposed by this RFC constitute version "2"
and will be used by ember-data
when the following is
true
.
recordData.version === "2";
It is possible that once this upgrade to identifiers
has occurred that we never need to
increment this version
again. If so, a future RFC may choose to deprecate the version
property altogether once "version 1" is no longer supported.
Sandboxing
The sandbox implementation would guarantee the following
Beginning with this RFC, StoreWrapper.recordDataFor
will no longer directly return the RecordData
instance provided by the createRecordDataFor
hook, instead returning a delegate.
This encapsulates the RecordData
feature ensuring that communication is via public API
and provides us the ability to manage the three concerns listed above and described below.
Managing Deprecations
When a RecordData
method or method signature is deprecated, we will detect calls to deprecated
methods or calls using deprecated method signatures and print the appropriate deprecation messages.
Version Interop
When a RecordData
method or method signature is deprecated the instance provided by createRecordDataFor
may not have implemented the deprecated method or signature. Using the supplied version, calling context,
and other information available we will transform calls to deprecated methods or signatures
into their supported equivalent.
Updates to Store Methods
Old |
New |
class Store {
createRecordDataFor(
modelName: string,
id: string,
clientId: string | null,
storeWrapper
): RecordData;
createRecordDataFor(
modelName: string,
id: null,
clientId: string,
storeWrapper
): RecordData {}
}
|
class Store {
createRecordDataFor(identifier: RecordIdentifier, storeWrapper): RecordData {}
}
|
Updates to StoreWrapper Methods
Old |
New |
class RecordDataStoreWrapper {
recordDataFor(
modelName: string,
id: string | null,
clientId: string | null
): RecordData {}
notifyPropertyChange(
modelName: string,
id: string | null,
clientId: string | null,
key: string
): void {}
notifyHasManyChange(
modelName: string,
id: string | null,
clientId: string | null,
key: string
): void {}
notifyBelongsToChange(
modelName: string,
id: string | null,
clientId: string | null,
key: string
): void {}
attributesDefinitionFor(modelName: string): AttributesSchema {}
relationshipsDefinitionFor(modelName: string): RelationshipsSchema {}
setRecordId(modelName: string, id: string, clientId: string): void {}
disconnectRecord(
modelName: string,
id: string | null,
clientId: string | null
): void {}
@deprecated // Use hasRecord
isRecordInUse(
modelName: string,
id: string | null,
clientId: string | null
): boolean {}
inverseForRelationship(modelName: string, key: string): string {}
inverseIsAsyncForRelationship(modelName: string, key: string): boolean {}
}
|
class RecordDataStoreWrapper {
recordDataFor(identifier: RecordIdentifier): RecordData {}
@deprecated // use notifyChange instead
notifyPropertyChange(
modelName: string,
id: string | null,
clientId: string | null,
key: string
): void {}
@deprecated // not in the original RFC, use notifyChange instead
notifyHasManyChange(
modelName: string,
id: string | null,
clientId: string | null,
key: string
): void {}
@deprecated // not in the original RFC, use notifyChange instead
notifyBelongsToChange(
modelName: string,
id: string | null,
clientId: string | null,
key: string
): void {}
// replaces notifyErrorsChange introduced by RecordData Errors RFC
notifyChange(
identifier: RecordIdentifier,
namespace: "errors" | "relationships" | "attributes" | "meta" | "state"
): void {}
attributesDefinitionFor(identifier: RecordIdentifier): AttributesSchema {}
relationshipsDefinitionFor(
identifier: RecordIdentifier
): RelationshipsSchema {}
setRecordId(identifier: RecordIdentifier, id: string): void {}
disconnectRecord(identifier: RecordIdentifier): void {}
@deprecated // Use hasRecord
isRecordInUse(
modelName: string,
id: string | null,
clientId: string | null
): boolean {}
hasRecord(identifier: RecordIdentifier): boolean {}
inverseForRelationship(identifier: RecordIdentifier, key: string): string {}
inverseIsAsyncForRelationship(
identifier: RecordIdentifier,
key: string
): boolean {}
}
|
Updates to RecordData Methods
Old |
New |
interface LegacyResourceIdentifierObject {
type: string;
id: string | null;
clientId: string | null;
}
interface RecordDataV1 {
unloadRecord(): void;
@deprecated // This RFC deprecates this method without replacement. It was not in
// the original RecordData RFC and after investigation is unneeded once the singleton
// and identifiers refactoring are completed.
isRecordInUse(): boolean;
isEmpty(): boolean;
isNew(): boolean;
getAttr(propertyName: string): any;
isAttrDirty(propertyName: string): boolean;
changedAttributes(): AttributesChanges;
hasChangedAttributes(): boolean;
rollbackAttributes(): void;
@deprecated // this RFC deprecates this method entirely
getBelongsTo(propertyName: string): { data: LegacyResourceIdentifierObject };
@deprecated // this RFC deprecates this method entirely
getHasMany(propertyName: string): { data: LegacyResourceIdentifierObject[] };
willCommit(): void;
didCommit(data: any): void;
@deprecated // this RFC deprecates this method entirely
_initRecordCreateOptions(options: object): object;
clientDidCreate(): void;
@deprecated // this RFC deprecates this method entirely and without replacement
getResourceIdentifier(): LegacyResourceIdentifierObject;
// original RecordData RFC specified setBelongsTo
// with the 2nd arg being LegacyResourceIdentifierObject
// but that was not what the resulting implementation in
// ember-data did
setDirtyBelongsTo(propertyName: string, value: RecordData | null): void;
// original RecordData RFC specified setAttribute
// but that was not what the resulting implementation in
// ember-data did
setDirtyAttribute(propertyName: string, value: any): void;
// original RecordData RFC specified setHasMany
// with the 2nd arg being LegacyResourceIdentifierObject[]
// but that was not what the resulting implementation in
// ember-data did
setDirtyHasMany(propertyName: string, value: RecordData[]): void;
// original RecordData RFC specified addToHasMany
// with the 2nd arg being LegacyResourceIdentifierObject[]
// but that was not what the resulting implementation in
// ember-data did
addToHasMany(propertyName: string, values: RecordData[], startIndex?: number): void;
// original RecordData RFC specified removeFromHasMany
// with the 2nd arg being LegacyResourceIdentifierObject[]
// but that was not what the resulting implementation in
// ember-data did
removeFromHasMany(propertyName: string, values: RecordData[]): void;
@deprecated // this RFC deprecates this method entirely and without replacement
removeFromInverseRelationships(isNew: boolean): void;
}
|
interface RecordDataV2 {
version: "2";
unloadRecord(identifier: Identifier);
isEmpty(identifier: Identifier): boolean;
isNew(identifier: Identifier): boolean;
getAttr(identifier: Identifier, propertyName: string): any;
isAttrDirty(identifier: Identifier, propertyName: string): boolean;
changedAttrs(identifier: Identifier): AttributesChanges;
hasChangedAttrs(identifier: Identifier): boolean;
rollbackAttrs(identifier: Identifier): void;
getRelationship(
identifier: Identifier,
propertyName: string
): { data: Identifier | Identifier[] };
willCommit(identifier: Identifier): void;
didCommit(identifier: Identifier, data: any);
clientDidCreate(identifier: Identifier, options: object): void;
setBelongsTo(
identifier: Identifier,
propertyName: string,
value: Identifier | null
): void;
setAttr(identifier: Identifier, propertyName: string, value: any): void;
setHasMany(
identifier: Identifier,
propertyName: string,
values: Identifier[]
): void;
addToHasMany(
identifier: Identifier,
propertyName: string,
values: Identifier[],
startIndex?: number
): void;
removeFromHasMany(
identifier: Identifier,
propertyName: string,
values: Identifier[]
): void;
}
|
How we teach this
RecordData
is primarily an API meant for power-user addon-authors,
and not something we expect everyday users of ember-data
to be intimately
familiar with. It is unlikely that we produce a guide
for using RecordData
, but
a public typescript
interface for RecordData
should be introduced with
API Documentation for the available APIs and their roles.
Drawbacks
It introduces churn in APIs we only recently introduced (in the past 6 months); however we have
strong reason to believe that very few implementations exist and that those which do have their
migration path covered by the sandboxed RecordData implementation.
Alternatives
- keep the status quo: large number of arguments to methods, no support for singleton RecordData,
renders
Identifier
RFC largely useless and makes future iteration on RecordData similarly difficult.