@ricglz/convex-offline
v0.2.0
Published
Typed offline mutation policy and outbox state for Convex applications
Readme
@ricglz/convex-offline
Typed, framework-independent operation manifests and replay policy for Convex applications.
Applications declare each offline operation once. The declaration connects a stable persisted key to its generated Convex mutation reference, supported payload versions, runtime codecs, and replay argument transformations.
Install
pnpm add @ricglz/convex-offlineComplete example
import { Schema } from "effect";
import { z } from "zod";
import {
createOperationManifest,
defineOfflineOperation,
defineOperationVersion,
executeOfflineMutation,
replayOutbox,
type ManifestOutboxEntry,
type RuntimeCodec,
} from "@ricglz/convex-offline";
import { api } from "./convex/_generated/api";
import { getFunctionName } from "convex/server";
import { convex } from "./convexClient";
import { outbox } from "./storage";
// RuntimeCodec is intentionally small. Adapt any validator at the boundary.
const fromZod = <Output>(schema: z.ZodType<Output>): RuntimeCodec<Output> => ({
decode: (value) => schema.parse(value),
});
const fromEffect = <Output>(
schema: Schema.Schema<Output>,
): RuntimeCodec<Output> => ({
decode: Schema.decodeUnknownSync(schema),
});
const createV1 = fromZod(z.string());
const createV2 = fromEffect(Schema.Struct({ text: Schema.String }));
const renameV1 = fromZod(z.object({ id: z.string(), text: z.string() }));
export const offlineOperations = createOperationManifest({
// Stable storage key. It remains unchanged if Convex paths are reorganized.
"todo.create": defineOfflineOperation({
mutation: api.todos.create,
currentVersion: 2,
versions: {
1: defineOperationVersion(createV1, (text, metadata) => ({
text,
clientCreatedAt: metadata.createdAt,
})),
2: defineOperationVersion(createV2, (payload, metadata) => ({
text: payload.text,
clientCreatedAt: metadata.createdAt,
})),
},
}),
"todo.rename": defineOfflineOperation({
mutation: api.todos.rename,
currentVersion: 1,
versions: {
// Omit transform when persisted payload already equals mutation args.
1: defineOperationVersion(renameV1),
},
}),
}, {
// Runtime lookup only. Function paths are never written to storage.
mutationIdentity: getFunctionName,
});
export type AppOutboxEntry = ManifestOutboxEntry<typeof offlineOperations>;
export async function createTodo(text: string, ownerId: string | null) {
return executeOfflineMutation({
strategy: "online-first",
isOnline: navigator.onLine,
ownerId,
operationName: "todo.create",
payload: { text }, // Inferred from current version (2).
manifest: offlineOperations,
runMutation: () =>
convex.mutation(api.todos.create, {
text,
clientCreatedAt: Date.now(),
}),
queueMutation: async (operationName, version, payload, options) => {
const id = await outbox.add({
operationName, // "todo.create", never a Convex path
version, // 2
payload,
ownerId: options.ownerId,
queuedWhileOnline: options.queuedWhileOnline,
createdAt: Date.now(),
status: "pending",
retries: 0,
});
return { ok: true, status: "queued", id };
},
});
}
export async function drain(ownerId: string) {
// Storage and owner partitioning remain application-owned.
const entries = await outbox.pendingForOwner(ownerId);
const result = await replayOutbox({
manifest: offlineOperations,
entries,
runMutation: async (reference, args) => {
const result = await convex.mutation(reference, args);
return result.ok
? { ok: true }
: { ok: false, error: result.error };
},
});
// replayOutbox runs sequentially by enqueue id. It stops at the first
// decoder, transform, domain, or thrown mutation failure.
for (const id of result.replayedIds) await outbox.remove(id);
if (!result.ok) {
await outbox.recordFailure(result.failedEntry.id, result.error);
}
}manifest.getOperationName(reference) performs reverse lookup for adapters
that receive a mutation reference. Identity lookup first uses object identity,
then the optional mutationIdentity function. With Convex, pass
getFunctionName; paths remain runtime metadata and are not persisted.
Mutation runners return an explicit outcome. This prevents resolved domain
failures such as { ok: false, error } from being removed as successful
replays. For mutations that do not return a domain result, await the mutation
and return { ok: true }.
Standard Schema works through the same adapter shape. Call its ~standard
validator inside decode; return the value or throw when issues are present.
Both synchronous and asynchronous codecs are supported.
Persisted contract
Persist these values:
- stable
operationName - numeric
version - codec-validated
payload - queue metadata such as
id,createdAt, owner, status, and retries
Never persist generated function paths, mutation references, callbacks, or
closures. Operation keys are storage protocol identifiers. Renaming one needs
an application-owned data migration. Removing a version rejects old entries as
unsupported_version; keep decoders until stored entries using that version
have drained or been migrated.
ManifestOutboxEntry<typeof manifest> derives the complete discriminated
entry union. OperationName, OperationPayload, CurrentOperationPayload,
OperationVersionNumber, MutationArguments, and
ManifestMutationReference are available for adapter types.
Package boundary
This package owns:
- manifest type inference and construction
- runtime operation/version lookup and decoding
- replay argument transformation and mutation dispatch
- sequential FIFO replay with stop-on-failure behavior
- online-first and queue-first execution policy
- failure classification and outbox status helpers
The application owns:
- IndexedDB or other durable storage and migrations
- authentication and owner partitioning
- connectivity detection and drain scheduling
- generated Convex references and client instance
- notifications, logging, retries, and failure persistence
- optimistic cache updates and rollback behavior
- semantic idempotency and server-side deduplication
replayOutbox accepts a structural mutation runner rather than a Convex client.
React, non-React, server, test, and future client adapters can all supply that
function. No annotations or code generation are required.
Queue and status helpers
executeOfflineMutation preserves online-first and queue-first strategies.
The helpers normalizeSyncFailure, statusAfterFailure, isPendingEntry,
isVisiblePendingEntry, shouldDrainOutbox, and
shouldShowOfflineIndicator remain storage/UI independent.
Replay does not mutate entry status. Mark entries syncing, remove successes,
increment retries, and persist failed or auth_required states in the
application transaction model.
Migrating from 0.1
Version 0.2 replaces mutation-path registries with stable operation manifests. It is a breaking pre-1.0 release.
- Replace
createOfflineMutationRegistrywithcreateOperationManifest,defineOfflineOperation, anddefineOperationVersion. - Replace persisted
mutationNamewith stableoperationName. - Replace persisted
argswith versionedpayloadand addversion. - Pass
operationName,payload, andmanifesttoexecuteOfflineMutation. - Replace application replay switches with
replayOutbox. - Make
runMutationreturn{ ok: true }or{ ok: false, error }after inspecting resolved domain results.
Existing 0.1 outbox rows need an application-owned storage migration before
upgrading. Map old Convex paths to stable operation names, set initial version,
and move args to payload. Do not publish 0.2 to consumers until that
migration or deliberate outbox reset is deployed.
