Shapes
Shapes are the core primitive for controlling sync in the ElectricSQL system.
Local apps establish shape subscriptions. This syncs data from the cloud onto the local device using the Satellite replication protocol, into the local embedded database. Once the initial data has synced, queries can run against it.
The Electric sync service maintains shape subscriptions and streams any new data and data changes onto the local device. In this way, local devices can sync a sub-set of a larger database for interactive offline use.
Local apps ask the server for a specific set of related data that gets synced to the device. The central Postgres instance will often have too much data to fit on any one device, so shapes allow us to sync only a required subset of data onto the device. There is a balance between local data availability and storage usage on the device that is unique to every application, and shapes allow you to balance those properties while maintaining required guarantees.
Shapes are being actively developed, there is a list of limitations at the bottom of the page.
What is a shape?
A shape is a set of related data that's synced onto the local device. It is defined by:
- a root table, in your electrified DDL schema, such as
projects
- a query, with where clauses used to filter the rows in that table
- an include tree, a directed acyclic graph of related data
For example, this sync
call causes a project and all its issues, their comments and comment authors to sync atomically onto the local device:
await db.projects.sync({
where: {
id: 'abcd'
},
include: {
issues: {
include: {
comments: {
include: {
author: true
}
}
}
}
}
})
Once the data has synced onto the local device, it's kept in sync using a shape subscription. This monitors the replication stream and syncs any new data, updates and deletions onto the local device for as long as the shape's subscription and retention semantics define.
Syncing shapes
ElectricSQL syncs shapes using the sync
client function. You can sync individual rows:
await db.projects.sync({
where: {
id: 'abcd'
}
})
You can sync filtered sets of rows:
await db.projects.sync({
where: {
status: 'active'
}
})
You can sync deep nested shapes, such as an individual project with its related content and some filtering over that content
await db.projects.sync({
where: {
id: 'abcd'
},
include: {
issues: {
where: { status: { not_in: ['backlog'] } },
include: {
comments: {
include: {
author: true
}
}
}
}
}
})
Filter clauses
You can filter requested data by specifying a where
clause while requesting a shape. where
clauses can filter top-level rows, as well as any rows that come from following a one-to-many relation (see last example above). Shape where
clauses may be either an object, or a string. They may only reference the columns on the filtered table itself.
It's important to note that many-to-one relations cannot be filtered that way (e.g. you cannot do comments: { include: { author: { where { id: true }}}}
), because if a
target row would be filtered out, that would break FK consistency on the client.
This filter will apply to both initial data, and ongoing replication, with related data being pulled in or removed accordingly. For example, for {projects: {where: {status: 'public'}, include: {issues: true}}}
shape, if there is an update that changes a project status to public
, then that project will be synced to the device along with all the issues in that newly visible project. If a project changes status to, say, private
, then the issues from that project will be gone from the device along with the project itself. There are some nuances to that part of the sync, which are described under the move-in lag section.
Object where
clause
The TypeScript type of the object accepted in .sync
call may be wider than what is actually supported.
When filtering using an object, you can use Prisma-like syntax. Direct value comparisons are supported, as well as the following filtering functions:
equals
in
not
notIn
lt
lte
gt
gte
startsWith
endsWith
contains
They can be combined as multiple parts of the same clause, and explicitly using:
AND
OR
NOT
For example:
await db.projects.sync({
where: {
status: {
in: ['active', 'pending']
}
}
})
See the API -> Typescript client docs for more details.
String where
clause
String where
clause is currently only supported as top-level filtering. This will be fixed in a future release.
You can use string where
clauses that are plain SQL comparisons. You can use the usual PostgreSQL syntax, although not all functions are supported. The filtering expression is expected to evaluate to a boolean value. The columns of the table you're filtering can be referenced using this.
prefix:
await db.projects.sync({
where: "this.status IN ('active', 'pending')"
})
This allows for more flexibility as the Prisma-like does not support the all possible expressions supported by the Electric sync engine. However, you need to be more careful with interpolating your own values in the string to ensure that PostgreSQL would accept for the expected type.
await db.projects.sync({
where: "lower(this.status) ~~ 'pending%'"
})
Full current list of supported Postgres functions and operators is easiest found in code, but here's a gist:
- Types (input, comparison): all numerics,
bool
,uuid
,text
, all date/times apart fromtimetz
- Numeric functions: all basics + bitwise operations
- String functions: concatenation,
LIKE
/ILIKE
- Date functions: comparisons, intervals,
AT TIME ZONE
functions
More basic type filtering support is being added, as well as more functions over those types.
The current filtering implementation does not support non-deterministic functions. For example it's not possible to use the PostgreSQL now()
function in the where clause.
Promise workflow
The sync
function resolves to an object containing a promise:
- the first
sync()
promise resolves when the shape subscription has been confirmed by the server (the sync service) - the second
synced
promise resolves when the initial data load for the shape has synced onto the local device
// Resolves once the shape subscription
// is confirmed by the server.
const shape = await db.projects.sync()
// Resolves once the initial data load
// for the shape is complete.
await shape.synced
If the shape subscription is invalid, the first promise will be rejected. If the data load fails for some reason, the second promise will be rejected.
If you establish a shape subscription that has already synced its initial data, awaiting shape.synced
will always resolve immediately. I.e.: imagine that you re-run the code above on subsequent page loads. Awaiting shape.synced
a second time will only ensure that the initial shape load is complete. It does not ensure that the replication stream has caught up.
Data loading
Data synced onto the local device via a shape subscription appears atomically in the local database. I.e.: it all loads within a single transaction.
You can query the local database at any time, for example, establishing a Live query at the same time as initiating the shape sync. The query results will initially be empty (unless data is already in the local database) and then will update once with the full set of data loaded by the shape subscription.
For example, this is OK:
const MyComponent = () => {
const { db } = useElectric()!
const { results } = useLiveQuery(db.projects.liveMany())
// console.log('MyComponent rendering')
// console.log('results', results)
const syncProjects = async () => {
// console.log('syncProjects')
const shape = await db.projects.sync()
// console.log('shape subscription confirmed')
await shape.synced
// console.log('shape data synced')
}
useEffect(() => {
syncProjects()
}, [])
return <h1>{results.length}</h1>
}
Or you can explicitly wait for the sync, for example, by conditionally rendering a child component once shape.synced
has resolved:
const MyContainer = () => {
const { db } = useElectric()!
const [ready, setReady] = useState(false)
// console.log('MyContainer rendering')
// console.log('ready', ready)
const syncProjects = async () => {
// console.log('syncProjects')
const shape = await db.projects.sync()
// console.log('shape subscription confirmed')
await shape.synced
// console.log('shape data synced')
setReady(true)
}
useEffect(() => {
syncProjects()
}, [])
if (!ready) {
return null
}
return <MyComponent />
}
const MyComponent = () => {
const { db } = useElectric()!
const { results } = useLiveQuery(db.projects.liveMany())
// console.log('MyComponent rendering')
// console.log('results', results)
return <h1>{results.length}</h1>
}
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 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.
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:
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.
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
method has a wider type signature in TypeScript than what's really supported. In particular,limit
,sort
and other keywords underinclude
should not be there.DELETE
of the top row on the client without having synced all the children may not result in aDELETE
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.
Foreign key and query consistency
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.
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.
For example, with this this shape:
await db.projects.sync({
where: { status: 'active' },
include: { issues: true }
})
If a project were to have its status changed to active
, the client would now be eligible to see it, along with its issues. However, the project will be synced to the local database before the issues.
Please note that while we're going to improve developer experience around this aspect of syncing, this "lag" is part of the current consistency model.
We expect to add client-side hooks notifying of such move-in events so that UI has more information to act upon.
Future capabilities
Shape-based sync is under active development. We aim soon to provide additional capabilities and primitives, such as the ones outlined below.
Segmentation indexes
When a shape subscription is established, the initial data load (the rows in the shape) are fetched by a Postgres query. However, ongoing changes from the replication stream are mapped into shapes by the Electric sync service.
This limits the expressiveness of shape filter clauses to the matching capabilities of the sync service (as opposed to the full query capabilities of Postgres). Segmentation indexes are a mechanism to pre-define virtual columns as user-defined functions in the Postgres database, in order to support abitrary query logic in shape definitions.
Subscription and retention semantics
Currently all shapes are always live. However, in some cases, you may want to make ephemeral queries and keep results available for offline use without always keeping them live and up-to-date. Subscription semantics will allow you to configure whether a shape subscription is maintained to keep the data synced in a shape live or not.
Currently all synced data is retained forever until explicitly deleted. Retention semantics will provide a declarative API to control data retention and deterministic behaviour when there's contention for storage resources.
Discovered shapes
Many applications need to provide listings and search capabilities. You don't always want to sync the whole search index or table onto the local device to support this.
Discovery queries allow you to "discover" the results of a query run server-side on Postgres and to then subscribe to the query results. This allows you to keep a shape subscription live for a search or listing query.
Derived shapes
Derived shapes are shapes that are derived from a component hierarchy. They are analogous to the way that fragments are aggregated into a top-level query by the Relay compiler.