Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add models namespace and constructor on client #58

Merged
merged 5 commits into from
Oct 20, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 31 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<API-key>" });
const models = new Models({ ably });
const modelsClient = new ModelsClient({ ably });
```

### Creating a Model
Expand All @@ -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;
Expand All @@ -167,38 +167,33 @@ 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, { updatePost: typeof updatePost }>('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<Post>({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for await here

name: 'post',
channelName: 'models:posts',
$sync: sync,
$update: {
'posts': {
'update': onPostUpdated,
},
},
$mutate: {
updatePost,
},
});
$merge: merge,
})

// run the initial sync
await model.$sync()

// subscribe to live changes to the model data!
model.subscribe((err, post) => {
Expand All @@ -208,8 +203,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({
zknill marked this conversation as resolved.
Show resolved Hide resolved
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;
Expand Down
62 changes: 26 additions & 36 deletions docs/concepts/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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) => ({
Expand All @@ -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:
Expand All @@ -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.
6 changes: 3 additions & 3 deletions docs/concepts/event-buffering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}
});
Expand All @@ -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) => { ... }}
});
Expand Down
106 changes: 6 additions & 100 deletions docs/concepts/event-correlation.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,106 +2,24 @@

*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({
name: 'myEvent',
data: { /* ... */ },
extras: {
headers: {
'x-ably-models-event-uuid': event.uuid,
'x-ably-models-event-uuid': mutationId,
},
},
});
Expand All @@ -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);
};
```
Loading
Loading