Commands
createCommand is the write side of Elur Query. It manages mutations with
concurrency control, retry, optimistic updates, and an offline queue.
# createCommand<V, R, C>(commandKey, executeFn, options?)
import { createCommand } from "@elurjs/query";
const saveProfile = createCommand(
"profile/save",
async (payload: { name: string }, { signal }) => {
const res = await fetch("/api/profile", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
signal,
});
if (!res.ok) throw new Error("Failed");
return res.json();
},
{ mode: "latest", invalidate: ["profile"] }
);
saveProfile.execute({ name: "Deiver" }); // fire-and-forget
await saveProfile.executeAsync({ name: "Ada" }); // imperative (throws on error)# Parameters
| Parameter | Type | Description |
|---|---|---|
commandKey |
string |
Unique command key (use action: "profile/save") |
executeFn |
(variables: V, ctx: CommandContext) => Promise<R> |
Mutation function |
options |
CommandOptions<V, R, C> |
Configuration |
# CommandContext
| Field | Type | Description |
|---|---|---|
signal |
AbortSignal |
Aborts when cancelled or superseded |
commandKey |
string |
The command key |
# CommandResult<V, R>
# Signals
| Member | Type | Description |
|---|---|---|
status |
Signal<CommandStatus> |
"idle" | "pending" | "success" | "error" | "queued" |
data |
Signal<R | undefined> |
Last result |
error |
Signal<unknown> |
Last error |
variables |
Signal<V | undefined> |
Last variables |
failureCount |
Signal<number> |
Retry failure count |
inFlight |
Signal<number> |
Active executions |
queuedCount |
Signal<number> |
Offline queue length |
# Computed signals
| Member | Type | Description |
|---|---|---|
isIdle |
Signal<boolean> |
status === "idle" |
isPending |
Signal<boolean> |
status === "pending" |
isSuccess |
Signal<boolean> |
status === "success" |
isError |
Signal<boolean> |
status === "error" |
isQueued |
Signal<boolean> |
status === "queued" |
# Methods
| Member | Type | Description |
|---|---|---|
execute(v) |
(v: V) => void |
Fire-and-forget (errors go to error signal) |
executeAsync(v) |
(v: V) => Promise<R> |
Imperative (throws on error, including CommandQueuedError) |
reset() |
() => void |
Reset signals to idle (or queued if queue has items) |
cancel() |
() => void |
Abort all in-flight controllers (status → idle, no error set) |
replayQueue() |
() => Promise<void> |
Replay offline queue |
clearQueue() |
() => Promise<void> |
Clear offline queue |
dispose() |
() => void |
Remove listeners, cancel in-flight, clean global state |
# reset() semantics
reset() clears data, error, variables, and failureCount. The
status becomes "queued" if queuedCount > 0, otherwise "idle".
This means persisted offline items keep the command in "queued" state
even after a reset:
// With 3 queued items:
cmd.reset();
cmd.status.value; // "queued" (not "idle")
cmd.queuedCount.value; // 3 (unchanged — reset doesn't touch the queue)# dispose() cleanup
dispose() is idempotent and removes the command from all global
registries so it cannot interfere with other instances sharing the same
commandKey:
- Aborts all in-flight controllers (via
cancel()) - Removes the
onlineevent listener (ifqueueOffline) - Deletes the command key from
_globalCommandQueues - Deletes the command key from
_globalLatestControllers - Deletes the command key from
_globalReplayLocks
After dispose(), other commands with the same key operate independently.
# cancel() and abort behavior
cancel() aborts all in-flight AbortControllers. When a command is aborted
(either via cancel() or by being superseded in latest mode):
- The fetcher's
AbortSignalis triggered — in-flightfetch()calls should abort. onErroris NOT called — abort errors are silently swallowed.onSettledis NOT called for abort errors.errorsignal is not set — the error signal keeps its previous value.statustransitions to"idle"(if no other in-flight) — not"error".- Retry delays (
_sleep) are also cancellable — the abort rejects the sleep promise immediately.
const cmd = createCommand("search", searchFn, { mode: "latest" });
cmd.execute("a"); // starts fetch
cmd.execute("b"); // aborts "a", starts "b"
// "a" is silently discarded — no onError, no error signal, no onSettled
cmd.cancel(); // aborts "b"
// status → "idle", error stays unchanged# Callback execution order
When a command succeeds, callbacks run in this order:
variablessignal set to the inputerrorsignal cleared toundefinedonMutate(variables)→ returns contextexecuteFn(variables, ctx)→ with retries if configureddatasignal set,failureCountreset to 0onSuccess(data, variables, context)invalidate(keys)— query keys are invalidatedonSettled(data, undefined, variables, context)
When a command fails (non-abort):
variablessignal set to the inputerrorsignal cleared toundefinedonMutate(variables)→ returns contextexecuteFnfails after all retrieserrorsignal set,status→"error"onError(error, variables, context)onSettled(undefined, error, variables, context)
When a command is queued offline at execution time (isOnline() returns
false):
onMutateis not called (no optimistic update on offline enqueue)variablessignal set,errorcleared,status→"queued"onEnqueue(entry)is called (if provided)onSettledis not called — theCommandQueuedErroris thrown before_runexecutes, so the callback chain never runs
When a command fails online after all retries and shouldEnqueue returns
true:
onMutate(variables)→ returns contextexecuteFnfails after all retries- Entry is enqueued via adapter
CommandQueuedErroris thrown (withattemptsandlastErrorpopulated)onSettled(undefined, CommandQueuedError, variables, context)IS called (because the error originates inside_run)
onMutateruns once before the retry loop. It is not called again on retry. This means optimistic updates are applied a single time, not on every retry attempt.
failureCountresets to 0 on success. After a successful execution (including after retries),failureCountis set back to 0.
variablesis set beforeonMutate. This means you can readcmd.variables.valueinsideonMutateif needed, though the argument is also passed directly.
erroris cleared at the start of each execution, not on success. This means during a pending execution,error.valueisundefineduntil the execution fails.
# execute vs executeAsync
// Fire-and-forget — errors go to cmd.error signal
cmd.execute({ name: "Deiver" });
// Imperative — throws on error
try {
const result = await cmd.executeAsync({ name: "Deiver" });
} catch (err) {
if (err instanceof CommandQueuedError) {
// queued offline, will replay later
} else {
// actual error
}
}# Concurrency modes
| Mode | Behavior | Use for |
|---|---|---|
"latest" (default) |
Aborts previous in-flight, keeps latest | Search-as-you-type, form saves |
"queue" |
Serializes calls in order | Ordered mutations, sequential saves |
"parallel" |
Runs all concurrently | Independent bulk operations |
"queueOffline" |
Like queue + offline queue on disconnect |
Mobile, unreliable networks |
Status with concurrent executions:
statusreflects the last settled execution. Withparallelmode, if execution A succeeds and B fails, the finalstatusdepends on which finishes last. UseinFlightto track active count and individualdata/errorsignals for the most recent result.
statusstays"pending"during concurrent executions: every new execution setsstatusto"pending"via_incInFlight. The status is only updated to the settled value wheninFlightreaches 0 (the last execution finishes). During parallel executions,statusremains"pending"throughout.
latestmode discards stale successes: even if the fetcher resolves successfully, if a newer execution has started (token mismatch), the result is discarded with an abort error. This prevents a slow earlier request from overwriting data from a newer one.
// Latest: only the last call matters
createCommand("search", searchFn, { mode: "latest" });
// Queue: every call runs in order
createCommand("order/create", createOrderFn, { mode: "queue" });
// Parallel: all run at once
createCommand("bulk/delete", deleteFn, { mode: "parallel" });
// Offline: queue when offline, replay on reconnect
createCommand("order/create", createOrderFn, {
mode: "queueOffline",
offline: { adapter: myAdapter },
});# Options
| Field | Type | Default | Description |
|---|---|---|---|
mode |
CommandMode |
"latest" |
Concurrency strategy |
dedupeWindowMs |
number |
0 |
Anti double-tap window (ms) |
serializeByKey |
boolean |
true |
Serialize queue/latest by command key across instances |
retry |
RetryPolicy |
0 (no retries) |
Number or function |
retryDelay |
RetryDelayPolicy |
exponential backoff | Number or function |
invalidate |
string[] | ((data, vars) => string[]) |
— | Query keys to invalidate on success |
onMutate |
(vars: V) => C | Promise<C> |
— | Pre-mutation hook (optimistic updates) |
onSuccess |
(data: R, vars: V, ctx: C?) => void |
— | Success callback |
onError |
(err: unknown, vars: V, ctx: C?) => void |
— | Error callback |
onSettled |
(data, err, vars, ctx?) => void |
— | Always runs (success, error, and queued) |
offline |
OfflineQueueOptions<V, R> |
— | Offline queue config (required for queueOffline) |
Throws if
mode: "queueOffline"is set withoutoffline.adapter.
# Retry
# RetryPolicy
// Fixed retry count
retry: 3
// Function: (failureCount, error) => boolean
retry: (failureCount, error) => {
const status = (error as { status?: number })?.status;
const isTransient = status === undefined || status >= 500 || status === 429;
return isTransient && failureCount < 3;
}failureCount starts at 1 on first failure.
# RetryDelayPolicy
// Fixed delay
retryDelay: 1000
// Function: (failureCount, error) => ms
retryDelay: (failureCount) => Math.min(500 * 2 ** (failureCount - 1), 5000)Default: min(1000 * 2^(failureCount-1), 30000) — exponential backoff capped at 30s.
Retry delays are cancellable — if the abort signal triggers during a
retry delay (e.g. via cancel() or a newer execution in latest mode),
the sleep rejects immediately with an abort error. No need to wait for
the full delay before the command responds to cancellation.
# dedupeWindowMs
Prevents rapid double-execution within a time window. When a second call arrives within the window, the same Promise object is returned — both callers share the same outcome:
createCommand("like", likeFn, { dedupeWindowMs: 300 });
// Clicking twice within 300ms → only one execution, both calls share the same promiseIf the first call throws, the deduped call also throws (same promise).
execute() swallows the error; executeAsync() propagates it.
# serializeByKey
When true (default), queue and latest modes serialize by command key
across all createCommand instances with the same key. Set false for
per-instance isolation:
// Two instances share the same queue
const cmd1 = createCommand("sync", syncFn, { mode: "queue", serializeByKey: true });
const cmd2 = createCommand("sync", syncFn, { mode: "queue", serializeByKey: true });
// cmd1 and cmd2 share a single queue
// Two instances have independent queues
const cmdA = createCommand("sync", syncFn, { mode: "queue", serializeByKey: false });
const cmdB = createCommand("sync", syncFn, { mode: "queue", serializeByKey: false });
// cmdA and cmdB have separate queuesFor latest mode specifically:
serializeByKey: true— callingexecuteon any instance with the same key aborts in-flight requests on all other instances.serializeByKey: false— callingexecuteonly aborts in-flight requests within the same instance. Other instances with the same key continue running.
# Invalidation
Commands can auto-invalidate query keys on success:
// Static list
createCommand("post/create", createPost, { invalidate: ["posts/list", "feed"] });
// Dynamic — compute keys from result and variables
createCommand("post/create", createPost, {
invalidate: (data, variables) => [`posts/${data.id}`, "posts/list"],
});# Offline queue
mode: "queueOffline" requires an adapter. When offline, commands are
enqueued and replayed on reconnect.
# OfflineQueueOptions<V, R>
| Field | Type | Default | Description |
|---|---|---|---|
adapter |
CommandQueueAdapter<V> |
— | Required — persistence strategy |
isOnline |
() => boolean | Promise<boolean> |
navigator.onLine (or true if unavailable) |
Online detector |
replayOnReconnect |
boolean |
true |
Auto-replay on browser online event |
maxReplayAttempts |
number |
— | Cap replay attempts before pausing item |
shouldEnqueue |
(error, variables) => boolean |
— | Enqueue after failed execution (online path only) |
onEnqueue |
(entry) => void |
— | Called when item is queued |
onReplaySuccess |
(data, entry) => void |
— | Called on successful replay |
onReplayError |
(error, entry) => void |
— | Called on failed replay |
# Two enqueue paths
There are two distinct paths to enqueueing a command in queueOffline mode:
Offline at execution time — if
isOnline()returnsfalse, the command is enqueued immediately without calling the fetcher. NoshouldEnqueuecheck is performed.onEnqueueis called, butonSettledis not called (the error is thrown before_run).Online but execution fails after retries — after all retry attempts are exhausted,
shouldEnqueue(error, variables)is called. If it returnstrue, the command is enqueued withattemptsandlastErrorpopulated, andonSettledIS called withCommandQueuedError. If omitted orfalse, the error is thrown normally.
// Path 1: offline → enqueued immediately
await cmd.executeAsync(payload); // throws CommandQueuedError
// Path 2: online, server fails 3x, shouldEnqueue returns true → enqueued
const cmd = createCommand("orders/create", createOrderFn, {
mode: "queueOffline",
retry: 3,
offline: {
adapter: myAdapter,
shouldEnqueue: (err, vars) => {
// Only queue 5xx errors, not 4xx validation errors
const status = (err as { status?: number })?.status;
return status === undefined || status >= 500;
},
},
});# CommandQueueAdapter<V>
interface CommandQueueAdapter<TVariables> {
enqueue(entry: OfflineCommandEntry<TVariables>): Promise<void> | void;
list(commandKey?: string): Promise<OfflineCommandEntry<TVariables>[]> | OfflineCommandEntry<TVariables>[];
update(entry: OfflineCommandEntry<TVariables>): Promise<void> | void;
remove(id: string): Promise<void> | void;
}# LocalStorage adapter example
class LocalStorageQueueAdapter implements CommandQueueAdapter<CreateOrderInput> {
private key = "elur-query:offline-commands";
private read(): OfflineCommandEntry<CreateOrderInput>[] {
const raw = localStorage.getItem(this.key);
return raw ? JSON.parse(raw) : [];
}
private write(items: OfflineCommandEntry<CreateOrderInput>[]) {
localStorage.setItem(this.key, JSON.stringify(items));
}
enqueue(entry) { this.write([...this.read(), entry]); }
list(commandKey?) {
const all = this.read();
return commandKey ? all.filter(i => i.commandKey === commandKey) : all;
}
update(entry) { this.write(this.read().map(i => i.id === entry.id ? entry : i)); }
remove(id) { this.write(this.read().filter(i => i.id !== id)); }
}# OfflineCommandEntry<V>
Represents a queued command stored by the adapter:
| Field | Type | Description |
|---|---|---|
id |
string |
Unique ID ("commandKey:timestamp:random") |
commandKey |
string |
The command key this entry belongs to |
variables |
V |
Serialized payload to replay |
attempts |
number |
Replay attempt count |
createdAt |
number |
Timestamp when enqueued |
lastError |
string |
Last replay error message (if any) |
# CommandQueuedError
Thrown by executeAsync when a command is queued offline:
import { CommandQueuedError } from "@elurjs/query";
try {
await cmd.executeAsync({ id: "A-100", total: 42 });
} catch (error) {
if (error instanceof CommandQueuedError) {
console.log("Queued:", error.entry.id);
console.log("Code:", error.code); // "COMMAND_QUEUED_OFFLINE"
}
}| Property | Type | Description |
|---|---|---|
entry |
OfflineCommandEntry<V> |
The queued entry with variables and metadata |
code |
"COMMAND_QUEUED_OFFLINE" |
Stable error code for programmatic checks |
name |
"CommandQueuedError" |
Error name |
message |
string |
"Command queued offline: <commandKey>" |
execute (fire-and-forget) does not throw — check status.value === "queued"
instead.
onSettledand the two enqueue paths:onSettledis called withCommandQueuedErroronly on theshouldEnqueuepath (online failure after retries). On the offline-at-execution-time path,onSettledis not called — useonEnqueueto track offline enqueue events.
entry.attemptsandentry.lastError: On the offline-at-execution-time path,attemptsis0andlastErroris absent. On theshouldEnqueuepath,attemptsis the failure count andlastErroris the error message.
// onSettled receives CommandQueuedError only on the shouldEnqueue path.
// For offline-at-execution-time, use onEnqueue instead.
createCommand("orders/create", createOrderFn, {
mode: "queueOffline",
offline: {
adapter: myAdapter,
shouldEnqueue: (err) => isServerError(err),
onEnqueue: (entry) => console.log("Queued offline:", entry.id),
},
onSettled: (data, error, vars) => {
if (error instanceof CommandQueuedError) {
// Only reached via shouldEnqueue path (online failure → enqueue)
console.log("Failed then queued:", error.entry.id, error.entry.lastError);
} else if (error) {
console.error("Failed:", error);
} else {
console.log("Success:", data);
}
},
});# Manual replay
await cmd.replayQueue(); // replay all queued items
await cmd.clearQueue(); // discard all queued itemsReplay preserves command ordering — if one item fails, replay stops to maintain sequence.
Replay behavior details:
- Items are sorted by
createdAt(oldest first). - Items that exceeded
maxReplayAttemptsare skipped but not removed — they stay in the queue. UseclearQueue()to discard them. replayQueue()is globally locked per command key — concurrent calls while a replay is running are no-ops.- Replay uses
_runByModeinternally, so the command'smodestill applies. ForqueueOffline, replayed items run sequentially through the queue path, preserving order. - After replay, if the queue is empty and no in-flight requests remain,
statustransitions to"idle". - If
isOnline()returnsfalse, replay returns immediately without processing any items.
# Initial queue load
When a queueOffline command is created, queuedCount is loaded from
the adapter immediately. This means persisted items from a previous
session are reflected on startup:
// Previous session queued 3 items to localStorage
const cmd = createCommand("orders/create", createOrderFn, {
mode: "queueOffline",
offline: { adapter: localStorageAdapter },
});
// queuedCount is already 3 on creation
console.log(cmd.queuedCount.value); // 3
// Replay them on startup if online
await cmd.replayQueue();