@esauflores/rxdb-convex
v0.1.0
Published
RxDB replication helpers for Convex
Readme
@esauflores/rxdb-convex
RxDB ↔ Convex replication helpers.
RxDB owns the local/offline database. Convex owns authentication, authorization, validation and server persistence. This package only connects the two; it does not add a cache, sync service or schema layer.
Client setup
Call replicateWithConvex once for each RxDB collection:
import { replicateWithConvex } from "@esauflores/rxdb-convex";
const replication = replicateWithConvex(patientsCollection, {
replicationIdentifier: "patients",
pull: (checkpoint, limit) => convex.query(api.rxdb.pullPatients, { checkpoint: checkpoint ?? null, limit }),
push: (rows) => convex.mutation(api.rxdb.pushPatients, { rows }),
});The adapter is a thin wrapper around RxDB's replication engine. Defaults are:
- live replication enabled;
- 500 documents per pull batch, 100 per push batch;
- retry after 5 seconds when a request fails.
Pull is a read-only query, so a large batch is cheap. Push writes the whole batch inside one Convex transaction, so a large batch widens the window for an optimistic-concurrency conflict when the same owner pushes from two devices at once — keep it smaller.
Pass live: false, retryTime, or pullBatchSize/pushBatchSize to
override them (batchSize still works as a shared fallback for both). The
returned RxDB replication state provides the normal RxDB lifecycle methods,
including awaitInitialReplication() and cancel().
Pull flow
- RxDB calls the configured
pullfunction with its last checkpoint and a batch limit. - The application calls a Convex query with those values.
createPullHandlerclamps the limit, calls the application'slistfunction, and passes the result topageChanges.pageChangesremoves records at or before the checkpoint, keeps the newest version of eachclientId, sorts byupdatedAtthenclientId, and returns the next checkpoint.- RxDB applies the returned documents. Documents with
_deleted: trueare tombstones and are not visible in normal RxDB queries.
The default checkpoint is { updatedAt, clientId }. updatedAt must be a
stable, strictly increasing server-side version within the replication scope,
and clientId breaks ties. The application's list function must enforce
authorization and should use Convex indexes and a bounded query; do not collect
an entire table for every pull.
For a checkpoint, query both records with updatedAt > checkpoint.updatedAt
and records with the same timestamp but clientId > checkpoint.clientId.
Query both ranges with the same limit; createPullHandler merges, sorts and
pages the result.
The local fixture demonstrates this with an (ownerId, updatedAt, clientId)
index and bounded take(limit) ranges.
Push flow
- RxDB batches local writes and calls the configured
pushfunction. - The application calls a Convex mutation with those rows.
createPushHandlerfinds the master row byserverIdwhen present, or by stableclientIdfor new local rows.- Unknown server IDs and mismatched client/server identities are rejected. A missing tombstone is ignored; a missing live row without a server ID is validated and inserted.
- For an existing row, the handler compares the incoming
assumedMasterStatewith the current master version. - A mismatch returns the current master as a conflict. Otherwise the handler updates or removes the row.
Conflict resolution remains an application decision: the library returns
conflicts to RxDB but does not choose client-wins or server-wins behavior.
The default comparison checks the full client document returned by toDocument
(with serverId handled separately as identity); use isSameMasterState when
the server stores fields intentionally absent from the client document. Non-
delete updates must also advance updatedAt.
The application owns revision assignment in its database callbacks; revisions
must be ordered within the same visibility scope used by list. Run the
handler and all callbacks inside one Convex mutation so the read/check/write
sequence is atomic. The generic helper only enforces that non-delete writes
move updatedAt forward and rejects non-finite timestamps. Deletes should remain as server-side
tombstones until every relevant offline client has had time to pull them. If
tombstones are purged, the application must provide an explicit retention or
full-resync policy; a missing tombstone is not safe for clients that have not
synced yet.
Client-side conflict handling
replicateWithConvex does not configure an RxDB conflictHandler. That means
RxDB's own default applies whenever a push comes back with a conflict: it
silently discards the local write and keeps the server's version — no
merge, no error, no retry, and nothing surfaces to the app unless it
subscribes to replication.conflict$. This can happen from perfectly normal
offline use — the same user editing the same document on two devices, one of
which was offline when the other's edit landed — not just from other users
stepping on each other.
If "server always wins" isn't the behavior you want (a merge, a per-field
resolution, surfacing the conflict to the user), pass your own
conflictHandler when creating the RxDB collection. See RxDB's
replication conflict handling docs.
Server helpers
The ./server entrypoint contains the generic Convex-side helpers. The
application still owns its Convex validators, indexes, visibility rules,
uniqueness constraints, tombstone retention and database operations:
import { createPullHandler, createPushHandler } from "@esauflores/rxdb-convex/server";
const syncDocument = v.object({
clientId: v.string(),
serverId: v.optional(v.id("patients")),
ownerId: v.string(),
createdAt: v.number(),
updatedAt: v.number(),
name: v.string(),
_deleted: v.boolean(),
});
export const pullPatients = query({
args: {
checkpoint: v.union(v.null(), v.object({ updatedAt: v.number(), clientId: v.string() })),
limit: v.number(),
},
handler: async (ctx, args) => {
const owner = await requireOwner(ctx);
return createPullHandler({
list: ({ checkpoint, limit }) => queryVisiblePatients(ctx, owner, checkpoint, limit),
})(args.checkpoint, args.limit);
},
});
export const pushPatients = mutation({
args: {
rows: v.array(
v.object({
newDocumentState: syncDocument,
assumedMasterState: v.optional(syncDocument),
}),
),
},
handler: async (ctx, args) => {
const owner = await requireOwner(ctx);
return createPushHandler({
findByClientId: (clientId) => findPatientByClientId(ctx, owner, clientId),
findByServerId: (serverId) => findPatientByServerId(ctx, owner, serverId),
toDocument: toPatientSyncDocument,
insert: (document) => insertPatient(ctx, owner, document),
update: (row, document) => updatePatient(ctx, owner, row, document),
remove: (row) => deletePatient(ctx, owner, row),
validate: (document) => validatePatient(document, owner),
})(args.rows);
},
});The client is not a security boundary. Every Convex query and mutation must
derive tenant/user visibility from authenticated server context and validate
incoming documents before writing them. Use a table-specific validator such as
v.id("patients") for serverId, rather than relying on a TypeScript cast.
Development
Node and pnpm versions are pinned in mise.toml (node@24, pnpm@11) and
mirrored in package.json's packageManager field.
pnpm build # tsc → dist/, what actually ships
pnpm typecheck # src/, --noEmit
pnpm typecheck:convex # convex/, including convex/rxdb.test.ts
pnpm typecheck:test # test/
pnpm lint # oxlint, config in .oxlintrc.json
pnpm lint:fix
pnpm fmt # oxfmt, config in .oxfmtrc.json
pnpm fmt:check.github/workflows/ci.yml runs lint, fmt:check, convex codegen,
test:fast and test:real-convex, in that order, on every push and pull
request against main, using the same node/pnpm versions pinned in
mise.toml. The cheap checks and the fast tier run before the slower
real-backend one, so a broken PR fails quickly.
convex/rxdb.ts is the test fixture the suite runs against, not a
copy-paste template — in particular its clear mutation deletes every record
an owner has, with no confirmation, and its auth.config.ts points at a
local-only JWKS server. Use the "Server helpers" example above as the
production-facing reference instead.
Tests
pnpm test runs both tiers below, test:fast then test:real-convex — that's
the one to reach for when you want the actual "did everything pass" answer.
test:fast alone typechecks and runs everything through vitest, one
environment (edge-runtime) for everything, without touching a real Convex
deployment:
test/*.test.ts: the genericcreatePullHandler/createPushHandlerlogic in isolation, plus real in-memory RxDB replication (rxdb/plugins/storage-memory) against a fake pull/push pair. No Convex runtime involved; importssrc/directly, no build step needed.convex/rxdb.test.ts(withconvex-test): the actualconvex/rxdb.tsfunctions — indexes, validators,ctx.auth, owner isolation — run against an in-memory mock Convex backend. This is the fast way to catch a wrong index name or validator shape.
pnpm test:real-convex is the other, separate tier: it starts Convex's
anonymous local deployment and verifies pull, insert, update, delete and
tombstone behavior against it. Slower, and the only tier that exercises a real
deployment end to end (and the only one still importing from dist/, which is
why it builds first) — kept as its own command rather than folded silently
into test:fast because spinning up a real subprocess deployment is heavier
and less parallelizable than the mocked tier, so you may want to skip it for
a tight local dev loop while still getting it automatically from pnpm test
and CI.
Run the fast tier on save with pnpm test:watch.
convex-test needs convex/_generated/ to locate the function module root.
It's gitignored and recreated by the CLI (convex.json points the CLI at the
convex/ test backend), so a fresh clone needs one npx convex codegen (or
pnpm test:real-convex, which triggers it) before pnpm test:fast will pass
on its own.
The fixture derives ownerId from the authenticated Convex identity. Its
auth.config.ts and test key are local-only integration-test credentials, not
a production authentication setup. The signing key itself is
test/fixtures/test-auth-key.pem, committed rather than treated as a secret:
it only signs tokens for a JWKS server this same test harness also serves
locally, so it's inert and any throwaway EC private key would work the same
way.
