diff --git a/README.md b/README.md index 2f04b54f..216bdc80 100644 --- a/README.md +++ b/README.md @@ -132,14 +132,14 @@ You should now be able to import `@ably-labs/models` in your project. ### Instantiation -To instantiate the Models SDK, create an [Ably client](https://ably.com/docs/getting-started/setup) and pass it into the Models constructor: +To instantiate the Models SDK, create an [Ably client](https://ably.com/docs/getting-started/setup) and pass it into the ModelsClient constructor: -```ts -import Models from '@ably-labs/models'; +```typescript +import ModelsClient from '@ably-labs/models'; import { Realtime } from 'ably'; const ably = new Realtime.Promise({ key: "" }); -const models = new Models({ ably }); +const modelsClient = new ModelsClient({ ably }); ``` ### Creating a Model @@ -153,7 +153,7 @@ You create a model by defining: - How the model is updated when *events* are received from your backend - How the model can be *mutated* by the user -```ts +```typescript // this is the type for our model's data as represented in the frontend application type Post = { id: number; @@ -167,38 +167,30 @@ async function sync() { return result.json(); } -// a function used by the model to update the model state when a change event is received -async function onPostUpdated(state: Post, event: OptimisticEvent | ConfirmedEvent) { +// a function used by the model to merge a change event that is received and the existing model state +async function merge(state: Post, event: OptimisticEvent | ConfirmedEvent) { return { ...state, text: event.data, // replace the previous post text field with the new value } } -// a function that the user can call to mutate the model data in your backend -async function updatePost(context: MutationContext, content: string) { +// a function that you might use to mutate the model data in your backend +async function updatePost(mutationId: string, content: string) { const result = await fetch(`/api/post`, { method: 'PUT', - body: JSON.stringify({ content }), + body: JSON.stringify({ mutationId, content }), }); return result.json(); } -// create a new model instance called 'post' -const model = models.Model('post'); - -// register the functions we defined above -await model.$register({ +// create a new model instance called 'post' by passing the sync and merge functions +const model = await modelsClient.models.get({ + name: 'post', + channelName: 'models:posts', $sync: sync, - $update: { - 'posts': { - 'update': onPostUpdated, - }, - }, - $mutate: { - updatePost, - }, -}); + $merge: merge, +}) // subscribe to live changes to the model data! model.subscribe((err, post) => { @@ -208,8 +200,18 @@ model.subscribe((err, post) => { console.log('post updated:', post); }); -// mutate the post -const [result, confirmation] = await model.mutations.updatePost('new value'); + +// apply an optimistic update to the model +// confirmation is a promise that resolves when the optimistic update is confirmed by the backend. +// cancel is a function that can be used to cancel and rollback the optimistic update. +const [confirmation, cancel] = await model1.optimistic({ + mutationId: 'my-mutation-id', + name: 'updatePost', + data: 'new post text', +}) + +// call your backend to apply the actual change +updatePost('my-mutation-id', 'new post text') // wait for confirmation of the change from the backend await confirmation; diff --git a/docs/concepts/concepts.md b/docs/concepts/concepts.md index 277988a7..b7b2535d 100644 --- a/docs/concepts/concepts.md +++ b/docs/concepts/concepts.md @@ -4,7 +4,7 @@ The Models SDK aims to make it easier for developers to efficiently and correctly synchronise state from the database to the client in realtime. -- The database is treated as the *source of truth* of the state +- The database is treated as the *source of truth* of the state - Alterations to the state take place through your backend APIs - The client initialises its local copy of the state via your backend APIs - The client processes a stream of change events from the backend to compute the up-to-date state as it evolves @@ -17,7 +17,7 @@ sequenceDiagram participant User participant Models SDK participant Backend - + User->>Models SDK: Takes action to mutate model Models SDK->>Models SDK: Emit optimistic event Models SDK->>User: Display optimistic state @@ -82,7 +82,7 @@ graph LR A[State v0] -->|event| B(State v1) --> |...| C(State vN) ``` -The next state is produced by an *update function*, which is a pure function of the previous state and the change event: +The next state is produced by an *merge function*, which is a pure function of the previous state and the change event: ```ts (state, event) => state @@ -95,7 +95,7 @@ graph LR A["[first comment]"] --> |add: second comment| B["[first comment, second comment]"] ``` -The update function might therefore be expressed as: +The merge function might therefore be expressed as: ```ts (state, event) => ({ @@ -106,7 +106,7 @@ The update function might therefore be expressed as: ## Applying Updates to State -Events are applied to model state via update function in the following way: +Events are applied to model state via merge function in the following way: - The model state is initialised with two copies of the state: an optimistic and a confirmed copy. - If the incoming event is an *optimistic* event: @@ -129,53 +129,43 @@ There may be cases where a confirmation event from your backend never arrives. F In this case, the optimistic update should be rolled back if a confirmation isn't received within some time period. This timeout interval defaults to 2 minutes, but you can override this behaviour by providing via mutation options: -```ts +```typescript // set a default timeout of 5s second via mutation options on the model -const models = new Models({ ably }, { - timeout: 5000, -}); - -// set a default timeout of 5s on a per-mutation basis -await model.$register({ - $mutate: { - myMutation: { - func: myMutation, - options: { timeout: 5000 }, - } - }, +const modelsClient = new ModelsClient({ + ably: ably, + defaultOptimisticOptions: { timeout: 5000 } , }); -// set a timeout of 5s on a specific mutation invocation -await model.mutations.myMutation.$expect({ - events: myExpectedEvents, +// set a timeout of 5s on a specific optimistic update invocation +await model.optimistic({ + event: myExpectedEvents, options: { timeout: 5000, }, })(); ``` - ## Rejections -In some cases you may wish to explicitly reject a mutation from your backend. You could do this by simply throwing an error from your mutation function, which would cause any optimistic events for that mutation to be rolled back: +In some cases you may wish to explicitly reject an optimistic event from your backend. +You can do this in the following ways: +1. Using the optimistic event's cancel function. +2. Throw an error from the `merge` function when processing an optimistic event. +3. Using the `x-ably-models-reject` header on an event emitted from your backend. -```ts -await model.$register({ - $mutate: { - myMutation: async () => { - throw new Error('mutation failed!'); - }, - }, -}); +Here's an example using the optimistic event's cancel function: +```typescript +const [confirmation, cancel] = await model.optimistic({ ... }); try { - await model.mutations.myMutation.$expect({ - events: myExpectedEvents, - })(); + updateMyBackend(); } catch (err) { - // mutation failed! + cancel(); } ``` -Other times, you may wish to reject a mutation asynchronously to the lifetime of the backend request made from your mutation function. In this case, it is useful to emit an explicit rejection event from your backend. You can do this by including an `x-ably-models-reject` header on the confirmation event. The Models SDK will automatically treat confirmations with this header set as a rejection and rollback any corresponding optimistic events. +Other times, you may wish to reject a mutation asynchronously to the lifetime of the backend request made from your mutation function. +In this case, it is useful to emit an explicit rejection event from your backend. +You can do this by including an `x-ably-models-reject` header on the confirmation event. +The Models SDK will automatically treat confirmations with this header set as a rejection and rollback any corresponding optimistic events. diff --git a/docs/concepts/event-buffering.md b/docs/concepts/event-buffering.md index 7aa11a77..e84b082d 100644 --- a/docs/concepts/event-buffering.md +++ b/docs/concepts/event-buffering.md @@ -7,8 +7,8 @@ By default the sliding window event buffer is off. It can be enabled but setting the number of millisecond to buffer events for, when instantiating the library: -```ts -const models = new Models({ +```typescript +const modelsClient = new ModelsClient({ ably, eventBufferOptions: {bufferMs: 100} }); @@ -22,7 +22,7 @@ That is, smaller message ids are moved earlier in the list of messages in the bu You can specify a custom ordering based on any part of the message by passing an eventOrderer function: ```ts -const models = new Models({ +const modelsClient = new ModelsClient({ ably, eventBufferOptions: {bufferMs: 100, eventOrderer: (a, b) => { ... }} }); diff --git a/docs/concepts/event-correlation.md b/docs/concepts/event-correlation.md index 3b19224a..cc377484 100644 --- a/docs/concepts/event-correlation.md +++ b/docs/concepts/event-correlation.md @@ -2,98 +2,16 @@ *Event correlation* describes how the Models SDK matches unconfirmed, optimistic events with their confirmations received from the backend. -- [Event Correlation](#event-correlation) - - [Comparator function](#comparator-function) - - [Custom comparator](#custom-comparator) - - [Correlating by UUID](#correlating-by-uuid) - - [Default Comparator](#default-comparator) +## Mutation IDs and comparator function -## Comparator function +Optimistic and confirmed events are correlated using the mutation ID. +The mutation ID is set on the optimistic event when it is created, and is expected to be set on the confirmed event emitted by your backend. -Internally, the Models SDK uses a *comparator* function to compare an optimistic event with a confirmation event. - -```ts -export type EventComparator = (optimistic: Event, confirmed: Event) => boolean; -``` +That means it's your responsibility to pass the mutation ID to your backend when making the mutation, so that it can be included in the confirmed event. Whenever the library receives an event from the backend, it will compare it with the pending optimistic events using this function to determine whether the event is confirming a pending optimistic event. -For example, the library exposes a simple comparator the compares events based on equality: - -```ts -export const equalityComparator: EventComparator = (optimistic: Event, confirmed: Event) => { - return ( - optimistic.channel === confirmed.channel && - optimistic.name === confirmed.name && - isEqual(optimistic.data, confirmed.data) - ); -}; -``` - -## Custom comparator - -You can specify your own comparison function to use by providing it as an option when instantiating the library: - -```ts -const models = new Models({ ably }, { - defaultComparator: myComparator, -}); -``` - -Instead of setting a global default, you can also specify a comparator on a specific mutation: - -```ts -await model.$register({ - $mutate: { - myMutation: { - func: myMutation, - options: { - comparator: myComparator, - }, - } - } -}); -``` - -Or even on a specific invocation of your mutation function: - -```ts -await model.mutations.myMutation.$expect({ - events: myExpectedEvents, - options: { - comparator: myComparator, - }, -})(); -``` - -## Correlating by UUID - -A more robust approach is to correlate events by a specific identifier on the event. To achieve this, the Models SDK always sets a `uuid` property on the expected events before they are passed to your mutation function. These events can be accessed from your mutation function via the special `context` parameter which has the following type: - -```ts -export interface MutationContext { - events: Event[]; -} -``` - -The context is the first argument passed to your mutation functions: - -```ts -async function myMutation(context: MutationContext, foo: string) { - const result = await fetch(`/api/post`, { - method: 'PUT', - body: JSON.stringify({ - content, - events: context.events, // pass the events to your backend - }), - }); - return result.json(); -} -``` - -Note that the context is provided to your mutation function automatically by the library; you do not need to provide it when invoking your mutation via `model.mutations`. - -Your backend now has access to the expected events, which contain a `uuid` field. Your backend should publish it's confirmation event with a special `x-ably-models-event-uuid` field in the `extras.headers`: +Your backend should publish it's confirmation event with a special `x-ably-models-event-uuid` field in the `extras.headers`: ```ts channel.publish({ @@ -101,7 +19,7 @@ channel.publish({ data: { /* ... */ }, extras: { headers: { - 'x-ably-models-event-uuid': event.uuid, + 'x-ably-models-event-uuid': mutationId, }, }, }); @@ -115,15 +33,3 @@ export const uuidComparator: EventComparator = (optimistic: Event, confirmed: Ev }; ``` -## Default Comparator - -If not otherwise specified, the library will use the default comparator which will compare events by `uuid` if it is available, otherwise it falls back to the equality comparator: - -```ts -export const defaultComparator: EventComparator = (optimistic: Event, confirmed: Event) => { - if (optimistic.uuid && confirmed.uuid) { - return uuidComparator(optimistic, confirmed); - } - return equalityComparator(optimistic, confirmed); -}; -``` diff --git a/docs/concepts/usage.md b/docs/concepts/usage.md index 984da1f3..19103fe6 100644 --- a/docs/concepts/usage.md +++ b/docs/concepts/usage.md @@ -1,11 +1,10 @@ - # Usage - [Usage](#usage) - [Model](#model) - [Sync Function](#sync-function) - - [Update Functions](#update-functions) - - [Mutation Functions](#mutation-functions) + - [Merge Functions](#merge-functions) + - [Optimistic Events](#optimistic-events) - [Subscriptions](#subscriptions) - [Model Lifecycle](#model-lifecycle) @@ -27,16 +26,15 @@ type Post = { Note that we pass in the shape of our data model (`Post`) as a type parameter. -> In addition, we pass in a type parameter that defines the set of available [*mutations*](#mutation-functions) on the data model, described below. - -Once your model is instantiated, we need to make some *registrations* which link up the model to your application code: +In order to instantiate the model, we need to pass some *registrations* which link up the model to your application code. ```ts -await model.$register({ - $sync: /* ... */, - $update: /* ... */, - $mutate: /* ... */, -}); +await modelsClient.models.get({ + name: /* ... */, + channelName: /* ... */, + $sync: /* ... */, + $merge: /* ... */, +}) ``` Let's take a look at each of these registrations in turn. @@ -59,19 +57,19 @@ async function sync() { return result.json(); } -await model.$register({ - $sync: sync, +await modelsClient.models.get({ + $sync: /* ... */, /* other registrations */ -}); +}) ``` The model will invoke this function at the start of its lifecycle to initialise your model state. Additionally, this function will be invoked if the model needs to re-synchronise at any point, for example after an extended period of network disconnectivity. -## Update Functions +## Merge Functions -> *Update functions* tell your model how to calculate the next version of the data when a mutation event occurs. +> *Merge functions* tell your model how to calculate the next version of the data when a mutation event is received. -When changes occur to your data model in your backend, your backend is expected to emit *events* which describe the mutation that occurred. The Models SDK will consume these events and apply them to its local copy of the model state to produce the next updated version. The way the next state is calculated is expressed as an *update function*, which has the following type: +When changes occur to your data model in your backend, your backend is expected to emit *events* which describe the result of the mutation that occurred. The Models SDK will consume these events and apply them to its local copy of the model state to produce the next updated version. The way the next state is calculated is expressed as an *update function*, which has the following type: ```ts type UpdateFunc = (state: T, event: OptimisticEvent | ConfirmedEvent) => Promise; @@ -79,98 +77,58 @@ type UpdateFunc = (state: T, event: OptimisticEvent | ConfirmedEvent) => Prom i.e. it is a function that accepts the previous model state and the event and returns the next model state. -> An event can be either *optimistic* or *confirmed*. Events that come from your backend are always treated as *confirmed* events as they describe a mutation to the data which has been accepted by your backend. Soon, we will see how the Models SDK can also emit local *optimistic* events which describe mutations that have happened locally but have not yet been confirmed by your backend. +> An event can be either *optimistic* or *confirmed*. Events that come from your backend are always treated as *confirmed* events as they describe a the result of a mutation to the data which has been accepted by your backend. Soon, we will see how the Models SDK can also emit local *optimistic* events which describe mutations that have happened locally but have not yet been confirmed by your backend. Confirmed events are emitted from your backend over Ably *[channels](https://ably.com/docs/channels)*. A model can consume events from any number of Ably channels. > **Note** > Ably's [Database Connector](https://github.com/ably-labs/adbc) makes it easy to emit change events over Ably transactionally with mutations to your data in your database. -An update function is associated with a *channel name* and an *event name*; the model will invoke the update function whenever it receives an event with that name on the associated channel. +A model is associated with a *channel name*; the model will invoke the update function for all events it receives on the associated channel. -For example, we might define an update function which runs when we get an `update` event on the `posts` channel, where the payload is the new value of the post's `text` field: +## Optimistic events -```ts -async function onPostUpdated(state, event) { - return { - ...state, - text: event.data, // replace the previous post text field with the new value - } -} +The Models SDK supports *optimistic updates* which allows you to render the latest changes to your data model before they have been confirmed by the backend. This makes for a really quick and snappy user experience where all updates feel instantaneous! -await model.$register({ - // update functions are registered on the model using a - // mapping of channel_name -> event_name -> update_function - $update: { - 'posts': { - 'update': onPostUpdated, - }, - }, - /* other registrations */ -}); -``` - -## Mutation Functions +> *Optimistic events* allow you to make local changes to your data optimistically that you expect you backend will later confirm or reject. -> *Mutation functions* allow you to make changes to your data in your backend and tell your model what changes to expect. +To apply optimistic changes to your model, you can call with `.optimistic(...)` method on your model passing the optimistic event. +You are also responsible for applying the change to your backend directly. +You should pass the mutationId that you included on your model to help correlate optimistic events applied locally and confirmed events emitted by your backend. -In order to make changes to your data, you can register a set of *mutations* on the data model. It is a simple function which accepts any input arguments you like and returns a promise of a given type. - -Typically, you would implement this as a function which updates the model state in your backend over the network. For example, we might have a REST HTTP API endpoint which updates the data for our post: - -```ts -async function updatePost(context: MutationContext, content: string) { +```typescript +// your method for applying the change to your backed +async function updatePost(mutationId: string, content: string) { const result = await fetch(`/api/post`, { method: 'PUT', - body: JSON.stringify({ content }), + body: JSON.stringify({ mutationId, content }), }); return result.json(); } -await model.$register({ - $mutate: { - updatePost, - }, - /* other registrations */ -}); -``` - -> Note that the first `context` argument gives your mutation function access to the expected events that were passed to the mutation function when it was invoked. See [Event Correlation](./event-correlation.md). - -The backend API endpoint would then update the post data in the database and emit a confirmation event, which would be received and processed by our `onPostUpdated` update function described [above](#update-functions). - -It is possible to configure options on each mutation, for example to set a specific timeout within which the model will expect the mutation to be confirmed: +// optimistically apply the changes to the model +const [confirmation, cancel] = await model.optimistic({ + mutationId: 'my-mutation-id', + name: 'updatePost', + data: 'new post text', +}) -```ts -await model.$register({ - $mutate: { - updatePost: { - func: updatePost, - options: { timeout: 5000 }, - } - }, - /* other registrations */ -}); +// apply the changes in your backend +updatePost('my-mutation-id', 'new post text') ``` -You can now invoke the registered mutation using the `mutations` handle on the model: +> See [Event Correlation](./event-correlation.md). -```ts -const result = await model.mutations.updatePost('new value'); -``` +This optimistic event will be passed to your merge function to be optimistically included in the local model state. -The Models SDK supports *optimistic updates* which allows you to render the latest changes to your data model before they have been confirmed by the backend. This makes for a really quick and snappy user experience where all updated feel instantaneous! To achieve this, when you invoke a mutation you can specify a set of *optimistic events* which will be applied to your data model immediately: +You are responsible for making changes to the persisted model state, typically you would implement this as a function which updates the model state in your backend over the network. For example, we might have a REST HTTP API endpoint which updates the data for our post, as in the `updatePost(...)` method above. -```ts -const [result, confirmed] = await model.mutations.updatePost.$expect([ - { channel: 'post', name: 'update', text: 'new value' }, // optimistic event -])('new value'); +The backend API endpoint would then update the post data in the database and emit a confirmation event, which would be received and processed by our merge function described [above](#merge-functions). -await confirmed; -// optimistic update was confirmed by the backend! -``` +The model returns a promise that resolves to two values from the `.optimsitic(...)` function. -When a mutation is invoked in this way, the mutation returns not only the result from calling the mutation function but also an additional promise that will resolve when the expected events are ultimately confirmed by the backend. +1. A `confirmation` promise that resolves when the optimistic update has been confirmed by that backend (or rejects if the update is rejected). +2. A `cancel` function that allows you to rollback the optimistic update, for example if your backed HTTP API call failed. ## Subscriptions diff --git a/src/Model.ts b/src/Model.ts index 07e2e77e..d13b2f69 100644 --- a/src/Model.ts +++ b/src/Model.ts @@ -21,11 +21,7 @@ import type { OptimisticEvent, ConfirmedEvent, } from './types/model.js'; -import { - MODELS_EVENT_REJECT_HEADER, - MODELS_EVENT_UUID_HEADER, - type OptimisticEventOptions, -} from './types/optimistic.js'; +import { MODELS_EVENT_REJECT_HEADER, MODELS_EVENT_UUID_HEADER, OptimisticEventOptions } from './types/optimistic.js'; import EventEmitter from './utilities/EventEmitter.js'; /** diff --git a/src/ModelsClient.test.ts b/src/ModelsClient.test.ts index e66f021a..10366039 100644 --- a/src/ModelsClient.test.ts +++ b/src/ModelsClient.test.ts @@ -28,9 +28,12 @@ describe('Models', () => { expectTypeOf(modelsClient.ably).toMatchTypeOf(); }); - it('getting a model with the same name returns the same instance', ({ ably, channelName }) => { + it('getting a model with the same name returns the same instance', async ({ + ably, + channelName, + }) => { const modelsClient = new ModelsClient({ ably }); - const model1 = modelsClient.models.get({ + const model1 = await modelsClient.models.get({ name: 'test', channelName: channelName, $sync: async () => 'initial data', @@ -38,7 +41,7 @@ describe('Models', () => { }); expect(model1.name).toEqual('test'); - const model2 = modelsClient.models.get({ + const model2 = await modelsClient.models.get({ name: 'test', channelName: channelName, $sync: async () => 'initial data', diff --git a/src/ModelsClient.ts b/src/ModelsClient.ts index fbc9f55d..31d75b95 100644 --- a/src/ModelsClient.ts +++ b/src/ModelsClient.ts @@ -44,7 +44,7 @@ export default class ModelsClient { * @param {registration} registration - The name, channelName, sync and merge functions for this model. * The names and funcitons will be automatically setup on the model returned. */ - get: (registration: registration) => { + get: async (registration: registration) => { const name = registration.name; const channelName = registration.channelName; @@ -59,7 +59,7 @@ export default class ModelsClient { const options: ModelOptions = { ...this.options, channelName: channelName }; const model = new Model(name, options); - model.$register(registration); + await model.$register(registration); this.modelInstances[name] = model; return model as Model;