Legend-State plugin for local-first
I'm investigating making a Legend-State plugin and can use some help with some things.
The first thing is it ideally works the same way when given a ConvexClient from React or from vanilla JS. So the first thing I'm trying to set up is to use
onUpdate
when given either a ConvexClient or a ReactConvexClient. But it seems like they work very differently - in React it internally uses a ConvexReactClient.watchQuery whereas the script tag example uses ConvexClient.onUpdate.
Looking through the source iIt looks like ConvexReactClient and ConvexClient share a BaseConvexClient base class, and I could use subscribe
on there. But is that the best way to go? Seems like I'd miss out on some of the nice behavior from the higher level wrappers. I can do that to get an initial prototype working but I'd like to make sure it works in the correct Convex way.70 Replies
Thanks for posting in <#1088161997662724167>.
Reminder: If you have a Convex Pro account, use the Convex Dashboard to file support tickets.
- Provide context: What are you trying to achieve, what is the end-user interaction, what are you seeing? (full error message, command output, etc.)
- Use search.convex.dev to search Docs, Stack, and Discord all at once.
- Additionally, you can post your questions in the Convex Community's <#1228095053885476985> channel to receive a response from AI.
- Avoid tagging staff unless specifically instructed.
Thank you!
I fixed the tanstack query example, which you can find here: https://github.com/FleetAdmiralJakob/convex-with-legendstate but it has no typesafety or something similiar which is why I think we need this plugin directly and not through something like tanstack query.
I added a new branch where it falls apart again while I'm trying to add the mutation too (but if you get the direct plugin working of course this isn't an problem anymore): https://github.com/FleetAdmiralJakob/convex-with-legendstate/tree/added-mutation
Error:
Hm, yeah you may want to make a wrapping function that takes the value and passes it into the convext mutation? But I think we're probably better off using the client directly. For one thing we need to be able to use it outside of React so making it work only in React will only get us part of the way there.
Lemme grab an esteemed engineer to have a look. @ian would you mind taking a peek at this when you get a chance?
Had not heard of legend state, this is very intriguing
This new version with the local-first features been in preview and alpha for almost a year - it's pretty hard to get right π
. I just released it to beta this week and Supabase just made a video about it: https://x.com/thorwebdev/status/1838949075703476438.
We have a few sync plugins and I'd like to make a first-class Convex plugin for it too π
https://legendstate.com if you want to check it out
Oh you're the dev, sweet! A Convex plugin would be awesome, I'd def try that out. Especially considering the react native support. Motion looks awesome too.
Early prototype but it seems to work for listing at least. Then this should be all you need for an observable that's realtime subscribed to a Convex query:
If you can help me with the differences between ConvexClient and ReactConvexClient it should be pretty easy to hook up mutations and we'll be good to go π
Just catching up @Jayzus, what's the goal here for supporting ReactCovexClient as well as ConvexClient?
Oh sorry, I should have provided more context.
Observables can be created in React with a hook or outside of React. And in both cases they would need to pass the client in to
syncedConvex
so that it can run queries/mutation, subscribe to realtime, etc... So syncedConvex
needs to work in both of these scenarios:
Ah got it, thanks. Yeah there's some SSR behavior in particular that happens in the ConvexReactClient and ConvexClient, but not in the BaseConvexClient.
Reading a bit, Legend State sounds pretty cool!
To accept both I think you'd need to write the code to check for each an use an onUPdate from a watch from the ConvexReactClient
Ok, that's certainly an option, to just check whether it's a ConvexReactClient and do the behavior differently. So would doing a watch with the ConvexReactClient look like this?
Is there a batching API in Legend State?
If you're not batching (or if the batching is automatic for callbacks run in a single microtask queue clear) then I'm not sure there are any differences
Oh let's see, in the React client if you subscribe to the same query twice you will not get an automatic callback on the second subscription, so you may want to check for the value synchronously and call it
in the simple client you will, and if you don't want this then the simple client won't work
There is a
batch
api yeahif you end up batching all the updates form a convex client together then I think the only change will be whether the second+ time the same query is subscribed to if that triggers an update or not
let's see, anything around error handling
I think these both throw, there's no onError equivalent in the ConvexReactClient
Would
onUpdate
return multiple results or be called many times with single results? Multiple is better for us.
And throw is better too, so that's good πmultiple values for the same query?
at successive points in time?
I mean if a query returns multiple results, it would return an array of all of the results?
The alternative (which firebase does) is call the "child changed" callback for each row
onUpdate will always return on a single result (which could be an array, but it's the result of running the query at a single point in time)
Ah yeah Convex is less record-oriented than Firebase, one subscription to a query results in only one (or zero) update each time the underyling data changes, the new value
And how about mutations? Does that work similarly in the ConvexClient vs. ConvexReactClient? I haven't had a chance to look into that part yet, but while you're here it'd be great to get the expert advice π
Yeah that's real similar, both of them can throw
there's anothe rthing called an action and it's similar to a mutaiton
from the client's perspective an action is a non-replayable (non-deterministic) mutation; a mutation is a database transaction but an action is sometihng that can run for up to 10 minutes and is more like a normal server endpoint, it can do anything
Would create/update/delete usually use mutations?
Yeah, if it's just a change to the database that always fits in a mutation
Would love to chat about client sync with you sometime, in Convex often what looks like a single record to the client might actually be the result of combining several records on the backend
it's more relational that e.g. Firebase
Ah cool yeah I'd love to learn more! But it's almost midnight here in France so another time π
We have plugins for multiple sql providers and Firebase (Firestore coming soon) so I think that should be fine... But ther's probably some specifics to go deep into π
Also one last question - does the convex client handle error handling? Like if a query fails will it retry? And is onChange resilient through going offline and back online?
that was a perfect opportunity for "it's dinnertime and I'm french"
Legend-State has its own retrying mechanism that we can use but if you have a good retry/resilience system then I prefer to disable ours and defer to yours π
Haha well I'm American, I'm just in France for a couple months π
ah! well nvm then lol
If a query fails it will not retry until something changes that means maybe it won't fail next time: queries are reactive, our runtime is deterministic, and we track readsets so we know that if it failed the first time would definitely fail again if we retried it. Until some data that it read (a database record) changes, and then we retry the query βΒ this is all assuming a client is subscribed to the query.
And is onChange resilient through going offline and back online?While offline the WebSocket will attempt to reconnect. onChange will not fire unless a new update is recieved from the server
Ah ok so if a mutation fails just because of being offline it will retry when it comes back offline?
Perfect, sounds like we can disable our retry and let yours handle it π
Btw @ballingt I think I'll see you at React Advanced?
I'm not going to be there! π¦
Oh no! I saw you on the speaker list, so you're doing a remote talk?
I'm recording my remote talk this weekend, going biking with some friends that weekend
Oh lol nice π
Would be great to chat and get up to speed on Legend sometime
That would be great! I'd like to get more up to speed on Convex too π
I see that _creationTime is set automatically - is there an easy way to set an _updateTime automatically when data is mutated? Legend State has a feature that uses the local cache to be able to sync only diffs since the previous sync. That relies on being able to query for "items updated after x".
you can set it up yourself but there is no in-build _updateTime
Hmm, we may have a bit of a blocker. Because Legend-State usage is to set the state first and have the state sync itself automatically, and because it needs to work while offline, we need to be able to generate an id client side and push that to the server. So a mutation would look like this:
We set the new object into state which then internally triggers the mutation in the ConvexClient.
Is that possible somehow?
there is something similar on the optimistic updates: https://docs.convex.dev/client/react/optimistic-updates
Optimistic Updates | Convex Developer Hub
Even though Convex queries are completely reactive, sometimes you'll want to
Hmm, this bit "replaced with the true ID once the server assigns it" makes our observability and caching nearly impossible. On successful sync we'd need a complex migration to adjust old ids to new ids in the in-memory objects, the observable subscriptions, the queued pending changes, the persisted cache.
Is there any way we can create ids client-side in a way that they won't change after sync? If not I think we're at an impasse π¦
Would it work to use a separate ID field, index on that, and just ignore the Convex-created ID completely?
but this would have to be set up manually right? like everyone that wants to use convex + legend needs to add a new field to their tables
or we wrap the db schema and add it programatically to every table
...perhaps? I'm admittedly still pretty new to Convex, so I'm not sure if that wrapping option is doable.
yeah, as far as I know its pretty doable but idk if we want that option
There is not currently, @ballingt would know if there's any possibility of this.
I tried that with a different database a while ago and it got very messy - all of the queries would need to be updated to take that new id field instead of the normal one, all of the features where you can find items by id don't work, etc... It's doable but creates a bunch of weird edge cases that make it not really work π
They ended up adding a feature to optionally include an id when creating
And then we use the same id creation library (ksuid) client side to match the server side id format
how do the other databases you support handle it?
Keel is the one I was just referring to - it supports including id in the creation functions
Supabase also allows id in creation functions
Firebase has no concept of id anyway so we can just set it
Pocketbase (we don't have a plugin yet) supports id in create too
We've explored client-generated IDs and the ID format was designed to make this possible. But it's not a feature now.
Lots of developers use another id here, there are only a few APIs that preference the build-in
_id
field, and if you stick an index on another field that's just as good modulo a few util functions.
In Convex documents aren't necessarily the entity that should be synced. A client-facing entity might be composed of multiple documents from multiple tables, including fields that clients aren't allowed to read. A sync solution may not involve syncing individual documents. Unlike Firebase table queries, Convex Query Functions can join multiple tables to produce entities that dont' exist in any one DB table.
Syncing arbitrary documents with Legend-State might not be the way to go here since e.g. an updatedAt is not instead you might want custom schema. If the only thing you need it client-side ID creation that's something within reach, but if I were you I'd do this in user space where you can implement whatever semantics you need for sync instead of relying on system properties.So the way Legend-State normally works with database sync plugins is that you'd have an observable synced with a
list
function to get rows of items, and that fills out the observable with all of the items. Then if you modify a value in any one of the items it calls an update
function to update that item in the database. And if you add a new item to the object (with a new key) it calls a create
function to add it to the database.
That does kind of restrict you to having the list
/create
/update
functions all working with the same type of element. If list
functions return a document plus extra data, it is possible for the create
/update
function to pick out the fields to update that it cares about, but that would require some extra user code.
The way we normally handle this in our apps is that functions which return joined or embedded data just don't sync both directions. And that's totally fine π
Does that vibe with the way people use Convex?
And for ids, Legend State can just operate on an "id" field instead of "_id" with no problem. The complexity (if any) would be more on the Convex side. How hard is it to make functions with their own "id" field work normally? Like how would a patch work - would they have to be given the custom "id" as args, do a lookup to get the real "_id" from it, and then patch with the real id?
But if we can just have some example code in our docs to follow some pattern to use a custom "id", and you're happy with that, then that's all good on our end πBtw if you want to learn more about legend-state's sync there's a great new video: https://youtu.be/xkWvDG6uEfk
Jack Herrington
YouTube
Legend State v3: Local first sync AND fastest React State manager!
Legend State version 3 is in beta and while it remains the fastest state manager (according to its docs) it now supports local first sync. So your NextJS, Remix, React and Vite applications can work offline and then sync up with the server again when they are online!
This video was sponsored by Infinite Red, find out more about their React Nati...
I think the bigger problem is that in Convex you have queries. These queries can be formatted in unlimited different ways. It's not like that you are directly calling the DB but the query is calling the DB and can fo everything it wants on top of what the DB outputs
Think about a Convex query like an API endpoint instead of a DB connection
That should be fine if the user creates crud queries for use in observables. And of course you could still use more complex queries outside of the observables.
But basically for Legend-State's model we need to be able to list records, update them in the observable, and then call functions to create/update with that data.
Does it seem like that's a reasonable constraint, at least for the part of the data that people want to use through observables in a local-first way? If not then Legend State may not be a good fit π’. And then I think you'd need a more custom solution for doing local-first using Convex's mutations?
hmmmmmm, this seems like we would need to share the code of the mutations and queries with the client so the client can update the observable the same way that the db will get updated on the backend
@FleetAdmiralJakob π π π yeah, this is how I've thought about we'd be implementing our own offline eventually -- there is some plain ol' typescript
merge<T>(siblings: T[]): T {}
function that the backend mutation calls
the frontend can call it as well
and then you can create standard implementations for well-known CRDTs etc
I should look at legend stateSometimes this is how people use Convex! Convex is both a database that only server functions can access and the API produced by those functions. And sometimes what those queries and mutations do is return a list of all the records in a table, create new items in the table, and modify existing items.
For some data models this would would well. For others there's be joined and otherwise munged (only some fields returned, calculated fields added, etc.) but there are always tables backing these.
The full sync implementations we've thought about have focused on the latter, because most access control is done at the latter level. Records are pretty bare-bones; there are no built-in RLS for example. But something at the table/record level could be a good step.
Performance-wise it's the same, but the syntax is more verbose:
I hope the more custom thing here could just be "Legend state, but it only operates on specific tables set up for it"
You can write helpers that define tables with constraints (the schema definition happens in code so you can write wrappers, transform a table, etc.) and with something like the CRUD helpers to generate the appropriate endpoints. Without endpoints all tables are private in Convex, but you can generate some sensible ones that provide exactly the API Legend State expects.
The programmability of Convex a distinguishing factor, it's a toolkit for writing a backend. It comes wiht zero public access out of the box and you declare APIs exposing things you want to expose. You can compose these APIs (called queries, mutations, and actions) with code, so it's no trouble to generate a set of routes per resource that provide given API.
@Jayzus I just submitted my React Advanced talk yesterday, am digging into TanStack integration tomorrow and headed to SquiggleConf tomorrow night βΒ but this is exciting stuff, would love to chat. Like Jamie I need to learn more here.
Wow you've been busy! When you've got some time let's have a call and chat about the right way to go? I'm not in a rush, still have lots of work to do to go from beta to full release π
Im watching this thread like a hawk. For context there has also been plenty of discussion on offline-first statemanagement for convex in the past. This is one of the more recent discussions: https://discord.com/channels/1019350475847499849/1242127900052819998/1242127900052819998
Hi @Jayzus just tried to get the convex + legendstate via tanstack query running but I think the types that are accepted by
mutationFn
are wrong. mutationFn
accepts the type MutationFunction<any[], void>
. But not even the example from the docs is working with this type:
Why should the type of the variables be void
?
Maybe I'm understanding something wrong hahaha. But maybe if you have a lil bit time left you can look into this.
Already thank you for even trying to get Convex + Legend working β€οΈI think that would mean that it didn't infer the types from the query correctly. Can you share a full example?
I would like to make sure that the Query plugin is working in general but for convex it'll be much better to base on the crud plugin.
@Jayzus I just copied the whole example (no convex at all) and the type errors are away but still it doesnt make sense that the variables are of type
void
or am I wrong here?
this is why I think I cant get my wrapper working without ignoring ts and eslint rules because convex is expecting proper strongly typed arguments/variables but what legend is giving is a type of voidAs I tried with different setups its always only changing the first generic argument of
MutationFunction
but never the second one this is always void
hey @Jayzus, I can chat in the next few days if you're free! @ballingt and I were chatting offline, and it'd be super cool to get convex + legendstate working together.
Sure, that'd be great! I'm free from 11am to 8pm in France time. Today or any other day, let me know π
Types should be fixed in beta.5
awesome! will send you a DM.
Thank you. β€οΈ I will share my wrapper if I get it working. So we have at least something until the first class support comes out. Hyped to see what the united powers of both projects can create.
Ok, nvm I think this wrapper will be far more complex because:
1. You have the limitation that the mutation has to return the same thing as the related query. This is not trivial and without a proper type hint if you don't return the same thing in the query as in the mutation this could lead to a lot of confusion. To get these type hints I think we would need like a
legendstateFunctions
wrapper for every pair of one convex mutation + one convex query.
You could live without this wrapper and do it manually but as I said it just spreads confusion.
The real deal breaker for an approach via the tanstack query plugins of both is this:
2. As Legend passes the whole observable as an variable it makes it really difficult to diff out the changes that are between the DB and the LegendState because in Convex the preferred of passing an argument is to only send the one bit that has changed.
So I think it will way better to wait for the first class support instead of building all these fragile things around although you could just try it for the sake of it.I talked to @sujayakar earlier and we have a good plan. I'll have something for you to play with today.
Ok I've got a first pass at the plugin. It works both in React and outside, it supports a list function and mutation functions, and all the offline first stuff works. It requires an
id
field in the table schema. And it requires that create
and update
have the same parameters as list
, or at least any fields that you might change need to be included in update
. Otherwise I think everything should just work. Please let me know if I missed anything or you find any issues. And I will gladly accept PRs :).
I'm making the plugin in a separate repo for now for easier testing, then I'll move it to the legend-state repo when it's ready.
The plugin: https://github.com/jmeistrich/convex-legendstate/blob/main/src/lib/convex.ts
Example usage: https://github.com/jmeistrich/convex-legendstate/blob/main/src/Chat/Chat.tsx
And some things I need help with @sujayakar :
1. How to do patch with a custom id field?
2. How to do changesSince with the token
3. Should we support a "get" function which would return a single result? Or should we always expect a list function that returns an array?
4. Can we use the crud helpers from "convex-helpers/server/crud" or do we need something different since we need the "id" field?
5. Should it always use onChange or have an option to run only once?
6. Docs: How to setup crud functions
7. Docs: How to setup your database with custom "id" functionThis is really cool!
Might I suggest that instead of
id
which might get confusing given Convex's _id
we call it clientSyncKey
or something like that? Or even better have it as a config param on the plugin so users can choose whatever key they want to use themselves?
Really looking forward to see this develop πawesome stuff! checking it out now -- I'll send up a PR w/some discussion for those questions in the description.