@aws-amplify/datastore
Version:
AppSyncLocal support for aws-amplify
297 lines (242 loc) • 7.75 kB
text/typescript
import { MutationEvent } from './index';
import { ModelPredicateCreator } from '../predicates';
import {
ExclusiveStorage as Storage,
StorageFacade,
Storage as StorageClass,
} from '../storage/storage';
import { ModelInstanceCreator } from '../datastore/datastore';
import {
InternalSchema,
PersistentModel,
PersistentModelConstructor,
QueryOne,
} from '../types';
import { USER, SYNC, valuesEqual } from '../util';
import { TransformerMutationType } from './utils';
// TODO: Persist deleted ids
class MutationEventOutbox {
private inProgressMutationEventId: string;
constructor(
private readonly schema: InternalSchema,
private readonly MutationEvent: PersistentModelConstructor<MutationEvent>,
private readonly modelInstanceCreator: ModelInstanceCreator,
private readonly ownSymbol: Symbol
) {}
public async enqueue(
storage: Storage,
mutationEvent: MutationEvent
): Promise<void> {
storage.runExclusive(async s => {
const mutationEventModelDefinition =
this.schema.namespaces[SYNC].models['MutationEvent'];
const predicate = ModelPredicateCreator.createFromExisting<MutationEvent>(
mutationEventModelDefinition,
c =>
c
.modelId('eq', mutationEvent.modelId)
.id('ne', this.inProgressMutationEventId)
);
const [first] = await s.query(this.MutationEvent, predicate);
if (first === undefined) {
await s.save(mutationEvent, undefined, this.ownSymbol);
return;
}
const { operation: incomingMutationType } = mutationEvent;
if (first.operation === TransformerMutationType.CREATE) {
if (incomingMutationType === TransformerMutationType.DELETE) {
await s.delete(this.MutationEvent, predicate);
} else {
// first gets updated with the incoming mutation's data, condition intentionally skipped
// we need to merge the fields for a create and update mutation to prevent
// data loss, since update mutations only include changed fields
const merged = this.mergeUserFields(first, mutationEvent);
await s.save(
this.MutationEvent.copyOf(first, draft => {
draft.data = merged.data;
}),
undefined,
this.ownSymbol
);
}
} else {
const { condition: incomingConditionJSON } = mutationEvent;
const incomingCondition = JSON.parse(incomingConditionJSON);
let merged: MutationEvent;
// If no condition
if (Object.keys(incomingCondition).length === 0) {
merged = this.mergeUserFields(first, mutationEvent);
// delete all for model
await s.delete(this.MutationEvent, predicate);
}
merged = merged || mutationEvent;
// Enqueue new one
await s.save(merged, undefined, this.ownSymbol);
}
});
}
public async dequeue(
storage: StorageClass,
record?: PersistentModel,
recordOp?: TransformerMutationType
): Promise<MutationEvent> {
const head = await this.peek(storage);
if (record) {
await this.syncOutboxVersionsOnDequeue(storage, record, head, recordOp);
}
await storage.delete(head);
this.inProgressMutationEventId = undefined;
return head;
}
/**
* Doing a peek() implies that the mutation goes "inProgress"
*
* @param storage
*/
public async peek(storage: StorageFacade): Promise<MutationEvent> {
const head = await storage.queryOne(this.MutationEvent, QueryOne.FIRST);
this.inProgressMutationEventId = head ? head.id : undefined;
return head;
}
public async getForModel<T extends PersistentModel>(
storage: StorageFacade,
model: T
): Promise<MutationEvent[]> {
const mutationEventModelDefinition =
this.schema.namespaces[SYNC].models.MutationEvent;
const mutationEvents = await storage.query(
this.MutationEvent,
ModelPredicateCreator.createFromExisting(
mutationEventModelDefinition,
c => c.modelId('eq', model.id)
)
);
return mutationEvents;
}
public async getModelIds(storage: StorageFacade): Promise<Set<string>> {
const mutationEvents = await storage.query(this.MutationEvent);
const result = new Set<string>();
mutationEvents.forEach(({ modelId }) => result.add(modelId));
return result;
}
// applies _version from the AppSync mutation response to other items
// in the mutation queue with the same id
// see https://github.com/aws-amplify/amplify-js/pull/7354 for more details
private async syncOutboxVersionsOnDequeue(
storage: StorageClass,
record: PersistentModel,
head: PersistentModel,
recordOp: string
): Promise<void> {
if (head.operation !== recordOp) {
return;
}
const { _version, _lastChangedAt, _deleted, ..._incomingData } = record;
const incomingData = this.removeTimestampFields(head.model, _incomingData);
const data = JSON.parse(head.data);
if (!data) {
return;
}
const {
_version: __version,
_lastChangedAt: __lastChangedAt,
_deleted: __deleted,
..._outgoingData
} = data;
const outgoingData = this.removeTimestampFields(head.model, _outgoingData);
// Don't sync the version when the data in the response does not match the data
// in the request, i.e., when there's a handled conflict
if (!valuesEqual(incomingData, outgoingData, true)) {
return;
}
const mutationEventModelDefinition =
this.schema.namespaces[SYNC].models['MutationEvent'];
const predicate = ModelPredicateCreator.createFromExisting<MutationEvent>(
mutationEventModelDefinition,
c => c.modelId('eq', record.id).id('ne', this.inProgressMutationEventId)
);
const outdatedMutations = await storage.query(
this.MutationEvent,
predicate
);
if (!outdatedMutations.length) {
return;
}
const reconciledMutations = outdatedMutations.map(m => {
const oldData = JSON.parse(m.data);
const newData = { ...oldData, _version, _lastChangedAt };
return this.MutationEvent.copyOf(m, draft => {
draft.data = JSON.stringify(newData);
});
});
await storage.delete(this.MutationEvent, predicate);
await Promise.all(
reconciledMutations.map(
async m => await storage.save(m, undefined, this.ownSymbol)
)
);
}
private mergeUserFields(
previous: MutationEvent,
current: MutationEvent
): MutationEvent {
const { _version, id, _lastChangedAt, _deleted, ...previousData } =
JSON.parse(previous.data);
const {
id: __id,
_version: __version,
_lastChangedAt: __lastChangedAt,
_deleted: __deleted,
...currentData
} = JSON.parse(current.data);
const data = JSON.stringify({
id,
_version,
_lastChangedAt,
_deleted,
...previousData,
...currentData,
});
return this.modelInstanceCreator(this.MutationEvent, {
...current,
data,
});
}
/*
if a model is using custom timestamp fields
the custom field names will be stored in the model attributes
e.g.
"attributes": [
{
"type": "model",
"properties": {
"timestamps": {
"createdAt": "createdOn",
"updatedAt": "updatedOn"
}
}
}
]
*/
private removeTimestampFields(
model: string,
record: PersistentModel
): PersistentModel {
const CREATED_AT_DEFAULT_KEY = 'createdAt';
const UPDATED_AT_DEFAULT_KEY = 'updatedAt';
let createdTimestampKey = CREATED_AT_DEFAULT_KEY;
let updatedTimestampKey = UPDATED_AT_DEFAULT_KEY;
const modelAttributes = this.schema.namespaces[USER].models[
model
].attributes?.find(attr => attr.type === 'model');
const timestampFieldsMap = modelAttributes?.properties?.timestamps;
if (timestampFieldsMap) {
createdTimestampKey = timestampFieldsMap[CREATED_AT_DEFAULT_KEY];
updatedTimestampKey = timestampFieldsMap[UPDATED_AT_DEFAULT_KEY];
}
delete (record as Record<string, any>)[createdTimestampKey];
delete (record as Record<string, any>)[updatedTimestampKey];
return record;
}
}
export { MutationEventOutbox };