npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

  1. RxDB calls the configured pull function with its last checkpoint and a batch limit.
  2. The application calls a Convex query with those values.
  3. createPullHandler clamps the limit, calls the application's list function, and passes the result to pageChanges.
  4. pageChanges removes records at or before the checkpoint, keeps the newest version of each clientId, sorts by updatedAt then clientId, and returns the next checkpoint.
  5. RxDB applies the returned documents. Documents with _deleted: true are 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

  1. RxDB batches local writes and calls the configured push function.
  2. The application calls a Convex mutation with those rows.
  3. createPushHandler finds the master row by serverId when present, or by stable clientId for new local rows.
  4. 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.
  5. For an existing row, the handler compares the incoming assumedMasterState with the current master version.
  6. 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 generic createPullHandler/createPushHandler logic in isolation, plus real in-memory RxDB replication (rxdb/plugins/storage-memory) against a fake pull/push pair. No Convex runtime involved; imports src/ directly, no build step needed.
  • convex/rxdb.test.ts (with convex-test): the actual convex/rxdb.ts functions — 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.