@gravlens/workflow
v0.4.6
Published
Lens port of @convex-dev/workflow (verbatim source; convex/convex-test/convex-helpers/@convex-dev/workpool aliased to lens/lens-test/lens-helpers/@gravlens/workpool)
Readme
Lens
A drop-in replacement for the Convex API, powered by SpacetimeDB 2.0.
Lens publishes the exact convex package surface: same import paths, same
exported names, same type signatures. A Convex app compiles and runs against
Lens with no source changes. Underneath, your functions execute in a
SpacetimeDB TypeScript module instead of the Convex backend.
How it works
Lens is three layers:
lens: theconvex-compatible npm package, covering all eight entry points (convex/values,convex/server,convex/react,convex/browser,convex/nextjs,convex/react-auth0,convex/react-clerk, and the root).lens-runtime: the server core, bundled together with yourconvex/folder into a SpacetimeDB module. It provides the Convex execution model: function registry and dispatch, document store, scheduler, file storage, auth, and components.lens-cli: replicates the Convex CLI (dev,deploy,codegen,run,env,logs,data,import,export,mcp, and more) by driving the Spacetime CLI plus its own local codegen.
Usage
A new app and an app migrating off Convex cloud adopt Lens the same way. Source code does not change; a new app simply skips the data steps.
Starting a new app from one of Convex's templates? create-convex-lens does
the steps below for you, and refuses templates that need something Lens
cannot run yet (Convex Auth signs JWTs inside the isolate, which needs
WebCrypto Lens does not implement; third-party providers such as Clerk are
fine):
npm create convex-lens@latest my-app # react-vite by default
npm create convex-lens@latest my-app -- -t nextjs
npx create-convex-lens@latest --list # the template shorthandsSee packages/create-convex-lens/README.md.
Export your data from the deployment you are leaving: download a backup from the Convex dashboard (Settings, then Backups), or run
npx convex export --path snapshot.zipwhile still on theconvexpackage. Both produce the same snapshot ZIP format.Install Lens under the
convexname through an npm alias, so imports,_generatedcode, frontend hooks, and tooling keep working unchanged:npm i convex@npm:@gravlens/convexComponent ports install the same way:
npm i @convex-dev/workflow@npm:@gravlens/workflowPorted components: workflow, workpool, batch-worker, crons, rate-limiter, action-retrier.
Push your app to a Lens deployment:
npx convex devThis publishes the module, runs codegen, and writes
.env.local(CONVEX_DEPLOYMENT,CONVEX_URL,LENS_SERVER_URL), so a frontend readingVITE_CONVEX_URL/CONVEX_URLpicks up the new deployment the same way it picked up the cloud one.Import the snapshot:
npx convex import snapshot.zipDocuments keep their
_idand_creationTime; Lens binds the source deployment's table numbers so existing document references keep resolving. Importing into a deployment that already has data takes--replaceor--replace-all, with the same confirmation summary as upstream.Re-create environment variables, which do not travel in the snapshot:
npx convex env set OPENAI_API_KEY ...for each one. Auth config (auth.config.ts) lives in your source tree, so it comes along in step 3, and crons begin scheduling on the new deployment automatically because they are code, not data.
What does not transfer yet (the import warns or errors rather than silently
dropping data): file storage (_storage entries are skipped with a warning;
re-upload files after migrating), components (a snapshot containing
_components/ is rejected until Phase 5; ported components start with fresh
state), and in-flight scheduled functions (Convex's export does not include
_scheduled_functions on either platform).
The migration flow is tested end to end against a real Convex cloud export:
the checked-in AI Town snapshot in
packages/lens-cli/test/fixtures/aitown-convex-snapshot.zip is imported
into a live deployment by packages/lens-cli/test/import_live.test.ts.
Note on versions: each Lens package mirrors the version of the package it
stands in for, so a project asking for convex: ^1.44.0 is satisfied by
@gravlens/[email protected], and anything that peer-depends on convex
installs cleanly. Packages with no upstream counterpart
(@gravlens/convex-runtime) carry their own version. Inside this
repository, apps consume Lens through pnpm workspace aliases instead of
npm (see examples/).
Your data in SpacetimeDB
Every table in your defineSchema is a real SpacetimeDB table of the same
name, so it shows up in the SpacetimeDB dashboard and in spacetime sql:
spacetime sql -s <server> <database> 'select id, author, body from messages'Each top-level field whose validator is a single scalar kind (v.string(),
v.id(), v.number(), v.int64(), v.boolean(), v.bytes(), literals, and
same-kind unions such as string enums) is a native column, named in the
host's snake_case (userId becomes user_id). Nested objects, arrays,
v.any(), v.null() and mixed unions live in the table's rest column as
JSON. Convex's _id and _creationTime are the id (primary key) and
creation_time columns.
SpacetimeDB can add a column to a populated table but never retype or drop
one, so a column keeps its kind for the life of the database. The CLI records
every column it ever created in lens.columns.json at the project root and
only ever appends to it; commit that file with your schema. A field whose
validator later changes kind keeps its old column (empty from then on) and its
values move into rest, with no migration and no change to what your
functions read. When a migration is finished and you want the column to
reflect the new kind, npx convex columns rebuild <table> exports the table,
recreates it with the current layout across two publishes, and re-imports the
documents with their ids preserved (the table is empty in between).
Tables that are not in the schema (schema-optional mode, imports of
undeclared tables) and Convex's system tables stay in the _lens_docs table as
JSON documents. Table names starting with lens_, _lens_, or st_ are
reserved.
Tests
Conformance is oracle-based: tests compare Lens behavior against the real
convex npm package, convex-test, and the vendored convex-backend
sources. Intentional behavioral differences are recorded in the
deviation register.
| Suites | Files | Tests | | |---|---|---|---| | From Convex upstream, running unchanged | 42 | 366 | ✅ passing | | Lens-authored | 80 | 936 | ✅ passing | | Total | 122 | 1,302 | ✅ |
The upstream suites are copied verbatim from the vendored component
repositories (workpool, workflow and its example app, rate-limiter,
batch-worker, crons, action-retrier); only package plumbing changes (npm
aliases, vitest environment), and they execute on the real Lens runtime
through the lens-test harness. The Lens-authored suites cover the rest:
convex-js API oracles against the real convex package, runtime core
units, CLI codegen oracles and live CLI flows, live conformance of the
shared scenario handlers on a real SpacetimeDB server vs the convex-test
oracle, component conformance and live e2e for every port, and the example
apps end to end. Live suites need pnpm dev:server and skip themselves
when it is not running.
Benchmarks
Lens is benchmarked with the
Convex tutorial chat, deployed unchanged
on both stacks and driven through each product's public ConvexHttpClient /
ConvexClient. Latency is p50 in milliseconds (lower is better), throughput
is calls per second (higher is better), and the fastest number on each row,
across all three columns, is bold. The Lens and Spacetime columns note how
much faster than Convex they are wherever they win against it. The
Spacetime column is the native baseline: the same app functionality
written as an idiomatic native SpacetimeDB module (no Convex API, no
Lens layer, driven through the native spacetimedb SDK), showing the
platform's ceiling. Lens reads sit at the native call floor, Lens writes carry a
fraction of a millisecond of runtime overhead on the per-table storage layout, and
Lens reactive delivery (one server-side re-materialization pushed to
every subscriber) stays 7x to 630x ahead of Convex at every subscriber
count and overtakes even the native baseline's per-socket delivery at
1,024+ subscribers. All numbers are one consolidated 2026-08-23 run set
on one machine (Windows 11, i9-14900K); full tables with p95s,
methodology, and analysis are in BENCHMARKS.md.
A second, system-level benchmark runs the verbatim
a16z-infra/ai-town backend (one of
the largest open-source Convex apps: a scheduled-action game engine, vector
search, crons, an HTTP router) unchanged on both stacks: pnpm bench:aitown
locally, pnpm bench:aitown:cloud hosted, pnpm bench:aitown:show for a
recorded 8x-world showcase. The game plays identically on both. Under load
the stacks split: chat bursts commit 12x to 25x faster on Lens, engine
updates reach all subscribers within a millisecond or two at every fan-out,
and at 256 concurrent movement writers Convex's write-contention storm
starves the game to 0.10 steps/s while Lens processes every input at 20/s.
Like the tutorial bench, a third backend bounds the platform itself: the
same game rebuilt as an idiomatic native SpacetimeDB module
(packages/lens-bench/stdb-aitown-app) with a scheduled-reducer engine
loop and no input queue, so an input applies in the transaction that
carries it. Full tables and analysis are in
BENCHMARKS.md.
Runs can also be recorded: pnpm bench:tutorial -- --record attaches one
passive monitor per backend (subscribed through the same public client an
app would use, in its own process) and records the raw data it sees:
every message arrival, the bench phase markers, and arrival rates, all
timestamped. A run lands in packages/lens-bench/recordings/<session>/
as data .json files plus one self-contained combined page that replays
the data over a native chat (or town map) view; the session's four pages
(chat/AI Town, local/cloud) link each other, and all can be restyled and
rebuilt without re-running the benches. Recorded runs carry that one
extra subscriber, so published numbers come from unrecorded runs. The
committed recordings are hosted at
theultdev.github.io/lens (stable
links: latest/chat-local.html, latest/chat-cloud.html,
latest/aitown-local.html, latest/aitown-cloud.html); every push to
master republishes the site with the recordings as-is (nothing is
re-benched on CI) plus a freshly built static Vitest report of the
whole workspace at
/tests/ (pnpm test:html;
live suites need the local dev server and are marked skipped there).
Local
Self-hosted, all backends on localhost: the open-source
convex-local-backend (SQLite, the self-host default) vs a local
SpacetimeDB 2.8.2 server.
Single calls (50 messages of history, p50):
| Operation | Convex | Lens | Spacetime | |---|---|---|---| | Query, cache hit | 0.5 ms | 0.4 ms (1.1x faster) | 0.4 ms (1.1x faster) | | Mutation | 8.0 ms | 0.6 ms (13x faster) | 0.4 ms (20x faster) |
Mutation throughput (sustained):
| Load | Convex | Lens | Spacetime | |---|---|---|---| | 8 independent clients | 267 calls/s | 2447 calls/s (9.2x faster) | 2532 calls/s (9.5x faster) | | 32 independent clients | 925 calls/s | 2787 calls/s (3.0x faster) | 9897 calls/s (11x faster) | | 64 independent clients | 1250 calls/s | 1735 calls/s (1.4x faster) | 17514 calls/s (14x faster) | | 128 independent clients | 1261 calls/s | 1508 calls/s (1.2x faster) | 30444 calls/s (24x faster) | | 256 independent clients | 1104 calls/s | 1381 calls/s (1.3x faster) | 13550 calls/s (12x faster) | | 512 independent clients | 1380 calls/s | 1633 calls/s (1.2x faster) | 14662 calls/s (11x faster) | | One tab, burst of 8 | 129 calls/s | 1154 calls/s (8.9x faster) | 3865 calls/s (30x faster) | | One tab, burst of 32 | 127 calls/s | 2785 calls/s (22x faster) | 15032 calls/s (119x faster) | | One tab, burst of 64 | 65 calls/s | 3111 calls/s (48x faster) | 32013 calls/s (493x faster) | | One tab, burst of 128 | 79 calls/s | 4956 calls/s (62x faster) | 36549 calls/s (460x faster) | | One tab, burst of 256 | 96 calls/s | 3525 calls/s (37x faster) | 60309 calls/s (628x faster) | | One tab, burst of 512 | 107 calls/s | 2991 calls/s (28x faster) | 64814 calls/s (603x faster) |
Interactive streams (N tabs each sending a mutation every ~15 ms, about 65/s per tab; p50 / p95 per mutation):
| Load | Convex | Lens | Spacetime | |---|---|---|---| | 1 streaming tab | 10.2 / 199 ms | 1.9 / 3.4 ms (5.4x faster) | 2.0 / 3.8 ms (5.1x faster) | | 8 streaming tabs | 2,435 / 4,425 ms | 4.4 / 10.6 ms (553x faster) | 5.0 / 16.9 ms (487x faster) | | 32 streaming tabs | 5,948 / 10,611 ms | 62.1 / 156 ms (96x faster) | 7.7 / 17.1 ms (772x faster) |
(Convex saturates at 213 to 446 calls/s against the 520 to 2,100/s offered; the seconds-long latencies are queueing. Lens and Spacetime keep up at every count.)
Cache-miss reads as history grows (query re-executed after a write, p50):
| History | Query:Convex | Query:Lens | Query:Spacetime | Search:Convex | Search:Lens | Search:Spacetime | |---|---|---|---|---|---|---| | 1,200 messages | 82.6 ms | 10.0 ms(8.3x faster) | 0.9 ms(92x faster) | 27.7 ms | 12.2 ms(2.3x faster) | 0.9 ms(31x faster) | | 5,000 messages | 82.1 ms | 4.3 ms(19x faster) | 0.8 ms(103x faster) | 17.3 ms | 15.0 ms(1.2x faster) | 0.8 ms(22x faster) | | 10,000 messages | 128 ms | 5.6 ms(23x faster) | 0.7 ms(183x faster) | 15.3 ms | 13.3 ms(1.2x faster) | 0.8 ms(19x faster) |
Reactive delivery (mutation sent to every subscriber updated, p50):
| Subscribers | Convex | Lens | Spacetime | |---|---|---|---| | 8 | 99.3 ms | 14.3 ms (6.9x faster) | 1.1 ms (90x faster) | | 32 | 110 ms | 9.6 ms (11x faster) | 1.0 ms (110x faster) | | 256 | 1,301 ms | 10.1 ms (129x faster) | 6.7 ms (194x faster) | | 1,024 | 5,139 ms | 12.1 ms (425x faster) | 24.8 ms (207x faster) | | 2,048 | 10,261 ms | 16.4 ms (626x faster) | 68.5 ms (150x faster) |
(A single subscriber among the 2,048 still sees the update in 6.3 ms on Lens vs 111 ms on Convex, and in 1.7 ms on native SpacetimeDB.)
Cloud
Hosted, same client machine, WAN round-trips included: Convex Professional S256 vs SpacetimeDB maincloud (Lens and the native Spacetime baseline both run on maincloud). Both origins sit in North Virginia (maincloud resolves directly to Ashburn servers; Convex runs in AWS us-east-1 behind Cloudflare anycast), so the comparison is geographically fair; raw ping and TCP timings are in BENCHMARKS.md. These tables are the 2026-08-24 re-roll, the first with server-side group commit (concurrent callers' mutations coalesce into shared transactions under pressure). Convex's 128-512-client rows measured well below its best recorded session this time (it has reached 880 to 1,515 calls/s there before; hosted Convex concurrency swings widely run to run) - Lens's 1,268-1,426 calls/s beats both readings at 128 and 256 and sits at 0.94x the best-ever Convex 512 row.
Single calls (50 messages of history, p50):
| Operation | Convex | Lens | Spacetime | |---|---|---|---| | Query, cache hit | 87.2 ms | 61.4 ms (1.4x faster) | 62.3 ms (1.4x faster) | | Mutation | 101 ms | 61.7 ms (1.6x faster) | 57.6 ms (1.8x faster) |
Mutation throughput (sustained):
| Load | Convex | Lens | Spacetime | |---|---|---|---| | 8 independent clients | 75 calls/s | 136 calls/s (1.8x faster) | 145 calls/s (1.9x faster) | | 32 independent clients | 306 calls/s | 512 calls/s (1.7x faster) | 550 calls/s (1.8x faster) | | 64 independent clients | 577 calls/s | 889 calls/s (1.5x faster) | 1066 calls/s (1.8x faster) | | 128 independent clients | 283 calls/s | 1268 calls/s (4.5x faster) | 2108 calls/s (7.5x faster) | | 256 independent clients | 149 calls/s | 1355 calls/s (9.1x faster) | 3857 calls/s (26x faster) | | 512 independent clients | 342 calls/s | 1426 calls/s (4.2x faster) | 7413 calls/s (22x faster) | | One tab, burst of 8 | 34 calls/s | 35 calls/s (1.0x faster) | 150 calls/s (4.4x faster) | | One tab, burst of 32 | 49 calls/s | 418 calls/s (8.6x faster) | 578 calls/s (12x faster) | | One tab, burst of 64 | 59 calls/s | 663 calls/s (11x faster) | 1009 calls/s (17x faster) | | One tab, burst of 128 | 58 calls/s | 1146 calls/s (20x faster) | 2032 calls/s (35x faster) | | One tab, burst of 256 | 59 calls/s | 1254 calls/s (21x faster) | 3463 calls/s (58x faster) | | One tab, burst of 512 | 66 calls/s | 1450 calls/s (22x faster) | 5692 calls/s (86x faster) |
Interactive streams (N tabs each sending a mutation every ~15 ms, about 65/s per tab; p50 / p95 per mutation):
| Load | Convex | Lens | Spacetime | |---|---|---|---| | 1 streaming tab | 138 / 174 ms | 56.7 / 284 ms (2.4x faster) | 80.6 / 108 ms (1.7x faster) | | 8 streaming tabs | 1,005 / 1,828 ms | 60.7 / 71.0 ms (17x faster) | 82.5 / 110 ms (12x faster) | | 32 streaming tabs | 1,475 / 2,621 ms | 372 / 430 ms (4.0x faster) | 84.3 / 113 ms (18x faster) |
(At 32 tabs Convex saturates at 1,254 calls/s against ~2,100/s offered; Lens (2,353/s) and the native baseline (2,360/s) keep up. Lens's dispatch lane is batch-amortized on both sides now: each tab's queue coalesces client-side, and the server's group commit merges concurrent tabs' batches into shared transactions.)
Cache-miss reads as history grows (query re-executed after a write, p50):
| History | Query:Convex | Query:Lens | Query:Spacetime | Search:Convex | Search:Lens | Search:Spacetime | |---|---|---|---|---|---|---| | 1,200 messages | 185 ms | 128 ms(1.4x faster) | 102 ms(1.8x faster) | 210 ms | 139 ms(1.5x faster) | 101 ms(2.1x faster) | | 5,000 messages | 194 ms | 120 ms(1.6x faster) | 104 ms(1.9x faster) | 216 ms | 134 ms(1.6x faster) | 103 ms(2.1x faster) | | 10,000 messages | 185 ms | 122 ms(1.5x faster) | 104 ms(1.8x faster) | 210 ms | 134 ms(1.6x faster) | 104 ms(2.0x faster) |
Reactive delivery (mutation sent to every subscriber updated, p50):
| Subscribers | Convex | Lens | Spacetime | |---|---|---|---| | 8 | 111 ms | 79.9 ms (1.4x faster) | 55.2 ms (2.0x faster) | | 32 | 118 ms | 71.1 ms (1.7x faster) | 54.7 ms (2.2x faster) | | 256 | 1,385 ms | 75.1 ms (18x faster) | 57.0 ms (24x faster) | | 1,024 | 5,329 ms | 78.8 ms (68x faster) | 79.5 ms (67x faster) | | 2,048 | 10,470 ms | 85.8 ms (122x faster) | 141 ms (74x faster) |
(A single subscriber among the 2,048 still sees the update in 72.1 ms on Lens vs 230 ms on Convex, and in 60.9 ms on native SpacetimeDB. The native large-N rows open one real WebSocket per subscriber from one machine; Lens multiplexes one invalidation socket per client process, which is why it can edge native at the very top of the ladder.)
Summary
- Writes: SpacetimeDB's commitlog commits a Lens mutation in under a millisecond locally; the Convex backend's SQLite path takes about 8 ms. A tab that fires many mutations at once has its queue coalesced into one Lens transaction, so bursts approach 5,000 calls/s locally and reach 1,450 hosted. Convex's client pipelines the burst over its WebSocket session, but the server still executes one session's mutations sequentially, about 34 to 129 calls/s on both targets.
- Reads: both stacks cache query results server-side, so cache hits sit
at each platform's call floor (a near-tie locally, RTT-bound hosted).
Lens cache misses stay flat as history grows because index scans are
bounded; the Convex backend's misses grow with table size. Cache-miss
search re-execution is flat too (12-15 ms vs Convex's 15-28) after the
lazy-search round: a
.take(n)search fetches n + 1 candidate docs instead of every ranked candidate, and dictionary scans are bounded to the index. - Reactivity: a Lens mutation re-executes the subscribed queries it invalidated once, inside its own transaction, and the results ride the same broadcast as the commit. Delivery stays flat in subscriber count: all 2,048 subscribers see an update in 16.4 ms locally and 85.8 ms hosted vs 10.3 to 10.5 s on Convex, and any single subscriber among them sees it at close to single-subscriber latency.
- Concurrent writers, hosted: this used to be where Convex S256 won (880 vs 647 calls/s at 128 clients, 1,366 vs 687 at 256, in the 2026-08-23 set). Group commit closed it: under sustained pressure independent callers' mutations park in a queue and a shared drain transaction executes the backlog in one pass, so hosted Lens now sustains 1,268 to 1,426 calls/s at 128 to 512 clients - ahead of every recorded hosted Convex row except its best 512 reading (1,515, a 0.94x). The gate that arms it is the module's own measured dispatch cost, so fast local deployments keep the lower-latency always-inline path. The native baseline (2,108 to 7,413 reducer calls/s) still marks the remaining headroom.
- The native ceiling: the Spacetime column bounds what the platform can do. Lens reads sit at it; writes carry about 0.2 ms of Lens-runtime overhead each (0.6 vs 0.4 ms locally), invisible behind WAN RTT for single hosted calls and visible under sustained concurrency; reactive delivery pays a few ms per update for the server-side re-execution that keeps it one query run regardless of subscriber count (9.6 vs 1.0 ms to all 32 locally), stays flat as subscribers grow, and overtakes native's per-socket delivery from 1,024 subscribers up.
Repository layout
| Path | Contents |
|---|---|
| packages/lens | The convex-compatible package |
| packages/lens-runtime | SpacetimeDB module core: dispatcher, doc store, scheduler, storage, components |
| packages/lens-cli | The lens/convex CLI |
| packages/lens-test | convex-test-compatible test harness |
| packages/lens-helpers | Ported subset of convex-helpers |
| packages/lens-stdb-test | Live conformance suite against a real SpacetimeDB server |
| packages/lens-bench | Real-world benchmark: tutorial chat on the real Convex backend vs Lens vs a native SpacetimeDB baseline |
| packages/components/* | Component ports (@gravlens/*) |
| examples/ | Sample apps: Convex tutorial, quickstart, workflow example, AI Town (the a16z-infra/ai-town backend, verbatim) |
| vendor/ | Pinned upstream sources used as reference and test oracles |
| docs/plan/ | Development plan details (see PLAN.md for the index) |
Development
Requires Node >= 22.18 and pnpm. Works on Windows, Linux, and macOS.
pnpm install
pnpm dev:server # isolated local SpacetimeDB server on port 3111
pnpm typecheck
pnpm test # run every suite, one process per package
pnpm test:ui # Vitest UI in the browser, whole workspace
pnpm test:html # static HTML test report (reports/vitest/) for CI
pnpm bench # runtime micro-benchmarks against the live server
pnpm bench:tutorial # real-world benchmark: Convex vs Lens vs native STDBpnpm test is the canonical gate: it runs each package's suite in its own
process. pnpm test:ui and pnpm test:html run the same suites through the
root Vitest config (vitest.config.ts), which registers
every package as a Vitest project, so the UI can browse, filter, and re-run
any test in the workspace. The HTML report is a static site (serve it with
npx vite preview --outDir reports/vitest, or publish it as a CI artifact).
Live suites skip themselves when the dev server is not running, so both
commands work offline out of the box.
License and attribution
Lens ports convex-js (Apache-2.0) source near-verbatim with attribution; see
packages/lens/NOTICE. The backend semantics are reimplemented on SpacetimeDB,
not copied. examples/ai-town vendors
a16z-infra/ai-town (MIT) verbatim
with its license file; see that example's README.
