@velopeplabs/ship-convex
v0.4.0
Published
Convex component for VeloShip: quote, buy, batch and track FedEx, USPS and UPS labels from Convex functions, with reactive mirror tables and a signed webhook receiver.
Maintainers
Readme
@velopeplabs/ship-convex
A Convex component for VeloShip, the shipping API for FedEx, USPS and UPS. Your Convex backend can quote, buy, batch, return and track labels, and the state of every shipment, tracker and batch is mirrored into Convex tables, so your queries stay reactive.
Guide: Use VeloShip from Convex walks through setup, a quote-buy-track quickstart, webhooks, batches, testing and going live.
- Writes (
createShipment,buy,createBatch,createReturn, ...) call the API through@velopeplabs/shipand upsert the result into the mirror. - Buying calls are safe to retry. They carry idempotency keys derived from their arguments, and
an unconfirmed purchase (
SHIPMENT.PURCHASE_UNKNOWN) is reconciled, never bought again. - A webhook receiver verifies signatures, drops redeliveries, and never lets an older event overwrite newer state.
- Batches, the end-of-day flow: buy up to 100 labels at once, follow progress reactively, and keep the merged labels PDF in Convex file storage.
- Test mode makes the mock carriers fail on demand, so you can exercise your error handling.
Contents: Install · Environment · Setup · Methods · Batches · Shipping rules · Webhooks · Test mode · Retries · Errors · Mirror tables · Testing your app · FAQ · Development
Install
npm install @velopeplabs/ship-convex convex// convex/convex.config.ts
import { defineApp } from "convex/server";
import veloship from "@velopeplabs/ship-convex/convex.config.js";
const app = defineApp();
app.use(veloship);
export default app;The component ships its generated code, so you don't run codegen for it.
Environment variables
Set these on your Convex deployment (npx convex env set ...). A component can't read your
environment, so the VeloShip class reads them inside your own functions, and never hands them to
the component.
| Variable | Used by | |
| --- | --- | --- |
| VELOSHIP_API_KEY | VeloShip | sk_test_... or sk_live_... |
| VELOSHIP_API_URL | VeloShip | Optional. Defaults to https://ship.vprev.dev |
| VELOSHIP_WEBHOOK_SECRET | registerRoutes | Optional. Only if you don't use ensureWebhook |
Each can also be passed in code: new VeloShip(components.veloship, { apiKey, baseUrl }) and
registerRoutes(http, components.veloship, { secret }). Use a sk_test_ key in development and
CI: test mode has its own data and mock carriers, so nothing is bought.
The key needs scopes for what you call: shipments:write covers quoting, shipments, batches and
trackers; webhooks:manage for ensureWebhook; rules:read / rules:write for the rules helpers;
settings:write for updateSettings and protection:manage for claims.
Setup
// convex/veloship.ts
import { VeloShip } from "@velopeplabs/ship-convex";
import { components } from "./_generated/api";
export const veloship = new VeloShip(components.veloship);// convex/http.ts
import { httpRouter } from "convex/server";
import { registerRoutes } from "@velopeplabs/ship-convex";
import { components, internal } from "./_generated/api";
const http = httpRouter();
registerRoutes(http, components.veloship, {
path: "/veloship/webhook", // default
onEvent: internal.orders.onShippingEvent, // optional
});
export default http;Register the webhook once per deployment and mode. ensureWebhook creates it through the API and
stores the returned signing secret inside the component, so nothing needs copying:
export const setupWebhook = internalAction({
args: {},
handler: async (ctx) =>
veloship.ensureWebhook(ctx, { url: `${process.env.CONVEX_SITE_URL}/veloship/webhook` }),
});npx convex run orders:setupWebhookThe URL must be https and publicly reachable, so this works against a cloud deployment, not
localhost. Calling it again is a no-op while the webhook exists and its secret is stored.
Methods
Methods that call the API take an action context (ctx) and mirror what they get back. The read
methods take a query context and are reactive. Pass { idempotencyKey, signal, scenario } as the last
argument of any call that takes options.
Quotes
| Method | |
| --- | --- |
| quote(params, opts?) | Rates without a shipment. Nothing is mirrored |
| compareRates(params, opts?) | Previews what createBatch would buy for the same body, without buying |
const { rates, excluded_rates, recommended_rate } = await veloship.quote({
to_address: customer,
parcel: { preset: "small_box", weight: 24 },
order: { value: "149.00", shipping_method: "Expedited" }, // for shipping rules
carrier: "ups", // optional: only this carrier, or `carrier_account_id`
sort: "cheapest",
});
for (const rate of rates) console.log(rate.carrier, rate.service, rate.rate, rate.carrier_account_id);Shipments
| Method | |
| --- | --- |
| createShipment(ctx, params, opts?) | Pass service to buy in the same call; protection, order, apply_rules, carrier all pass through |
| buy(ctx, shipmentId, { rate_id \| service, carrier?, carrier_account_id?, label_format? }) | |
| rerate(ctx, shipmentId) | Fresh rates for a created shipment |
| refund(ctx, shipmentId) | Voids the label |
| reconcile(ctx, shipmentId) | Resolves purchase_unknown |
| createReturn(ctx, shipmentId, { service?, label_format?, options? }) | Return label, addresses swapped. Mirrors it with returnOf set and refreshes the original |
| syncShipment(ctx, shipmentId) | Fetches and refreshes the mirror |
| downloadLabel(ctx, shipmentId) | Stores the label in file storage once; returns { storageId, url } |
| downloadForm(ctx, shipmentId, formType) | The same for a customs form (commercial_invoice, cn22, cn23) |
const shipment = await veloship.createShipment(ctx, {
reference: orderId,
to_address: customer,
parcel: "small_box",
service: "FEDEX_GROUND",
protection: { value: "149.00" }, // package protection; fee fixed at creation
});
const label = await veloship.downloadLabel(ctx, shipment.id);The shipment's carrier, carrier_account_id, tags, notes, applied_rules, excluded_rates,
recommended_rate and order come back typed and are stored on the mirror.
International. A parcel that crosses a customs border needs customs_info, with EasyPost's field
names. It passes through quote, createShipment and each order of createBatch. The bought
shipment's customs_info and forms (the customs documents, such as a commercial invoice) are
stored on the mirror as customsInfo and forms.
const shipment = await veloship.createShipment(ctx, {
reference: orderId,
to_address: { name: "Ada Lovelace", street1: "10 Downing St", city: "London", zip: "SW1A 2AA", country: "GB", phone: "442071234567" },
parcel: { weight: 16 },
service: "FEDEX_INTERNATIONAL_PRIORITY",
options: { duties_payer: "sender" }, // default `recipient`
customs_info: {
contents_type: "merchandise",
customs_certify: true,
customs_signer: "Ada Lovelace",
customs_items: [{ description: "Cotton T-shirt", quantity: 2, value: "40.00", weight: 8, hs_tariff_number: "6109.10" }],
},
});
for (const form of shipment.forms) {
// Already filed electronically when `submitted_electronically`; print only for a paper copy.
if (!form.submitted_electronically) await veloship.downloadForm(ctx, shipment.id, form.form_type);
}listCarriers() reports where each carrier ships under capabilities.international.
Batches
| Method | |
| --- | --- |
| createBatch(ctx, params, opts?) | Up to 100 orders, bought in the background. Mirrors the batch with every order pending |
| syncBatch(ctx, batchId) | Fetches the batch and refreshes the mirror |
| downloadBatchLabels(ctx, batchId, { partial? }) | The merged labels file in file storage; see the walkthrough |
Trackers
| Method | |
| --- | --- |
| createTracker(ctx, trackingCode, { carrier? }) | carrier defaults to the one the number's format points to |
| refreshTracker(ctx, trackerId) | |
Carriers, addresses, settings, claims, rules
Thin pass-throughs. Nothing is mirrored.
| Method | |
| --- | --- |
| listCarriers() | Each carrier with its capabilities |
| listServices(carrier?) | [{ carrier, services: [{ code, name }] }]; filter by id (ups) or name (UPS) |
| createAddress(params) / getAddress(id) | Saved addresses; verify: true adds verification and warnings. Pass the id as to_address: { id } |
| getSettings() / updateSettings(params) | Default from-address, parcel presets, protection pricing |
| createClaim(shipmentId, { reason, description? }), listClaims(params?), getClaim(id), updateClaim(id, params) | Package protection claims. Customer-filed claims arrive as claim.created events (source: "customer") |
| rules.list(), .create(), .retrieve(), .update(), .delete(), .reorder(), .test(), .draft(), .copyToLive() | The rules API |
veloship.client is the underlying VeloShipClient, for anything else the API offers (API keys,
reports, drafts, billing). The mirror doesn't see those calls.
Reads (reactive)
| Method | |
| --- | --- |
| getShipment(ctx, apiId) | |
| getShipmentByReference(ctx, reference, mode?) | Newest match; references aren't unique |
| listShipments(ctx, { status?, mode?, carrier?, carrierAccountId?, reference?, paginationOpts }) | Newest first, paginated, every filter optional |
| getTrackerByCode(ctx, trackingCode) / getTrackerByShipment(ctx, shipmentId) | |
| getBatch(ctx, batchId) | The batch with its items, in the order sent |
| listBatches(ctx, { status?, mode?, paginationOpts }) | Newest first, without items |
| getBatchItemByReference(ctx, reference, mode?) | The newest batch order carrying an order id |
| getLabel(ctx, shipmentId) / getForm(ctx, shipmentId, formType) / getBatchLabels(ctx, batchId) | { storageId, url } once downloaded |
export const tracking = query({
args: { orderId: v.string() },
handler: async (ctx, args) => {
const shipment = await veloship.getShipmentByReference(ctx, args.orderId);
return shipment && (await veloship.getTrackerByShipment(ctx, shipment.apiId));
},
});
export const upsAwaitingPickup = query({
args: { paginationOpts: paginationOptsValidator },
handler: async (ctx, args) =>
veloship.listShipments(ctx, { status: "purchased", carrier: "UPS", mode: "live", paginationOpts: args.paginationOpts }),
});carrier filters use the display name that appears on shipments (FedEx, UPS, USPS). A React
client using useQuery(api.orders.tracking, { orderId }) updates as webhooks land.
Batches walkthrough
The flow a Shopify-style shop needs: an order is paid, it joins the day's batch, the labels are
printed, tracking is written back to the order. The example app is a
runnable version, with its own orders table and tests.
1. Preview, optionally. compareRates takes the same body as createBatch, shows what each
order would buy, and what One Rate saves, without buying:
const body = {
service: "cheapest", // or "fastest", "best_value", or a service code
one_rate: "compare", // buy in a FedEx box when that is cheaper
max_delivery_days: 5,
one_rate_packages: ["FEDEX_SMALL_BOX", "FEDEX_MEDIUM_BOX"], // what you stock
shipments: orders.map((order) => ({
reference: order.orderId,
to_address: order.to,
parcel: { weight: order.weightOz },
order: { value: order.value, shipping_method: order.shippingMethod },
})),
} satisfies CreateBatchParams;
const preview = await veloship.compareRates(body);
preview.summary; // { total, difference, failed, ... }2. Create the batch. It returns at once; the labels are bought in the background.
const batch = await veloship.createBatch(ctx, body);
await ctx.runMutation(internal.orders.markBatched, { orderIds, batchId: batch.id });Each order can override service, one_rate, max_delivery_days, one_rate_packages, carrier,
carrier_account_id and apply_rules. The idempotency key comes from the body, so an action that
runs twice for the same orders creates one batch.
3. Follow it. The component mirrors the batch and every order. The API sends one batch event,
batch.completed, when every order has bought or failed, so that is when the mirror, and your
onEvent callback, hear about the result. There are no per-order events. For progress in between,
poll syncBatch, for instance from the scheduler:
export const pollBatch = internalAction({
args: { batchId: v.string(), attempt: v.number() },
handler: async (ctx, args) => {
const batch = await veloship.syncBatch(ctx, args.batchId);
if (batch.status === "processing" && args.attempt < 30) {
await ctx.scheduler.runAfter(60_000, internal.orders.pollBatch, { ...args, attempt: args.attempt + 1 });
}
},
});export const progress = query({
args: { batchId: v.string() },
handler: async (ctx, args) => veloship.getBatch(ctx, args.batchId),
});
// { status: "processing" | "completed" | "completed_with_errors", purchased, failed, pending, items: [...] }Snapshots never go backwards: a slow poll that returns after the webhook is ignored.
4. Write tracking back. In your onEvent mutation (it runs in the same transaction as the
mirror write), match batch.completed items to orders by reference:
export const onShippingEvent = internalMutation({
args: { event: v.any() },
handler: async (ctx, { event }) => {
if (event.type !== "batch.completed") return;
for (const item of event.data.items) {
if (item.status === "purchased") { /* save item.tracking_code, item.carrier, item.packaging */ }
else if (item.status === "failed") { /* flag the order: item.error.message */ }
}
},
});Failed orders don't stop the rest. Fix their address or package and put them in the next batch.
5. Print. downloadBatchLabels stores the merged file (one 4x6 page per label for PDF, or
ZPL) in Convex file storage and returns a URL:
const { url, count } = await veloship.downloadBatchLabels(ctx, batchId);It is stored once per set of labels: calling it again returns the same file. While the batch is
still processing the API refuses (BATCH.IN_PROGRESS); pass { partial: true } for the labels
bought so far, which replaces the stored file as more arrive. Print at 100% / actual size, not
"fit to page", or barcodes can fail to scan.
6. Tracking after that. Each purchased order has a shipment and a tracker. With the webhook
registered, shipment.purchased, tracker.created and tracker.updated events fill
getShipmentByReference and getTrackerByCode. Without it, call syncShipment and
refreshTracker yourself.
Shipping rules
Rules ("orders over $100 need a signature", "never ship heavy parcels with USPS") run on the server on every quote, shipment and batch order. The component only passes the inputs and keeps the results:
- Send
order: { value, currency?, tags?, shipping_method?, shipping_paid?, items? }onquote,createShipment,compareRatesand each batch order. Rules can read all of it. Nothing inordergoes to a carrier. - Send
apply_rules: falseto skip rules for a request (on a batch, for every order that doesn't set its own). - Shipments store
applied_rules,excluded_rates,recommended_rate,tagsandnoteson the mirror row (appliedRules,excludedRates,recommendedRate,tags,notes) and indata. Apickrule's choice is inrecommended_rate. - A rule can refuse an order (
RULES.BLOCKED) or leave no rate (RULES.NO_RATE), and rules beat an explicitservice(RULES.SERVICE_EXCLUDED). See Error handling.
The rules API is exposed as veloship.rules, the client's rules object. Test mode and live mode keep
separate rules.
export const addSignatureRule = internalAction({
args: {},
handler: async () =>
veloship.rules.create({
name: "Signature over $100",
when: { all: [{ field: "order.value", op: "gt", value: "100.00" }] },
then: [{ type: "set_signature", signature: "direct" }],
}),
});
// A dry run: which rules match, what they'd do, and which rates survive.
const result = await veloship.rules.test({
input: { to_address: customer, parcel: { weight: 16 }, order: { value: "149.00" } },
quote: true,
});
await veloship.rules.reorder(["rule_b", "rule_a"]);
await veloship.rules.draft({ text: "Require a signature over $100" }); // unsaved draft; needs an AI key on the serverWebhooks
registerRoutes verifies X-Webhook-Signature (HMAC-SHA256 of timestamp.body, five minute
tolerance), then applies the event in one mutation:
| Event | Mirrored |
| --- | --- |
| shipment.purchased, shipment.refunded, shipment.purchase_failed | shipments |
| tracker.created, tracker.updated | trackers |
| batch.completed | batches and batchItems |
| claim.created, claim.updated, alert.raised, webhook.test | Nothing; they reach onEvent |
- Secrets. It accepts
options.secret/VELOSHIP_WEBHOOK_SECRETand every secret stored byensureWebhook, so test and live webhooks can share one URL. - Responses.
400for a bad or stale signature or body,500if no secret is configured or the callback threw (the API redelivers),200otherwise with{ applied }. - Redeliveries. Delivery is at least once. The component records each event id and acknowledges a
repeat with
200 { applied: false, duplicate: true }, without runningonEventagain. Ids are kept for three days, longer than the API retries. - Out of order. A shipment or tracker row is never overwritten by an object with an older
updated_at. A batch row is never overwritten by a snapshot with fewer resolved orders, or byprocessingonce it iscompleted. Stale events get200 { applied: false }and skiponEvent. onEventis a mutation taking{ event }. It runs in the same transaction as the mirror write and the event record, so if it throws, all of it rolls back and the redelivery runs again.
Test mode and scenarios
The API key decides the mode: sk_test_... keys act on test data with mock carriers, sk_live_...
on live data. (A root key takes mode in the VeloShip options and defaults to live.) Every
mirrored row stores mode, so test and live data can sit in one deployment and be told apart:
await veloship.listShipments(ctx, { status: "purchased", mode: "live", paginationOpts });Webhooks are per mode: run ensureWebhook once with a test key and once with a live key.
Scenarios make the mock carriers misbehave, to test failure handling. They send the
VeloShip-Scenario header, and only with a test key: a live key never carries one, even if you set
it. See Testing with scenarios for every scenario.
// Every call from this instance
const flaky = new VeloShip(components.veloship, { scenario: "carrier_down" });
// A copy for a test
const lost = veloship.withScenario("purchase_timeout");
await lost.buy(ctx, shipmentId, { service: "FEDEX_GROUND" }); // throws PurchaseUnknownError
// One call
await veloship.quote(params, { scenario: "carrier_down@ups" }); // UPS fails, the others answer| Scenario | Try it with |
| --- | --- |
| no_rates, rates_slow, carrier_down, carrier_rate_limited | quote, and every call that quotes |
| purchase_timeout, purchase_rejected, charge_mismatch | createShipment with service, buy |
| void_rejected | refund |
| delivered_fast, exception_then_delivered, return_to_sender, lost | Pass it to the buying call, then refreshTracker |
| address_invalid, address_suite_missing | createAddress with verify: true |
Add @fedex, @ups, @usps or @mockex to limit a scenario to one carrier. A scenario that
only exists as a header is not stored with the shipment: for tracking scenarios, which need to
survive later calls, put __scenario:<name> in to_address.street2 instead.
Retries, idempotency and lost purchases
Idempotency keys. Convex doesn't retry actions by itself, but your scheduler, workpool or users
do. Every buying call (createShipment, buy, refund, createReturn, createBatch,
createClaim) sends an Idempotency-Key derived from its arguments, so running the same call again
replays the first result instead of buying twice. The API remembers keys for 24 hours.
- Pass your own with
{ idempotencyKey: "shopify-order-1042" }when you have a natural key. - Calls on an existing shipment (
buy,refund,createReturn) also fold in the mirroredupdatedAt, so once the shipment has moved, a fixed-up retry gets a fresh attempt instead of a replayed rejection. createShipmentandcreateBatchhave no earlier state to fold in. If one was rejected and you fixed the cause (a rule, a carrier account), pass a newidempotencyKeyto try again.buyon a shipment the mirror already shows as bought with that rate returns it without calling the API, so a retry after a late failure is a no-op.refundandcreateReturndo the same.
Unconfirmed purchases. If the carrier doesn't confirm a label, the API answers
502 SHIPMENT.PURCHASE_UNKNOWN and the shipment goes to purchase_unknown; a label may exist.
Never buy the same package again. The component throws a PurchaseUnknownError (a VeloShipError
with shipmentId) after refreshing the mirror, and the client never retries it:
import { isPurchaseUnknown } from "@velopeplabs/ship-convex";
try {
await veloship.buy(ctx, shipmentId, { service: "FEDEX_GROUND" });
} catch (err) {
if (!isPurchaseUnknown(err)) throw err;
// Voids an orphaned label, or finds none, and returns the shipment to "created".
const shipment = await veloship.reconcile(ctx, err.shipmentId);
// Buy again only if shipment.status === "created". The API also reconciles every 15 minutes.
}Show these in your UI with listShipments(ctx, { status: "purchase_unknown" }).
Labels. downloadLabel, downloadForm and downloadBatchLabels store a file once. Concurrent downloads, and
retries after a failure halfway, leave one file; a file that can't be attached to a row is deleted.
Error handling
API errors throw a VeloShipError, re-exported from this package:
import { VeloShipError } from "@velopeplabs/ship-convex";
try {
await veloship.createShipment(ctx, params);
} catch (err) {
if (err instanceof VeloShipError) {
err.status; // 422
err.code; // "CARRIER.ERROR", "RULES.BLOCKED", ...
err.details; // validation issues, carrier errors, the rule that fired
err.shipmentId; // set when a create-and-buy made the shipment but the purchase failed
err.requestId; // quote it when reporting a problem
}
}| Code | What to do |
| --- | --- |
| SHIPMENT.PURCHASE_UNKNOWN | reconcile, don't re-buy (above) |
| CARRIER.ERROR (422) | The carrier rejected it; fix the address or package. The shipment is created and buyable |
| CARRIER.UNAVAILABLE, CARRIER.ERROR (502, 503), RATE_LIMITED | Transient. The client already retried with backoff; retry later with the same key |
| SHIPMENT.RATES.EXPIRED | rerate, then buy |
| RULES.BLOCKED, RULES.NO_RATE, RULES.SERVICE_EXCLUDED | A shipping rule decided; details names it. apply_rules: false skips rules |
| BATCH.IN_PROGRESS | Wait for batch.completed, or pass partial: true |
| IDEMPOTENCY.MISMATCH | You reused an idempotencyKey with a different body |
| FORBIDDEN.SCOPE | The key lacks a scope; details lists the options |
When a write fails and the shipment id is known, the shipment is re-fetched into the mirror before
the error propagates, so states like purchase_unknown show up reactively. A failed call never
leaves a half-written mirror: the API is called first, then the mirror is written.
Mirror tables
Component tables, not visible in your own schema.
shipments:apiId,mode,reference,status,trackingCode,trackerId,updatedAt,labelStorageId,carrier,carrierAccountId,returnOf,tags,notes,appliedRules,excludedRates,recommendedRate,customsInfo,forms, the stored forms, anddata(the full Shipment). Indexed by id, reference, status, mode and status, tracking code, and carrier and status.trackers:apiId,mode,trackingCode,shipmentId,status,updatedAt,carrier,carrierAccountId,data.batches:apiId,mode,status,total,purchased,failed,pending,createdAt,completedAt,labelFormat, and the stored labels file.batchItems: one per order, withbatchId,index,reference,status,shipmentId,trackingCode,carrier,service,rate,packaging,erroranddata.webhooks: ids, URLs and signing secrets stored byensureWebhook.events: ids of applied webhook events, pruned after three days.
Files (labels, customs forms, merged batch labels) live in the component's file storage.
Upgrading. Releases only add optional fields, tables and indexes to these tables, and never
rename or retype a field, so existing deployments keep their data. Rows written by an earlier version
lack the newer fields (carrier, tags, ...) until the shipment is next written, by a webhook or
syncShipment; data always holds the full object as it was received. Filtering by carrier
therefore only finds rows written since. Nothing needs migrating.
Testing your app
import { convexTest } from "convex-test";
import veloship from "@velopeplabs/ship-convex/test";
const t = convexTest(schema, modules);
veloship.register(t); // or veloship.register(t, "myName")In tests, stub fetch or pass fetch in the VeloShip options; the example's
tests cover the batch flow, signed webhooks, redeliveries and out-of-order events
that way. Against a real deployment, use a sk_test_ key and the scenarios.
FAQ
Do I need the webhook? Not to use the API. Without it the mirror only changes when you call
syncShipment, syncBatch and refreshTracker, or when a write returns. The webhook is what keeps
tracking and batch results current on their own.
Why is a call "stuck" replaying an error? The API replays a stored response, including a 4xx rejection, for 24 hours per idempotency key. See idempotency.
Can I use more than one carrier? Yes. Quotes cover every enabled carrier account; filter with
carrier or carrier_account_id, and service: "cheapest" picks across them in a batch. Accounts
are managed in the VeloShip admin or with veloship.client.carrierAccounts.
Can I call the API without mirroring? Yes: veloship.client is the plain client.
Does it work in queries? The read methods do. The write methods call the network, so they need
an action. Run them from a mutation with ctx.scheduler.runAfter(0, internal.x.y, ...).
How do I get the label to a printer? downloadLabel and downloadBatchLabels return a Convex
storage URL. Open it, or fetch it from your server and send it to the printer; thermal printers
want ZPL (label_format: "ZPLII") and 4x6 paper at actual size.
What happened to getTracker? It is getTrackerByCode. listShipmentsByStatus in the
component is now listShipments, with more filters.
Where are customer claims? Customers file claims on the tracking page; they arrive as
claim.created events with source: "customer", handled in onEvent. Staff claims go through
createClaim. The request log isn't wrapped: the API only serves it to the root key.
Development
bun install
bun run build:packages # the tests import the built client
bun run --filter @velopeplabs/ship-convex test
bun run --filter @velopeplabs/ship-convex typecheckAfter changing src/component, regenerate its bindings (src/component/_generated):
cd packages/convex
CONVEX_AGENT_MODE=anonymous npx convex dev --once # first time only: a local deployment, no account
npx convex codegen --component-dir ./src/component && bun run buildTests are named *.cvxtest.ts rather than *.test.ts so the repo-root Workers vitest run doesn't
pick them up. src/client/harness.cvxtest.ts holds the mocked-fetch helpers the component tests
share; src/client/component.cvxtest.ts runs against the component's own functions and schema.
