Skip to content

Commit

Permalink
docs: unsubscribe, sync status, and keyed sync (#1331)
Browse files Browse the repository at this point in the history
  • Loading branch information
icehaunter committed Jun 10, 2024
1 parent 2ffa6ee commit e07bad2
Show file tree
Hide file tree
Showing 4 changed files with 150 additions and 22 deletions.
91 changes: 87 additions & 4 deletions docs/api/clients/typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ The Electric client exposes the following interface:
```ts
interface ElectricClient<DB> {
db: ClientTables<DB> & RawQueries;
sync: SyncManager
connect(token?: string): Promise<void>;
disconnect(): void;
}
Expand All @@ -69,6 +70,11 @@ type Statement = {
sql: string;
args?: BindParams;
};

interface SyncManager {
unsubscribe(keys: string[]): Promise<void>
syncStatus(key: string): SyncStatus
}
```

The Electric client above defines a property for every table in our data model: `electric.db.users`, `electric.db.projects`, etc.
Expand Down Expand Up @@ -166,7 +172,7 @@ Shapes define the portion of the database that syncs to the user's device.
Initially, users are not subscribed to any shape.
Tables can be synced by requesting new shape subscriptions.

### `sync`
### `sync` method

Once we are connected to the sync service we can request a new shape subscription using the `sync` method on database tables.
We can sync a single table:
Expand Down Expand Up @@ -207,10 +213,87 @@ await sync2;
This approach differs from the previous code snippet because the data for the `projects` and `users` tables
is delivered independently, whereas, in the previous example they are deliver together as one database transaction.

When a table is not yet synced, it exists on the device's local database but is empty.
If you try to read from an unsynced table you will get empty results and a warning will be logged:
#### Specifying a `key` for sync

A `.sync()` call may have a key property that is unique for the entire application. It can be used to check the status of a sync, to unsubscribe,
or to change the subscription (i.e. subscribe to the new shape and unsubscribe from the old one).

In the following example, first a subscription is established with one filter, then it's changed to use another filter. After the last `await` is resolved, rows with status `active` but with `author_id` not `1` will be removed from the device as after an unsubscribe.

```ts
const { db, sync } = useElectric()
const { key, synced } = await db.projects.sync({
where: "this.status = 'active'",
key: 'allProjects'
})
await synced

const { synced: newSynced } = await db.projects.sync({
where: "this.author_id = '1'",
key: 'allProjects'
})
await newSynced
```

`.sync()` method always returns a `key` property. If one was provided, the returned one matches the provided one, and if not then it's deterministically generated based on the shape itself.

### `sync` top-level property

Every Electric client contains a `sync` property that allows control over the sync process and status.

```ts
const { db, sync } = await electrify()
```

### `sync.unsubscribe` method

Using the key returned from the `.sync()` call you can cancel a previously established subscription using the `.unsubscribe(keys)` method on the top-level `sync` object.

Unsubscribing will cause any rows on the device that were part of this subscription (but not any others) to be removed. Returns a promise that resolves as soon as the server accepts the unsubscribe.

```ts
const { db, sync } = await electrify()
const { key, synced } = await db.projects.sync()

sync.unsubscribe([key])
```

### `sync.syncStatus` method

Using the key returned from the `.sync()` call you can check the status of a subscription. It returns either `undefined` if that key is not known, or an object with a status of a subscription.

```ts
export type SyncStatus =
| undefined
| { status: 'active'; serverId: string }
| { status: 'cancelling'; serverId: string }
| {
status: 'establishing'
serverId: string
progress: 'receiving_data' | 'removing_data'
oldServerId?: string
}
```
The `serverId` and `oldServerId` properties should be considered opaque, but can be useful in debugging and development, and seeing if a server subscription behind a given key has changed or not.
Example:
```ts
const { db, sync } = await electrify()

sync.syncStatus('testKey') // undefined, since subscription with this key is not known

const { key, synced } = await db.projects.sync({ key: 'testKey' })
sync.syncStatus(key) // { status: 'establishing' }

await synced
sync.syncStatus(key) // { status: 'active' }

sync.unsubscribe([key])
sync.syncStatus(key) // { status: 'cancelling' }
```

> Reading from unsynced table memberships

## Queries

Expand Down
4 changes: 2 additions & 2 deletions docs/intro/sync-controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ For example, you can sync:
- a region and all its locations and their data
- a time period and all its events

Shapes are live, so if new data arrives that matches the shape, that data also syncs onto the local device. And they're dynamic, so they can change at runtime, using the [`sync`](../api/clients/typescript.md#sync) function, e.g.:
Shapes are live, so if new data arrives that matches the shape, that data also syncs onto the local device. And they're dynamic, so they can change at runtime, using the [`sync`](../api/clients/typescript.md#sync-method) function, e.g.:

<!--
Expand All @@ -57,7 +57,7 @@ Below, we have a simplified example of a project management app. There are proje
Because the data is synced onto the local app, the local app is fully functional offline. You can still navigate and engage with it without connectivity.
The shape of the data that is synced changes at runtime and is defined by a simple [`sync`](../api/clients/typescript.md#sync) call:
The shape of the data that is synced changes at runtime and is defined by a simple [`sync`](../api/clients/typescript.md#sync-method) call:
-->

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ This is a very temporary workaround and will be removed soon!

### Shapes

[Shape-based sync](../usage/data-access/shapes.md) using the [`sync()` function](../api/clients/typescript.md#sync) is currently supported with some [limitations](../usage/data-access/shapes.md#limitations-and-issues). You can ask for specific tables and rows, filter them, and follow relations. The biggest current limitation is that you cannot unsubscribe from a shape.
[Shape-based sync](../usage/data-access/shapes.md) using the [`sync()` function](../api/clients/typescript.md#sync-method) is currently supported with some [limitations](../usage/data-access/shapes.md#limitations-and-issues). You can ask for specific tables and rows, filter them, and follow relations.

### Failure modes

Expand Down
75 changes: 60 additions & 15 deletions docs/usage/data-access/shapes.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Once the data has synced onto the local device, it's kept in sync using a **shap

## Syncing shapes

ElectricSQL syncs shapes using the [`sync`](../../api/clients/typescript.md#sync) client function. You can sync individual rows:
ElectricSQL syncs shapes using the [`sync`](../../api/clients/typescript.md#sync-method) client function. You can sync individual rows:

```ts
await db.projects.sync({
Expand Down Expand Up @@ -180,7 +180,7 @@ The current filtering implementation does not support non-deterministic function

### Promise workflow

The [`sync`](../../api/clients/typescript.md#sync) function resolves to an object containing a promise:
The [`sync`](../../api/clients/typescript.md#sync-method) function resolves to an object containing a promise:

1. the first `sync()` promise resolves when the shape subscription has been confirmed by the server (the sync service)
2. the second `synced` promise resolves when the data in the shape has fully synced onto the local device
Expand Down Expand Up @@ -277,11 +277,68 @@ const MyComponent = () => {

For many applications you can simply define the data you want to sync up-front, for example, at app load time and then just code against the local database once the data has synced in. For others, you can craft more dynamic partial replication, for instance, syncing data in as the user navigates through different routes or parts of the app.

### Unsubscribe, status, and shape changes

A `.sync()` call is stateful in establishing a subscription: once executed on the client, a subscription will be active even if the original `.sync()` call is removed from the code. To interact with an established subscription, you can use a [`key` property](../../api/clients/typescript.md#specifying-a-key-for-sync) that `.sync()` call returns in order to check the status of a subscription, unsubscribe, or seamlessly change the subscription. This key can also be provided when you make the `.sync()` call to have more control.

#### Shape changes

A `key` property can be provided to the `.sync()` call. A `key` is unique across all shapes, and if a new `.sync()` call is made with the same key, the new one will be subscribed, and the old one unsubscribed. This makes `.sync()` calls more declarative while you're developing your application and figuring out which exact shape suits you best.

In the following example, first a subscription is established with one filter, then it's changed to use another filter. After the last `await` is resolved, rows with status `active` but with `author_id` not `1` will be removed from the device as after an unsubscribe.

```ts
const { db, sync } = useElectric()
const { key, synced } = await db.projects.sync({
where: "this.status = 'active'",
key: 'allProjects'
})
await synced

const { synced: newSynced } = await db.projects.sync({
where: "this.author_id = '1'",
key: 'allProjects'
})
await newSynced
```

#### Unsubscribe

Using the key returned from the `.sync()` call you can cancel a previously established subscription using a `.unsubscribe(keys)` method on the [top-level `sync` object](../../api/clients/typescript.md#sync-top-level-property):

```ts
const { db, sync } = useElectric()
const { key, synced } = await db.projects.sync()

sync.unsubscribe([key])
```

Unsubscribing will cause any rows on the device that were part of this subscription (but not any others) to be removed.

#### Sync status

If you want to check the status of a subscription, there is a `sync.syncStatus(key)` method available on the [top-level `sync` object](../../api/clients/typescript.md#sync-top-level-property).

```ts
const { db, sync } = useElectric()

sync.syncStatus('testKey') // undefined, since subscription with this key is not known

const { key, synced } = await db.projects.sync({ key: 'testKey' })
sync.syncStatus(key) // { status: 'establishing' }

await synced
sync.syncStatus(key) // { status: 'active' }

sync.unsubscribe([key])
sync.syncStatus(key) // { status: 'cancelling' }
```

## Limitations and issues

Shape-based sync is under active development, and we're aware of some issues with it. We're working on fixing the bugs and lifting limitations as we go.

- [`.sync`](../../api/clients/typescript.md#sync) method has a wider type signature in TypeScript than what's really supported. In particular, `limit`, `sort` and other keywords under `include` should not be there.
- [`.sync`](../../api/clients/typescript.md#sync-method) method has a wider type signature in TypeScript than what's really supported. In particular, `limit`, `sort` and other keywords under `include` should not be there.
- `DELETE` of the top row on the client without having synced all the children may not result in a `DELETE` on the server and the row will be restored
- Recursive and mutually recursive tables are not supported at all for now. A foreign key loop will prevent the shape subscription from being established.
- Shape unsubscribe is not available, which means any shape subscription established by calling `.sync()` (in development in particular) is going to be statefully persisted regardless of code changes.
Expand All @@ -290,18 +347,6 @@ Shape-based sync is under active development, and we're aware of some issues wit

ElectricSQL maintains foreign key consistency both in the PostgreSQL central database, and in the local database on the client. To achieve it, the server will automatically follow any many-to-one relation in the requested shape. For example, if there are projects each with an owner and related issues, requesting all projects will also ensure that users who are owners of those projects are available on the device too. However, related issues won't show up on the device unless explicitly requested.

#### Updating shapes

:::danger Potential foot-gun in development
We're working to fix this limitation
:::

Once a subscription is established, it remains statefully in the local database even when you change the code. For example, doing `db.projects.sync({ where: { id: 1 }})`, starting the application, then changing the code to `db.projects.sync({ where: { id: 2 }})` will result in **2 subscriptions** established, with both projects synced to the device. We're working on lifting this limitation.

#### Unsubscribe not available

Related to the previous heading, removing a subscription to an existing shape is not supported yet. This will be available, lifting the previous limitation as well.

#### Move-in lag

Due to consistency considerations, when additional rows move into a shape as a result of following a one-to-many relation these will show up on the device slightly later than the parent row itself. It's important to keep this in mind when designing the UI.
Expand Down

0 comments on commit e07bad2

Please sign in to comment.